diff --git a/src/router.ts b/src/router.ts index d3939298e05..68ae9ca013e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -30,7 +30,7 @@ import { LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, } from "./providers/openai-tiers"; -import { decodeRoutedModelIdOrThrow, encodeRoutedModelId } from "./providers/slug-codec"; +import { decodeRoutedModelId, decodeRoutedModelIdOrThrow, encodeRoutedModelId } from "./providers/slug-codec"; import { effectiveProviderAliasDecision, resolveModelAlias } from "./providers/default-aliases"; import { resolveBlockedModelRedirect } from "./lib/shadow-call"; import { workBuddyCatalogIdentity } from "./providers/workbuddy"; @@ -583,7 +583,14 @@ function routeModelInternal( const decoded = decodeRoutedModelIdOrThrow(requestedModel, known); const nativeModel = known.includes(decoded) ? decoded - : resolveModelAlias(config, prov, known, requestedModel) ?? decoded; + : resolveModelAlias(config, prov, known, requestedModel) + // Last resort for direct requests while the live roster is cold: display-name keys are + // operator-declared native ids, consulted only here so they never claim aliases or shadow + // known or retained ids. Concrete policy/combo/redirect targets (bypassCombos) keep the + // exact model their evaluator selected, so evidence and eligibility describe what is sent. + ?? (bypassCombos || prov.retainModels?.includes(decoded) + ? decoded + : decodeRoutedModelId(decoded, Object.keys(prov.modelDisplayNames ?? {}))); return routeResult( config, provName, diff --git a/structure/runtime.md b/structure/runtime.md index e40e2fc0ed0..5621876406b 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -18,7 +18,7 @@ Release packaging, Docker, installed-artifact and service smoke jobs, desktop sh `src/router.ts` attaches one frozen `ResolvedModelPolicy` to every `RouteResult`. Fast observation, persistence and cost provenance follow the [response-tier authority contract](transports/responses.md#response-tier-observation-authority); outbound Fast policy is unchanged. Policy/combo route spreads retain that object. Every initial, fallback, and recovery route is recaptured for the request's original inbound protocol before route-dependent normalization, and all adapter rebuilds consume its recorded adapter. A translated Chat or Anthropic replay therefore cannot inherit a Responses-only default. Credential, account, quota, health, cooldown, and observed transport evidence remain late and cannot widen a captured static limit. -Virtual models are the sole model-identity transition: the ordinary and compact paths preserve the selected public id in diagnostics, rewrite `route.modelId` to the upstream wire id, and atomically replace `route.staticPolicy` before adapter or capability decisions continue. Model aliases are resolved before the route result is built, so their policy is already keyed by the native wire id. `src/router.ts` applies bare `blockedModelRedirects` keys after this resolution; ordinary targets substitute one upstream model id on the selected provider/account, including slash-valued targets, while only targets explicitly naming a different configured provider reroute. Qualified source keys take precedence only for those cross-provider targets. A cross-provider redirect sets `RouteResult.credentialDomainRewrite`, which Responses and Chat header scoping treat like combo/policy routes, so a caller credential for the source route is never forwarded. `src/cli/alias.ts` consumes the JSON output flag before choosing its default list action. `ocx alias --json` and both flag positions around an explicit action preserve the selected read or mutation. Live selector hints obey the [credential-scoped cache contract](catalog.md): a selection change cannot reuse the previous credential's roster to choose an alias target. Passive OAuth observation neither refreshes credentials nor repairs their storage. Cross-provider redirects require an enabled destination with a resolved key, usable stored OAuth account, or local/keyless auth (never source-caller forward auth), and share one visited set and five-edge budget across alias, provider, combo and policy resolution; every policy redirect must end at an eligible declared candidate, including same-provider substitutions and candidate fallback retries, and pinned account selectors reject provider changes. Decision traces name the final provider/model while policy candidates retain selection evidence and combos retain their route kind and redirect reason. +Virtual models are the sole model-identity transition: the ordinary and compact paths preserve the selected public id in diagnostics, rewrite `route.modelId` to the upstream wire id, and atomically replace `route.staticPolicy` before adapter or capability decisions continue. Model aliases are resolved before the route result is built, so their policy is already keyed by the native wire id. `src/router.ts` applies bare `blockedModelRedirects` keys after this resolution; ordinary targets substitute one upstream model id on the selected provider/account, including slash-valued targets, while only targets explicitly naming a different configured provider reroute. Qualified source keys take precedence only for those cross-provider targets. A cross-provider redirect sets `RouteResult.credentialDomainRewrite`, which Responses and Chat header scoping treat like combo/policy routes, so a caller credential for the source route is never forwarded. `src/cli/alias.ts` consumes the JSON output flag before choosing its default list action. `ocx alias --json` and both flag positions around an explicit action preserve the selected read or mutation. Live selector hints obey the [credential-scoped cache contract](catalog.md): a selection change cannot reuse the previous credential's roster to choose an alias target. Passive OAuth observation neither refreshes credentials nor repairs their storage. Cross-provider redirects require an enabled destination with a resolved key, usable stored OAuth account, or local/keyless auth (never source-caller forward auth), and share one visited set and five-edge budget across alias, provider, combo and policy resolution; every policy redirect must end at an eligible declared candidate, including same-provider substitutions and candidate fallback retries, and pinned account selectors reject provider changes. Decision traces name the final provider/model while policy candidates retain selection evidence and combos retain their route kind and redirect reason. An explicit provider-qualified slug decodes against `knownModelIdsForProvider` first, then model aliases. Only when both miss on a direct request does `src/router.ts` decode once more against the provider's `modelDisplayNames` keys, which are operator-declared native ids; this keeps a live-discovery provider such as Baseten routable while its roster cache is cold. The fallback never feeds `knownModelIdsForProvider`, so display-name keys claim no aliases and never shadow a configured or `retainModels` id, and concrete policy, combo and blocked-model redirect targets keep the exact model their evaluator selected (`tests/codex-integration/slug-codec.test.ts`, `tests/routing/policy-execution.test.ts`). Routed Meta Muse requests use the registry-owned [Muse effort and header contract](providers-and-adapters.md); `max` reaches the provider through the existing reasoning mapper. diff --git a/tests/codex-integration/slug-codec.test.ts b/tests/codex-integration/slug-codec.test.ts index 3a07001def8..a5e0ae79319 100644 --- a/tests/codex-integration/slug-codec.test.ts +++ b/tests/codex-integration/slug-codec.test.ts @@ -301,6 +301,76 @@ describe("routeModel decode (proxy layer)", () => { expect(route.modelId).toBe("vendor/m-2"); }); + test("modelDisplayNames keys decode a provider-qualified slug with a cold live cache", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "other", + providers: { + other: { + adapter: "openai-chat", + baseUrl: "https://example.com/v1", + apiKey: "k", + modelDisplayNames: { "vendor/m-3": "Vendor M3" }, + }, + }, + }; + const route = routeModel(config, "other/vendor-m-3"); + expect(route.providerName).toBe("other"); + expect(route.modelId).toBe("vendor/m-3"); + }); + + test("a stale modelDisplayNames key does not shadow a configured id", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "other", + providers: { + other: { + adapter: "openai-chat", + baseUrl: "https://example.com/v1", + apiKey: "k", + models: ["a-b"], + modelDisplayNames: { "a/b": "Stale A/B" }, + }, + }, + }; + expect(routeModel(config, "other/a-b").modelId).toBe("a-b"); + }); + + test("a stale modelDisplayNames key does not shadow a retained id", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "other", + providers: { + other: { + adapter: "openai-chat", + baseUrl: "https://example.com/v1", + apiKey: "k", + retainModels: ["a-b"], + modelDisplayNames: { "a/b": "Stale A/B" }, + }, + }, + }; + expect(routeModel(config, "other/a-b").modelId).toBe("a-b"); + }); + + test("a stale modelDisplayNames key does not break an existing default alias", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "other", + providers: { + other: { + adapter: "openai-chat", + baseUrl: "https://example.com/v1", + apiKey: "k", + defaultAliases: true, + models: ["vendor/claude-opus-5"], + modelDisplayNames: { "vendor/claude-opus-5-old": "Stale Opus" }, + }, + }, + }; + expect(routeModel(config, "other/opus").modelId).toBe("vendor/claude-opus-5"); + }); + test("knownModelIdsForProvider unions config, registry, and hint-map ids", () => { const ids = knownModelIdsForProvider("zenmux", zenmuxConfig().providers.zenmux!); expect(ids).toContain("moonshotai/kimi-k3-free"); diff --git a/tests/routing/policy-execution.test.ts b/tests/routing/policy-execution.test.ts index 96b6405a5b5..3508c5a2594 100644 --- a/tests/routing/policy-execution.test.ts +++ b/tests/routing/policy-execution.test.ts @@ -134,6 +134,35 @@ describe("policy execution (RI-05)", () => { expect(route.modelId).toBe("m1"); }); + test("policy and combo targets keep their exact model when a display name could re-decode it", () => { + const base = baseConfig(); + const config = baseConfig({ + providers: { + ...base.providers, + other: { + adapter: "openai-chat", + baseUrl: "https://other.example/v1", + apiKey: "ko", + modelDisplayNames: { "a/b": "Display-only A/B" }, + }, + }, + combos: { exactcombo: { strategy: "failover", targets: [{ provider: "other", model: "a-b" }] } }, + routingProfiles: { + exact: { + candidates: [{ provider: "other", model: "a-b" }], + unknownEvidence: { capability: "penalize", health: "penalize", quota: "penalize", cost: "penalize" }, + }, + }, + }); + expect(routeModel(config, "other/a-b").modelId).toBe("a/b"); + const policy = routeModel(config, "policy/exact"); + expect(policy.modelId).toBe("a-b"); + expect(policy.routeDecision!.selected.model).toBe("a-b"); + const combo = routeModel(config, "combo/exactcombo"); + expect(combo.modelId).toBe("a-b"); + expect(combo.combo!.target.model).toBe("a-b"); + }); + test("existing explicit, combo, native and default routes are unchanged", () => { const config = baseConfig(); expect(routeModel(config, "a/m1")).toMatchObject({ routeKind: "explicit-provider", providerName: "a", modelId: "m1" });