mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-11-01 05:27:39 +08:00
fix: disambiguate bare model ids by request protocol in model registry
A bare model id registered under multiple providers with disjoint protocol capabilities resolved to undefined, so hop 1 failed locally and the request silently fell through to the global fallback chain on a different model. ModelRegistry.resolve() now accepts the request protocol and uses it to break ties between bare-id matches; genuinely ambiguous ids (no protocol given, same protocol declared on both providers, or providers without capability metadata) keep the previous undefined behavior instead of guessing. Fixes #1860.
This commit is contained in:
@@ -1051,7 +1051,7 @@ function resolveCcrGatewayRoute(
|
||||
|
||||
const publicModel = resolveGatewayPublicModelId(routedModel, config) ?? routedModel;
|
||||
const modelRegistry = modelRegistryForConfig(config);
|
||||
const resolved = modelRegistry.resolve(publicModel) ??
|
||||
const resolved = modelRegistry.resolve(publicModel, { protocol }) ??
|
||||
resolveProfileProviderModel(config, requestInput.request?.headers, publicModel, modelRegistry);
|
||||
if (!resolved) {
|
||||
return undefined;
|
||||
|
||||
@@ -3,12 +3,14 @@ import {
|
||||
availableGatewayModelIds,
|
||||
isGatewayProviderEnabled,
|
||||
type AppConfig,
|
||||
type GatewayProviderConfig
|
||||
type GatewayProviderConfig,
|
||||
type GatewayProviderProtocol
|
||||
} from "@ccr/core/contracts/app";
|
||||
import type { RouteModelRef } from "@ccr/core/routing/contracts";
|
||||
|
||||
export type ResolveRouteModelOptions = {
|
||||
providerName?: string;
|
||||
protocol?: GatewayProviderProtocol;
|
||||
};
|
||||
|
||||
export class ModelRegistry {
|
||||
@@ -53,17 +55,20 @@ export class ModelRegistry {
|
||||
};
|
||||
}
|
||||
|
||||
const exactMatches = this.providerModelMatches(normalized, false);
|
||||
if (exactMatches.length === 1) {
|
||||
return providerModelRef(exactMatches[0].provider, exactMatches[0].model, normalized);
|
||||
}
|
||||
if (exactMatches.length > 1) {
|
||||
return undefined;
|
||||
const exactMatch = this.disambiguateByProtocol(
|
||||
this.providerModelMatches(normalized, false),
|
||||
options.protocol
|
||||
);
|
||||
if (exactMatch) {
|
||||
return providerModelRef(exactMatch.provider, exactMatch.model, normalized);
|
||||
}
|
||||
|
||||
const caseInsensitiveMatches = this.providerModelMatches(normalized, true);
|
||||
return caseInsensitiveMatches.length === 1
|
||||
? providerModelRef(caseInsensitiveMatches[0].provider, caseInsensitiveMatches[0].model, normalized)
|
||||
const caseInsensitiveMatch = this.disambiguateByProtocol(
|
||||
this.providerModelMatches(normalized, true),
|
||||
options.protocol
|
||||
);
|
||||
return caseInsensitiveMatch
|
||||
? providerModelRef(caseInsensitiveMatch.provider, caseInsensitiveMatch.model, normalized)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -117,6 +122,30 @@ export class ModelRegistry {
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the single match among bare-id candidates, using the request protocol
|
||||
* to break ties when the same id is registered under multiple providers with
|
||||
* disjoint protocol capabilities. Returns undefined when the match is not
|
||||
* unique (keeps the pre-existing behavior for genuinely ambiguous ids).
|
||||
*/
|
||||
private disambiguateByProtocol(
|
||||
matches: Array<{ model: string; provider: GatewayProviderConfig }>,
|
||||
protocol: GatewayProviderProtocol | undefined
|
||||
): { model: string; provider: GatewayProviderConfig } | undefined {
|
||||
if (matches.length === 1) {
|
||||
return matches[0];
|
||||
}
|
||||
if (matches.length > 1 && protocol) {
|
||||
const protocolMatches = matches.filter(({ provider }) =>
|
||||
provider.capabilities?.some((capability) => capability.type === protocol)
|
||||
);
|
||||
if (protocolMatches.length === 1) {
|
||||
return protocolMatches[0];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const registryCache = new WeakMap<object, ModelRegistry>();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { ModelRegistry } from "@ccr/core/routing/model-registry.ts";
|
||||
|
||||
const anthropicOnly = {
|
||||
name: "anthropic-only",
|
||||
api_base_url: "https://anthropic-only.test",
|
||||
models: ["shared-model", "anthropic-only-model"],
|
||||
capabilities: [{ type: "anthropic_messages" }]
|
||||
};
|
||||
|
||||
const openaiOnly = {
|
||||
name: "openai-only",
|
||||
api_base_url: "https://openai-only.test",
|
||||
models: ["shared-model", "openai-only-model"],
|
||||
capabilities: [
|
||||
{ type: "openai_chat_completions" },
|
||||
{ type: "openai_responses" }
|
||||
]
|
||||
};
|
||||
|
||||
const noCapability = {
|
||||
name: "no-capability",
|
||||
api_base_url: "https://no-capability.test",
|
||||
models: ["bare-shared"]
|
||||
};
|
||||
|
||||
function registryWith(...providers) {
|
||||
return new ModelRegistry({ Providers: providers, virtualModelProfiles: [] });
|
||||
}
|
||||
|
||||
test("bare id under two disjoint providers: protocol picks the matching owner", () => {
|
||||
const registry = registryWith(anthropicOnly, openaiOnly);
|
||||
|
||||
const openaiRef = registry.resolve("shared-model", { protocol: "openai_chat_completions" });
|
||||
assert.equal(openaiRef?.provider, openaiOnly);
|
||||
assert.equal(openaiRef?.model, "shared-model");
|
||||
|
||||
const anthropicRef = registry.resolve("shared-model", { protocol: "anthropic_messages" });
|
||||
assert.equal(anthropicRef?.provider, anthropicOnly);
|
||||
|
||||
// no protocol → stays ambiguous on purpose (pre-existing behavior preserved)
|
||||
assert.equal(registry.resolve("shared-model"), undefined);
|
||||
// neither provider declares the protocol → no unique resolution
|
||||
assert.equal(registry.resolve("shared-model", { protocol: "gemini_generate_content" }), undefined);
|
||||
});
|
||||
|
||||
test("single owner keeps resolving with or without protocol", () => {
|
||||
const registry = registryWith(anthropicOnly, openaiOnly);
|
||||
|
||||
assert.equal(registry.resolve("anthropic-only-model")?.provider, anthropicOnly);
|
||||
assert.equal(registry.resolve("anthropic-only-model", { protocol: "openai_chat_completions" })?.provider, anthropicOnly);
|
||||
});
|
||||
|
||||
test("providers without capability metadata degrade to the old ambiguous behavior", () => {
|
||||
const registry = registryWith(noCapability, { ...noCapability, name: "no-capability-2" });
|
||||
|
||||
assert.equal(registry.resolve("bare-shared", { protocol: "openai_chat_completions" }), undefined);
|
||||
assert.equal(registry.resolve("bare-shared"), undefined);
|
||||
});
|
||||
|
||||
test("case-insensitive fallback branch also disambiguates by protocol", () => {
|
||||
const a = { name: "ci-a", api_base_url: "https://ci-a.test", models: ["Model-X"], capabilities: [{ type: "anthropic_messages" }] };
|
||||
const b = { name: "ci-b", api_base_url: "https://ci-b.test", models: ["MODEL-X"], capabilities: [{ type: "openai_chat_completions" }] };
|
||||
const registry = registryWith(a, b);
|
||||
|
||||
assert.equal(registry.resolve("model-x", { protocol: "openai_chat_completions" })?.provider, b);
|
||||
assert.equal(registry.resolve("model-x", { protocol: "anthropic_messages" })?.provider, a);
|
||||
assert.equal(registry.resolve("model-x"), undefined);
|
||||
});
|
||||
Reference in New Issue
Block a user