diff --git a/packages/core/src/gateway/core-runtime/router-plugin.ts b/packages/core/src/gateway/core-runtime/router-plugin.ts index 7be74431..f74c266e 100644 --- a/packages/core/src/gateway/core-runtime/router-plugin.ts +++ b/packages/core/src/gateway/core-runtime/router-plugin.ts @@ -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; diff --git a/packages/core/src/routing/model-registry.ts b/packages/core/src/routing/model-registry.ts index bf82744c..ce094135 100644 --- a/packages/core/src/routing/model-registry.ts +++ b/packages/core/src/routing/model-registry.ts @@ -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(); diff --git a/packages/core/test/unit/routing/model-registry-protocol-disambiguation.test.mjs b/packages/core/test/unit/routing/model-registry-protocol-disambiguation.test.mjs new file mode 100644 index 00000000..725ac134 --- /dev/null +++ b/packages/core/test/unit/routing/model-registry-protocol-disambiguation.test.mjs @@ -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); +});