Qualify target model selectors with routed provider

This commit is contained in:
musistudio
2026-08-15 23:47:32 +08:00
parent 83e01c3077
commit 22b68fd907
2 changed files with 130 additions and 2 deletions
+25 -2
View File
@@ -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
@@ -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: [