diff --git a/src/config.ts b/src/config.ts index 1308b3a64b..838c409c01 100644 --- a/src/config.ts +++ b/src/config.ts @@ -930,6 +930,7 @@ const configSchema = z.object({ // parse: a hand-edited typo must never trip the backup-and-defaults repair // path below and wipe providers/pool accounts. Warning emitted in loadConfig. streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined), + blockedModelRedirects: z.record(z.string(), z.string()).optional().catch(undefined), // Same degrade-don't-reject rationale as the fields above: a hand-edited // non-string must not trip the backup-and-defaults repair path. Unset then // takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot). diff --git a/src/lib/shadow-call.ts b/src/lib/shadow-call.ts index 23c6365928..8e94432da5 100644 --- a/src/lib/shadow-call.ts +++ b/src/lib/shadow-call.ts @@ -9,6 +9,23 @@ */ export const DEFAULT_SHADOW_SOURCE_MODELS = ["gpt-5.6-luna"] as const; +/** + * Optional blocked model redirects at the shared routing layer. + * When `blockedModelRedirects` is configured (e.g. `{ "gpt-5.6-terra": "gpt-5.6-luna" }`), + * requests targeting those models are rewritten to the substitute model with + * routeReason "blocked-model-redirect". + * Returns undefined when not configured or the model is not in the redirect map. + */ +export function resolveBlockedModelRedirect( + config: { blockedModelRedirects?: Record } | undefined, + modelId: string, +): string | undefined { + if (!config?.blockedModelRedirects || typeof config.blockedModelRedirects !== "object") { + return undefined; + } + return config.blockedModelRedirects[modelId]; +} + /** Normalize a persisted `sourceModels` override; falls back to the defaults. */ export function shadowSourceModels(configured?: unknown): string[] { const configuredStrings = Array.isArray(configured) diff --git a/src/router.ts b/src/router.ts index 489451de3e..e3b5046cfe 100644 --- a/src/router.ts +++ b/src/router.ts @@ -33,6 +33,7 @@ import { } from "./providers/openai-tiers"; import { decodeRoutedModelIdOrThrow, encodeRoutedModelId } from "./providers/slug-codec"; import { resolveModelAlias } from "./providers/default-aliases"; +import { resolveBlockedModelRedirect } from "./lib/shadow-call"; import { getStaleCached } from "./codex/model-cache"; import { codexAccountNamespaceEntries } from "./codex/account-namespaces"; import { @@ -506,19 +507,23 @@ function isBareOpenAiFamilyModel(modelId: string): boolean { } function routeResult( + config: OcxConfig | undefined, providerName: string, provider: OcxProviderConfig, modelId: string, routeKind: RouteDecisionKind, routeReason: string, ): RouteResult { + const redirected = resolveBlockedModelRedirect(config, modelId); + const effectiveModelId = redirected ?? modelId; + const effectiveRouteReason = redirected ? "blocked-model-redirect" : routeReason; const codexAccountMode = providerCodexAccountMode(providerName, provider); return { providerName, provider: routedProviderConfig(providerName, provider), - modelId, + modelId: effectiveModelId, routeKind, - routeReason, + routeReason: effectiveRouteReason, ...(codexAccountMode ? { codexAccountMode } : {}), }; } @@ -621,7 +626,7 @@ function routeModelInternal( throw new NoEnabledOpenAiProviderError(nativeModelId); } return { - ...routeResult(OPENAI_CODEX_PROVIDER_ID, provider, nativeModelId, "explicit-account", "account-namespace"), + ...routeResult(config, OPENAI_CODEX_PROVIDER_ID, provider, nativeModelId, "explicit-account", "account-namespace"), // Exact account injection uses the pool credential machinery even when the canonical // provider is globally Direct. The fixed id bypasses pool selection entirely. codexAccountMode: "pool", @@ -666,7 +671,7 @@ function routeModelInternal( // itself a known model (e.g. orcarouter/auto). Route it whole instead of stripping to the // remainder, which would send a bare `auto` the upstream cannot resolve. if (known.includes(modelId)) { - return routeResult(provName, prov, modelId, "explicit-provider", "explicit-provider-namespace"); + return routeResult(config, provName, prov, modelId, "explicit-provider", "explicit-provider-namespace"); } // Codex-facing alias ids (`provider/vendor-model`) decode back to the native // slash id via an exact known-id lookup; raw full-slash selectors keep working. @@ -676,6 +681,7 @@ function routeModelInternal( ? decoded : resolveModelAlias(config, prov, known, requestedModel) ?? decoded; return routeResult( + config, provName, prov, nativeModel, @@ -689,7 +695,7 @@ function routeModelInternal( if (isBareOpenAiFamilyModel(modelId)) { const provider = config.providers[OPENAI_CODEX_PROVIDER_ID]; if (provider && provider.disabled !== true) { - return routeResult(OPENAI_CODEX_PROVIDER_ID, provider, modelId, "native", "native-family"); + return routeResult(config, OPENAI_CODEX_PROVIDER_ID, provider, modelId, "native", "native-family"); } throw new NoEnabledOpenAiProviderError(modelId); } @@ -697,7 +703,7 @@ function routeModelInternal( for (const [provName, prov] of activeProviderEntries(config)) { if (prov.defaultModel === modelId || (typeof prov.defaultModel === "string" && encodeRoutedModelId(prov.defaultModel) === modelId)) { - return routeResult(provName, prov, prov.defaultModel as string, "explicit-provider", "configured-default-model"); + return routeResult(config, provName, prov, prov.defaultModel as string, "explicit-provider", "configured-default-model"); } } @@ -708,7 +714,7 @@ function routeModelInternal( if (prov.models && Array.isArray(prov.models)) { const hit = (prov.models as string[]).find(id => id === modelId || encodeRoutedModelId(id) === modelId); if (hit !== undefined) { - return routeResult(provName, prov, hit, "explicit-provider", "configured-model-list"); + return routeResult(config, provName, prov, hit, "explicit-provider", "configured-model-list"); } } } @@ -728,7 +734,7 @@ function routeModelInternal( } if (aliasMatches[0]) { const match = aliasMatches[0]; - return routeResult(match.provider, config.providers[match.provider], match.model, "explicit-provider", "model-alias"); + return routeResult(config, match.provider, config.providers[match.provider], match.model, "explicit-provider", "model-alias"); } if (config.defaultProvider === LEGACY_CHATGPT_PROVIDER_ID) { @@ -737,7 +743,7 @@ function routeModelInternal( if (hasOwnProvider(config.providers, config.defaultProvider)) { const defaultProv = config.providers[config.defaultProvider]; if (defaultProv.disabled === true) throw new Error(`Default provider is disabled: ${config.defaultProvider}`); - return routeResult(config.defaultProvider, defaultProv, modelId, "default-provider", "default-provider"); + return routeResult(config, config.defaultProvider, defaultProv, modelId, "default-provider", "default-provider"); } throw new Error(`No provider configured for model: ${modelId}`); @@ -786,7 +792,7 @@ function routeByKnownModelPattern(config: OcxConfig, modelId: string): RouteResu ); if (matchingProvider) { const [provName, prov] = matchingProvider; - return routeResult(provName, prov, modelId, "explicit-provider", "model-pattern"); + return routeResult(config, provName, prov, modelId, "explicit-provider", "model-pattern"); } // Deliberately no "first provider with an Anthropic adapter" fallback here. Picking by // object insertion order, without checking `models`, `selectedModels`, `disabledModels` or diff --git a/src/types/config.ts b/src/types/config.ts index 10a87c9859..b4145b3475 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -436,17 +436,25 @@ export interface OcxConfig { * commit messages, skill orchestration) to a user-chosen model. Default intercepted * source models: gpt-5.4-mini (older clients) and gpt-5.6-luna (Codex 0.145.0+). * Opt-in; disabled by default. Matching requests preserve their configured reasoning effort. - * All requests for configured shadow source models are intercepted regardless of request kind, - * except when the replacement intersects the same provider+model source set. - */ - shadowCallIntercept?: { - /** When true, requests for known shadow/helper source models are rewritten to the configured model. */ - enabled?: boolean; - /** Replacement model id (e.g. "gpt-5.5"). */ - model?: string; - /** Optional override of intercepted source-model prefixes (default: gpt-5.4-mini, gpt-5.6-luna). */ - sourceModels?: string[]; - }; + * All requests for configured shadow source models are intercepted regardless of request kind, + * except when the replacement intersects the same provider+model source set. + */ + shadowCallIntercept?: { + /** When true, requests for known shadow/helper source models are rewritten to the configured model. */ + enabled?: boolean; + /** Replacement model id (e.g. "gpt-5.5"). */ + model?: string; + /** Optional override of intercepted source-model prefixes (default: gpt-5.4-mini, gpt-5.6-luna). */ + sourceModels?: string[]; + }; + /** + * Optional map of blocked model IDs to their replacement model IDs. + * When configured, incoming requests targeting a blocked model (including + * account-namespaced and concrete routes) are redirected to the replacement + * model at the shared routing layer with routeReason "blocked-model-redirect". + * Unset or omitted by default. + */ + blockedModelRedirects?: Record; /** * 3-state multi-agent surface override: * - "v1": force ALL models to v1 surface (override upstream pins) diff --git a/tests/router.test.ts b/tests/router.test.ts index 8b47e7e42e..13876194e6 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -613,3 +613,89 @@ describe("routeModel backfills google wire mode from the registry", () => { expect(routed.providerName).toBe("fallbackProvider"); }); }); + +describe("routeModel blocked model redirect", () => { + test("routes gpt-5.6-terra normally when blockedModelRedirects is unset", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + }, + }; + + const routed = routeModel(config, "gpt-5.6-terra"); + expect(routed).toMatchObject({ + providerName: "openai", + modelId: "gpt-5.6-terra", + routeKind: "native", + routeReason: "native-family", + }); + }); + + test("opt-in intercepts gpt-5.6-terra and rewrites to gpt-5.6-luna with blocked-model-redirect reason", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "gpt-5.6-terra": "gpt-5.6-luna", + }, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + }, + }; + + const routed = routeModel(config, "gpt-5.6-terra"); + expect(routed).toMatchObject({ + providerName: "openai", + modelId: "gpt-5.6-luna", + routeKind: "native", + routeReason: "blocked-model-redirect", + }); + expect(routed.routeDecision?.selected).toMatchObject({ + model: "gpt-5.6-luna", + reason: "blocked-model-redirect", + }); + expect(routed.routeDecision?.requestedModel).toBe("gpt-5.6-terra"); + }); + + test("opt-in intercepts account-namespaced gpt-5.6-terra and rewrites to gpt-5.6-luna", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "gpt-5.6-terra": "gpt-5.6-luna", + }, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + codexAccountMode: "direct", + }, + }, + codexAccountNamespaces: { side: "side-account-id" }, + }; + + const routed = routeModel(config, "side/gpt-5.6-terra"); + expect(routed).toMatchObject({ + providerName: "openai", + modelId: "gpt-5.6-luna", + routeKind: "explicit-account", + routeReason: "blocked-model-redirect", + codexAccountId: "side-account-id", + codexAccountNamespace: "side", + }); + expect(routed.routeDecision?.selected).toMatchObject({ + model: "gpt-5.6-luna", + accountRef: "side", + reason: "blocked-model-redirect", + }); + expect(routed.routeDecision?.requestedModel).toBe("side/gpt-5.6-terra"); + }); +});