diff --git a/packages/core/src/gateway/upstream/executor.ts b/packages/core/src/gateway/upstream/executor.ts index fa5bd0cd..416c10ef 100644 --- a/packages/core/src/gateway/upstream/executor.ts +++ b/packages/core/src/gateway/upstream/executor.ts @@ -610,12 +610,19 @@ function prepareUpstreamCredentialAttempt(input: { const credentials = activeProviderCredentials(target.provider); if (credentials.length === 0) { const preserveModelSelector = shouldPreserveCapabilityModelSelector(input.attempt.body, target); + const targetHeaders = targetProviderFallbackHeaders(attemptHeaders, target.provider, target.protocol); + const targetBody = target.body ?? normalizedBody?.body ?? input.attempt.body; + const providerQualifiedTargetBody = providerQualifiedTargetModelBody( + targetBody, + target.model, + targetHeaders["x-target-provider"] + ); return { ...input.attempt, - body: attemptBody(preserveModelSelector ? input.attempt.body : target.body ?? normalizedBody?.body ?? input.attempt.body), + body: attemptBody(preserveModelSelector ? input.attempt.body : providerQualifiedTargetBody ?? targetBody), headers: preserveModelSelector ? clearTargetProviderHeaders(attemptHeaders) - : targetProviderFallbackHeaders(attemptHeaders, target.provider, target.protocol) + : targetHeaders }; } @@ -732,6 +739,22 @@ function shouldPreserveCapabilityModelSelector(body: Buffer | undefined, target: } +function providerQualifiedTargetModelBody( + body: Buffer | undefined, + model: string | undefined, + providerSelector: string | undefined +): Buffer | undefined { + if (!model || !providerSelector || !parseProviderModelSelector(model)) { + return undefined; + } + const parsedBody = parseJsonObjectSafe(body); + if (!parsedBody) { + return undefined; + } + return serializeJsonBodyWithModel(parsedBody, `${providerSelector}/${model}`); +} + + function resolvePlannedProviderCredentialRoutingTarget( attempt: UpstreamAttempt, path: string diff --git a/packages/core/test/unit/gateway/upstream-executor.test.mjs b/packages/core/test/unit/gateway/upstream-executor.test.mjs index a7e17807..ae8ab61f 100644 --- a/packages/core/test/unit/gateway/upstream-executor.test.mjs +++ b/packages/core/test/unit/gateway/upstream-executor.test.mjs @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { buildClaudeAppGatewayModelRoutes } from "@ccr/core/agents/claude-app/gateway-routes.ts"; +import { prepareClaudeAppDiscoveredModelRequest } from "@ccr/core/gateway/features/model-discovery.ts"; import { fetchUpstreamWithFallback, prepareGatewayUpstreamAttemptForTest } from "@ccr/core/gateway/upstream/executor.ts"; import { RequestRouteTraceRecorder } from "@ccr/core/observability/route-trace.ts"; @@ -116,6 +118,63 @@ test("target-provider routing preserves slash-namespaced model ids", () => { } }); +test("Claude App OpenRouter routes do not send conflicting vendor-prefixed model selectors to the core gateway", () => { + const targetModel = "OpenRouter/google/gemini-3.7-flash"; + const config = { + Providers: [ + { + apiKey: "openrouter-key", + capabilities: [ + { baseUrl: "https://openrouter.ai/api/v1", type: "openai_chat_completions" }, + { baseUrl: "https://openrouter.ai/api/v1", type: "openai_responses" } + ], + id: "openrouter", + models: ["google/gemini-3.7-flash"], + name: "OpenRouter" + } + ], + Router: { fallback: { mode: "off", models: [], retryCount: 0 }, rules: [] }, + profile: { + enabled: true, + profiles: [ + { + agent: "claude-code", + enabled: true, + id: "claude-code-openrouter", + model: targetModel, + name: "Claude Code OpenRouter", + scope: "global" + } + ] + }, + virtualModelProfiles: [] + }; + const route = buildClaudeAppGatewayModelRoutes(config).find((item) => item.targetModel === targetModel); + assert.ok(route); + + const rewrite = prepareClaudeAppDiscoveredModelRequest( + config, + "POST", + "/v1/messages", + Buffer.from(JSON.stringify({ max_tokens: 8, messages: [{ role: "user", content: "hello" }], model: route.id })) + ); + assert.equal(rewrite?.routedModel, targetModel); + + const rewrittenBody = JSON.parse(rewrite.body.toString("utf8")); + const attempt = prepareGatewayUpstreamAttemptForTest({ + body: rewrittenBody, + config, + headers: {}, + method: "POST", + path: "/v1/messages", + routedModel: rewrite.routedModel + }); + + assert.equal(attempt.headers["x-target-provider"], "openrouter::openai_chat_completions"); + assert.equal(coreGatewayTargetProviderConflict(attempt), false); + assert.equal(attempt.body.model, "openrouter::openai_chat_completions/google/gemini-3.7-flash"); +}); + test("target-provider routing keeps vendor-prefixed model ids even when the prefix names another provider", () => { const config = { Providers: [ @@ -156,6 +215,52 @@ test("target-provider routing keeps vendor-prefixed model ids even when the pref assert.equal(attempt.logicalProvider, "Groq"); }); +function coreGatewayTargetProviderConflict(attempt) { + const bodyModel = typeof attempt.body?.model === "string" ? attempt.body.model : ""; + const targetProvider = attempt.headers?.["x-target-provider"]; + if (!bodyModel || !targetProvider) { + return false; + } + const slashIndex = bodyModel.indexOf("/"); + if (slashIndex <= 0 || slashIndex >= bodyModel.length - 1) { + return false; + } + const providerHint = bodyModel.slice(0, slashIndex); + if (providerHint === targetProvider) { + return false; + } + const modelProvider = coreGatewayBuiltinProvider(providerHint); + const targetProviderType = coreGatewayProviderType(targetProvider); + return Boolean(modelProvider && targetProviderType && modelProvider !== targetProviderType); +} + +function coreGatewayProviderType(providerSelector) { + if (providerSelector.includes("::openai_chat_completions") || providerSelector.includes("::openai_responses")) { + return "openai"; + } + if (providerSelector.includes("::anthropic_messages")) { + return "anthropic"; + } + if (providerSelector.includes("::gemini_generate_content") || providerSelector.includes("::gemini_interactions")) { + return "gemini"; + } + return coreGatewayBuiltinProvider(providerSelector); +} + +function coreGatewayBuiltinProvider(value) { + const normalized = value.trim().toLowerCase(); + if (normalized === "openai") { + return "openai"; + } + if (normalized === "anthropic" || normalized === "claude") { + return "anthropic"; + } + if (normalized === "gemini" || normalized === "google") { + return "gemini"; + } + return undefined; +} + test("target-provider routing preserves slash model ids for providers without explicit capabilities", () => { const config = { Providers: [