mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-29 03:12:10 +08:00
Refine Claude app model discovery and fallback routing
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user