Refine Claude app model discovery and fallback routing

This commit is contained in:
musistudio
2026-07-14 14:46:54 +08:00
parent 1308eb4510
commit 7b0e203693
4 changed files with 206 additions and 65 deletions
@@ -1,7 +1,7 @@
import type { AppConfig } from "@ccr/core/contracts/app";
import { normalizeProfileScopeValue } from "@ccr/core/contracts/app";
import { availableGatewayModelIds, normalizeProfileScopeValue } from "@ccr/core/contracts/app";
import { modelRegistryForConfig } from "@ccr/core/routing/model-registry";
export const CLAUDE_APP_FALLBACK_MODEL = "claude-sonnet-4-5";
export const CLAUDE_APP_ONE_MILLION_CONTEXT_SUFFIX = "[1m]";
const CLAUDE_APP_ENCODED_ROUTE_PREFIX = "anthropic/claude-ccr-h";
@@ -25,9 +25,14 @@ export type ClaudeAppGatewayInferenceModel = {
supports1m?: true;
};
export function inferClaudeAppGatewayTargetModel(config: Pick<AppConfig, "profile">): string {
return inferGlobalClaudeProfileModel(config) ||
CLAUDE_APP_FALLBACK_MODEL;
export function inferClaudeAppGatewayTargetModel(
config: Pick<AppConfig, "Providers" | "profile" | "virtualModelProfiles">
): string | undefined {
const profileModel = inferGlobalClaudeProfileModel(config);
const resolvedProfileModel = profileModel
? canonicalClaudeAppGatewayTargetModel(profileModel, config)
: undefined;
return resolvedProfileModel ?? availableGatewayModelIds(config)[0];
}
export function buildClaudeAppGatewayModelRoutes(
@@ -102,13 +107,11 @@ export function buildClaudeAppGatewayInferenceModels(
options: ClaudeAppGatewayModelRouteOptions = {}
): ClaudeAppGatewayInferenceModel[] {
const routes = buildClaudeAppGatewayModelRoutes(config, options);
return routes.length
? routes.map((route) => ({
labelOverride: route.displayName,
name: route.id,
...(route.oneMillionContext ? { supports1m: true as const } : {})
}))
: [{ labelOverride: "Claude Sonnet 4.5", name: CLAUDE_APP_FALLBACK_MODEL }];
return routes.map((route) => ({
labelOverride: route.displayName,
name: route.id,
...(route.oneMillionContext ? { supports1m: true as const } : {})
}));
}
export function hasClaudeAppGatewayOneMillionContextSuffix(id: string): boolean {
@@ -129,54 +132,28 @@ function inferGlobalClaudeProfileModel(config: Pick<AppConfig, "profile">): stri
}
function claudeAppGatewayTargetModels(config: Pick<AppConfig, "Providers" | "profile" | "virtualModelProfiles">): string[] {
const baseEntries = config.Providers.flatMap((provider) => {
const providerName = provider.name?.trim();
if (!providerName || !Array.isArray(provider.models)) {
return [];
}
return provider.models.flatMap((rawModel) => {
const modelName = rawModel.trim();
return modelName ? [{ modelName, providerName }] : [];
});
});
const defaultTargetModel = inferClaudeAppGatewayTargetModel(config);
return uniqueStrings([
inferClaudeAppGatewayTargetModel(config),
...baseEntries.map((entry) => `${entry.providerName}/${entry.modelName}`),
...(config.virtualModelProfiles ?? []).flatMap((profile) => {
if (
profile.enabled === false ||
profile.materialization?.enabled === false ||
profile.materialization?.includeInGatewayModels === false
) {
return [];
}
const derivedModels = baseEntries.flatMap((entry) => [
...(profile.match?.prefixes ?? []).flatMap((prefix) => {
const normalizedPrefix = prefix.trim();
return normalizedPrefix ? [`${entry.providerName}/${normalizedPrefix}${entry.modelName}`] : [];
}),
...(profile.match?.suffixes ?? []).flatMap((suffix) => {
const normalizedSuffix = suffix.trim();
return normalizedSuffix ? [`${entry.providerName}/${entry.modelName}${normalizedSuffix}`] : [];
})
]);
return [
...derivedModels,
...(profile.match?.exactAliases ?? []).flatMap((alias) => {
const normalizedAlias = alias.trim();
if (!normalizedAlias) {
return [];
}
return normalizedAlias.toLowerCase().startsWith("fusion/")
? [normalizedAlias]
: [`Fusion/${normalizedAlias}`];
})
];
})
...(defaultTargetModel ? [defaultTargetModel] : []),
...availableGatewayModelIds(config)
]);
}
function canonicalClaudeAppGatewayTargetModel(
model: string,
config: Pick<AppConfig, "Providers" | "virtualModelProfiles">
): string | undefined {
const oneMillionContext = hasClaudeAppGatewayOneMillionContextSuffix(model);
const resolved = modelRegistryForConfig(config).resolve(stripClaudeAppGatewayOneMillionContextSuffix(model));
if (!resolved) {
return undefined;
}
return oneMillionContext
? `${resolved.canonicalSelector}${CLAUDE_APP_ONE_MILLION_CONTEXT_SUFFIX}`
: resolved.canonicalSelector;
}
function claudeAppGatewaySupportsOneMillionContext(
model: string,
options: ClaudeAppGatewayModelRouteOptions
@@ -3,7 +3,7 @@
*/
import type { IncomingHttpHeaders } from "node:http";
import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app";
import { CLAUDE_APP_FALLBACK_MODEL, buildClaudeAppGatewayModelRoutes, inferClaudeAppGatewayTargetModel, resolveClaudeAppGatewayRouteModel } from "@ccr/core/agents/claude-app/gateway-routes";
import { buildClaudeAppGatewayModelRoutes, resolveClaudeAppGatewayRouteModel } from "@ccr/core/agents/claude-app/gateway-routes";
import { normalizeRouteSelector } from "@ccr/core/routing/model-registry";
import { findModelCatalogEntry, modelCatalogMaxInputTokens, modelCatalogMaxOutputTokens, readCatalogCapability, type ModelCatalogEntry } from "@ccr/core/gateway/model-catalog";
import { stringValue } from "@ccr/core/gateway/internal/value";
@@ -50,7 +50,7 @@ export function prepareClaudeCodeDiscoveredModelRequest(
}
export function prepareClaudeAppFallbackModelRequest(
export function prepareClaudeAppDiscoveredModelRequest(
config: AppConfig,
method: string,
path: string,
@@ -70,15 +70,14 @@ export function prepareClaudeAppFallbackModelRequest(
return undefined;
}
const routeModel = resolveClaudeAppGatewayRouteModel(normalizedModel, config, claudeAppGatewayModelRouteOptions);
const routedModel = routeModel ??
(normalizedModel.toLowerCase() === CLAUDE_APP_FALLBACK_MODEL ? inferClaudeAppGatewayTargetModel(config) : undefined);
const routedModel = resolveClaudeAppGatewayRouteModel(
normalizedModel,
config,
claudeAppGatewayModelRouteOptions
);
if (!routedModel || routedModel.toLowerCase() === normalizedModel.toLowerCase()) {
return undefined;
}
if (isConfiguredGatewayModelSelector(normalizedModel, config) && !routeModel) {
return undefined;
}
return {
body: serializeJsonBodyWithModel(parsedBody, routedModel),
@@ -11,7 +11,7 @@ import { recordProviderCredentialOutcome } from "@ccr/core/providers/credential-
import { codexApplyPatchBridgeResponseStream, prepareCodexApplyPatchBridgeRequest } from "@ccr/core/gateway/features/codex-patch-bridge";
import { prepareCursorOpenAICompatChatBody } from "@ccr/core/gateway/features/cursor-compat";
import { filteredResponseHeaders, formatError, forwardHeaders, inferGatewayClient, parseJsonObject, readRequestBody, sendJson, shouldCaptureGatewayUsage, shouldSendBody, stripLocalGatewayAuthHeaders } from "@ccr/core/gateway/http/io";
import { createGatewayModelsResponse, prepareClaudeAppFallbackModelRequest, prepareClaudeCodeDiscoveredModelRequest, shouldServeGatewayModelsResponse } from "@ccr/core/gateway/features/model-discovery";
import { createGatewayModelsResponse, prepareClaudeAppDiscoveredModelRequest, prepareClaudeCodeDiscoveredModelRequest, shouldServeGatewayModelsResponse } from "@ccr/core/gateway/features/model-discovery";
import { resolveProviderLogName, resolveResponseProviderProtocol, sanitizeHeaderValue } from "@ccr/core/providers/runtime-topology";
import { createBodySampler, shouldRecordRequestLogs } from "@ccr/core/observability/raw-trace-sync";
import { endpoint } from "@ccr/core/gateway/core-runtime/supervisor";
@@ -69,7 +69,7 @@ export class GatewayRequestPipeline {
headers["x-ccr-claude-model-discovery"] = sanitizeHeaderValue(claudeModelRewrite.diagnostic);
bodyToForward = claudeModelRewrite.body;
}
const claudeAppModelRewrite = prepareClaudeAppFallbackModelRequest(this.config, method, path, bodyToForward);
const claudeAppModelRewrite = prepareClaudeAppDiscoveredModelRequest(this.config, method, path, bodyToForward);
if (claudeAppModelRewrite) {
headers["x-ccr-claude-app-model-rewrite"] = sanitizeHeaderValue(claudeAppModelRewrite.diagnostic);
bodyToForward = claudeAppModelRewrite.body;
@@ -0,0 +1,165 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildClaudeAppGatewayInferenceModels,
buildClaudeAppGatewayModelRoutes,
inferClaudeAppGatewayTargetModel,
resolveClaudeAppGatewayRouteModel
} from "../../packages/core/src/agents/claude-app/gateway-routes.ts";
import {
createGatewayModelsResponse,
prepareClaudeAppDiscoveredModelRequest
} from "../../packages/core/src/gateway/features/model-discovery.ts";
import { ModelRegistry } from "../../packages/core/src/routing/model-registry.ts";
function createConfig({ profileModel, providers = [], virtualModelProfiles = [] } = {}) {
return {
Providers: providers.map(({ models, name }) => ({ models, name })),
profile: {
enabled: true,
profiles: profileModel === undefined
? []
: [{
agent: "claude-code",
enabled: true,
id: "claude-code-global",
model: profileModel,
name: "Claude Code",
scope: "global"
}]
},
virtualModelProfiles
};
}
function createClaudeModelsResponse(config) {
return createGatewayModelsResponse(config, { "user-agent": "claude-app/1.0" });
}
function assertPublishedRoutesResolveUniquely(config) {
const registry = new ModelRegistry(config);
const routes = buildClaudeAppGatewayModelRoutes(config);
const response = createClaudeModelsResponse(config);
assert.deepEqual(response.data.map((model) => model.id), routes.map((route) => route.id));
for (const route of routes) {
assert.equal(resolveClaudeAppGatewayRouteModel(route.id, config), route.targetModel);
assert.equal(registry.resolve(route.targetModel)?.canonicalSelector, route.targetModel);
}
}
test("issue 1535 Claude App discovery defaults to the first configured provider model", () => {
const config = createConfig({
providers: [
{ models: ["gpt-4.1"], name: "Provider-1" },
{ models: ["claude-sonnet-4-5"], name: "Provider-2" }
]
});
const routes = buildClaudeAppGatewayModelRoutes(config);
const response = createClaudeModelsResponse(config);
assert.equal(inferClaudeAppGatewayTargetModel(config), "Provider-1/gpt-4.1");
assert.equal(routes[0].targetModel, "Provider-1/gpt-4.1");
assert.equal(response.first_id, routes[0].id);
assert.equal(routes.some((route) => route.targetModel === "claude-sonnet-4-5"), false);
assertPublishedRoutesResolveUniquely(config);
});
test("issue 1535 Claude App discovery canonicalizes a uniquely configured bare profile model", () => {
const config = createConfig({
profileModel: "claude-sonnet-4-5",
providers: [
{ models: ["gpt-4.1"], name: "Provider-1" },
{ models: ["claude-sonnet-4-5"], name: "Provider-2" }
]
});
const routes = buildClaudeAppGatewayModelRoutes(config);
assert.equal(inferClaudeAppGatewayTargetModel(config), "Provider-2/claude-sonnet-4-5");
assert.equal(routes[0].targetModel, "Provider-2/claude-sonnet-4-5");
assertPublishedRoutesResolveUniquely(config);
});
test("issue 1535 duplicate provider model names keep distinct deterministic Claude App routes", () => {
const config = createConfig({
profileModel: "claude-sonnet-4-5",
providers: [
{ models: ["claude-sonnet-4-5"], name: "Provider-1" },
{ models: ["claude-sonnet-4-5"], name: "Provider-2" }
]
});
const routes = buildClaudeAppGatewayModelRoutes(config);
assert.equal(inferClaudeAppGatewayTargetModel(config), "Provider-1/claude-sonnet-4-5");
assert.deepEqual(
routes.map((route) => route.targetModel),
["Provider-1/claude-sonnet-4-5", "Provider-2/claude-sonnet-4-5"]
);
assert.equal(new Set(routes.map((route) => route.id.toLowerCase())).size, 2);
assertPublishedRoutesResolveUniquely(config);
});
test("issue 1535 historical fallback IDs are never special-cased", () => {
const configuredLegacyModel = createConfig({
providers: [
{ models: ["gpt-4.1"], name: "Provider-1" },
{ models: ["claude-sonnet-4-5"], name: "Provider-2" }
]
});
const unconfiguredLegacyModel = createConfig({
providers: [{ models: ["gpt-4.1"], name: "Provider-1" }]
});
const body = Buffer.from(JSON.stringify({ messages: [], model: "claude-sonnet-4-5" }));
assert.equal(prepareClaudeAppDiscoveredModelRequest(
configuredLegacyModel,
"POST",
"/v1/messages",
body
), undefined);
assert.equal(prepareClaudeAppDiscoveredModelRequest(
unconfiguredLegacyModel,
"POST",
"/v1/messages",
body
), undefined);
assert.equal(
buildClaudeAppGatewayModelRoutes(unconfiguredLegacyModel)
.some((route) => route.id === "claude-sonnet-4-5" || route.targetModel === "claude-sonnet-4-5"),
false
);
});
test("issue 1535 Claude App discovery publishes no synthetic model without configured providers", () => {
const config = createConfig();
const response = createClaudeModelsResponse(config);
assert.equal(inferClaudeAppGatewayTargetModel(config), undefined);
assert.deepEqual(buildClaudeAppGatewayModelRoutes(config), []);
assert.deepEqual(buildClaudeAppGatewayInferenceModels(config), []);
assert.deepEqual(response.data, []);
assert.equal(response.first_id, null);
assert.equal(response.last_id, null);
assert.equal(
prepareClaudeAppDiscoveredModelRequest(
config,
"POST",
"/v1/messages",
Buffer.from(JSON.stringify({ messages: [], model: "claude-sonnet-4-5" }))
),
undefined
);
});
test("issue 1535 canonical profile resolution preserves the Claude App 1M context variant", () => {
const config = createConfig({
profileModel: "claude-opus-4-1[1m]",
providers: [{ models: ["claude-opus-4-1"], name: "Anthropic" }]
});
const routes = buildClaudeAppGatewayModelRoutes(config);
assert.equal(inferClaudeAppGatewayTargetModel(config), "Anthropic/claude-opus-4-1[1m]");
assert.equal(routes[0].targetModel, "Anthropic/claude-opus-4-1");
assert.equal(routes[0].oneMillionContext, true);
assertPublishedRoutesResolveUniquely(config);
});