mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-28 19:01:32 +08:00
Qualify target model selectors with routed provider
This commit is contained in:
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user