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:
Burlesque1
2026-10-08 10:10:24 +00:00
parent f2e01bfe0c
commit c3bb363640
3 changed files with 110 additions and 11 deletions
@@ -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;
+39 -10
View File
@@ -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);
});