diff --git a/docs-site/src/content/docs/fr/reference/configuration/routing.md b/docs-site/src/content/docs/fr/reference/configuration/routing.md index 64713d645cc..672091acc27 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/routing.md +++ b/docs-site/src/content/docs/fr/reference/configuration/routing.md @@ -31,11 +31,16 @@ Les fournisseurs désactivés sont exclus. Un espace de noms explicite qui dési ### Redirections des modèles bloqués -`blockedModelRedirects` est un `Record` facultatif de premier niveau associant des remplacements exacts d’identifiants de modèle résolus ; il est non défini par défaut. Il s’applique après l’ordre de résolution ci-dessus : une correspondance conserve la route du fournisseur et du compte déjà sélectionnée, ne remplace que l’identifiant du modèle en amont et enregistre le motif de routage `blocked-model-redirect`. L’omission de la clé ne modifie pas le routage. +`blockedModelRedirects` est un `Record` facultatif de premier niveau définissant les remplacements d'identifiants de modèle, non défini par défaut. Lorsqu'un modèle entrant correspond à une clé, il est redirigé vers le modèle de substitution cible. Les modèles cibles peuvent être résolus au sein du même fournisseur ou réacheminés vers un autre fournisseur (par exemple `google-antigravity/gemini-3.8-flash-high`), avec prise en charge des redirections en chaîne multi-sauts (jusqu'à une profondeur maximale de 5 sauts avec détection de boucle). Le motif de routage est enregistré sous `blocked-model-redirect`. L'omission de la clé ne modifie pas le routage. + +La correspondance est également effectuée sur le modèle natif résolu à partir d'un alias ; les alias menant à des modèles natifs bloqués sont donc redirigés en conséquence. Les redirections inter-fournisseurs qualifiées par un compte nécessitent une clé exacte (par exemple `side/gpt-5.6-terra`) ; les clés non qualifiées échouent de manière sécurisée (fail closed) sous un espace de noms de compte, et les destinations inter-fournisseurs utilisent directement le fournisseur cible sans hériter des identifiants ou informations d'authentification du fournisseur source, des champs de compte (`codexAccountId`, `codexAccountNamespace`) ni des quotas. ```json { - "blockedModelRedirects": { "gpt-5.6-terra": "gpt-5.6-luna" } + "blockedModelRedirects": { + "gpt-5.6-terra": "gpt-5.6-luna", + "gpt-5.6-anon": "google-antigravity/gemini-3.8-flash-high" + } } ``` diff --git a/docs-site/src/content/docs/ja/reference/configuration/routing.md b/docs-site/src/content/docs/ja/reference/configuration/routing.md index c6a6d807728..2851cfc4511 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ja/reference/configuration/routing.md @@ -31,11 +31,16 @@ opencodex は、要求されたモデルを次の順序で解決します。 ### ブロック対象モデルのリダイレクト -`blockedModelRedirects` は、完全一致する解決済みモデル ID の置換を指定する任意のトップレベル `Record` で、デフォルトでは未設定です。上記の解決順序の後に適用されます。一致した場合、すでに選択されたプロバイダーとアカウントのルートは維持され、上流モデル ID のみが置き換えられ、ルート理由として `blocked-model-redirect` が記録されます。このキーを省略すると、ルーティングは変更されません。 +`blockedModelRedirects` は、モデル ID の置換を指定する任意のトップレベル `Record` で、デフォルトでは未設定です。受信したモデルがキーに一致した場合、対象の置換モデルにリダイレクトされます。対象モデルは同一プロバイダー内での置換だけでなく、別プロバイダーへの再ルーティング(例: `google-antigravity/gemini-3.8-flash-high`)も可能で、複数ホップの連鎖リダイレクト(ループ検出および最大 5 ホップ制限)をサポートします。ルート理由として `blocked-model-redirect` が記録されます。このキーを省略すると、ルーティングは変更されません。 + +マッチングはエイリアスから解決されたネイティブ モデルに対しても実行されるため、ブロック対象のネイティブ モデルに解決されるエイリアスも同様にリダイレクトされます。アカウント修飾されたクロスプロバイダー リダイレクトには完全一致するキー(例: `side/gpt-5.6-terra`)が必要であり、アカウント名前空間付きのベア キーは fail closed します。クロスプロバイダーの宛先は、ソース プロバイダーの資格情報や認証情報、アカウント フィールド(`codexAccountId`、`codexAccountNamespace`)、クォータを継承せずにターゲット プロバイダーを直接使用します。 ```json { - "blockedModelRedirects": { "gpt-5.6-terra": "gpt-5.6-luna" } + "blockedModelRedirects": { + "gpt-5.6-terra": "gpt-5.6-luna", + "gpt-5.6-anon": "google-antigravity/gemini-3.8-flash-high" + } } ``` diff --git a/docs-site/src/content/docs/ko/reference/configuration/routing.md b/docs-site/src/content/docs/ko/reference/configuration/routing.md index 2f617695ce6..f87564b6473 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ko/reference/configuration/routing.md @@ -30,11 +30,16 @@ opencodex는 요청된 model을 다음 순서로 해석합니다: ### 차단된 모델 리디렉션 -`blockedModelRedirects`는 기본적으로 설정되지 않는 선택적 최상위 `Record`이며, 정확히 일치하는 해석된 모델 ID의 대체값을 정의합니다. 위 해석 순서가 끝난 후 적용됩니다. 일치하면 이미 선택된 공급자와 계정 경로는 유지하고 업스트림 모델 ID만 교체하며, 경로 사유를 `blocked-model-redirect`로 기록합니다. 이 키를 생략하면 라우팅이 바뀌지 않습니다. +`blockedModelRedirects`는 기본적으로 설정되지 않는 선택적 최상위 `Record`이며, 모델 ID의 대체값을 정의합니다. 수신된 모델이 키와 일치하면 대상 대체 모델로 리디렉션됩니다. 대상 모델은 동일한 공급자 내에서 대체되거나 다른 공급자로 교차 재라우팅(예: `google-antigravity/gemini-3.8-flash-high`)될 수 있으며, 멀티홉 체인 리디렉션(사이클 감지 및 최대 5홉 깊이 제한)을 지원합니다. 경로 사유는 `blocked-model-redirect`로 기록됩니다. 이 키를 생략하면 라우팅이 바뀌지 않습니다. + +매칭은 별칭에서 확인된 네이티브 모델에 대해서도 수행되므로 차단된 네이티브 모델로 해석되는 별칭도 적절히 리디렉션됩니다. 계정 한정 교차 공급자 리디렉션에는 정확한 키(예: `side/gpt-5.6-terra`)가 필요하며, 계정 네임스페이스 요청에서 베어 키는 fail closed 합니다. 교차 공급자 대상은 소스 공급자의 자격 증명, 인증 정보, 소스 계정 필드(`codexAccountId`, `codexAccountNamespace`) 또는 할당량을 상속하지 않고 대상 공급자를 직접 사용합니다. ```json { - "blockedModelRedirects": { "gpt-5.6-terra": "gpt-5.6-luna" } + "blockedModelRedirects": { + "gpt-5.6-terra": "gpt-5.6-luna", + "gpt-5.6-anon": "google-antigravity/gemini-3.8-flash-high" + } } ``` diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index d830faafae1..30a4790a2f8 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -36,14 +36,21 @@ more than one provider, so use explicit namespaces when a bare model could be am ### Blocked-model redirects -`blockedModelRedirects` is an optional top-level `Record` of exact resolved -model-id replacements, unset by default. It runs **after** the resolution order above: a match -keeps the provider and account route already selected, replaces only the upstream model id, and -records the route reason `blocked-model-redirect`. Omitting the key leaves routing unchanged. +`blockedModelRedirects` is an optional top-level `Record` of exact model-id +replacements, unset by default. When an incoming model matches a key, it is redirected to the +target replacement model. Target models can resolve to the same provider or re-route across providers +(e.g. `google-antigravity/gemini-3.8-flash-high`), with multi-hop chained redirects supported (up to +a maximum depth of 5 hops with cycle detection). The route reason is recorded as +`blocked-model-redirect`. Omitting the key leaves routing unchanged. + +Matching is also performed against the native model resolved from an alias, so aliases resolving to blocked native models redirect accordingly. Account-qualified cross-provider redirects require an exact key (e.g. `side/gpt-5.6-terra`); bare keys fail closed when account-namespaced, and cross-provider destinations use the target provider directly without inheriting source provider credentials, authentication material, account fields (`codexAccountId`, `codexAccountNamespace`), or quotas. ```json { - "blockedModelRedirects": { "gpt-5.6-terra": "gpt-5.6-luna" } + "blockedModelRedirects": { + "gpt-5.6-terra": "gpt-5.6-luna", + "gpt-5.6-anon": "google-antigravity/gemini-3.8-flash-high" + } } ``` diff --git a/docs-site/src/content/docs/ru/reference/configuration/routing.md b/docs-site/src/content/docs/ru/reference/configuration/routing.md index 595916aebde..4ef31347077 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ru/reference/configuration/routing.md @@ -37,15 +37,21 @@ opencodex разрешает запрошенную модель в следую ### Перенаправления заблокированных моделей -`blockedModelRedirects` — необязательный верхнеуровневый `Record` точных замен -разрешённых идентификаторов моделей; по умолчанию не задан. Он применяется после описанного выше -порядка разрешения: при совпадении уже выбранный маршрут провайдера и аккаунта сохраняется, заменяется -только идентификатор вышестоящей модели, а причиной маршрута записывается `blocked-model-redirect`. -Если ключ отсутствует, маршрутизация не меняется. +`blockedModelRedirects` — необязательный верхнеуровневый `Record` замен +идентификаторов моделей; по умолчанию не задан. При совпадении входящей модели с ключом запрос +перенаправляется на целевую модель. Целевая модель может находиться у того же провайдера или +перемаршрутизироваться к другому провайдеру (например, `google-antigravity/gemini-3.8-flash-high`), +с поддержкой многозвенных цепочек перенаправления (до 5 переходов с обнаружением циклов). Причиной +маршрута записывается `blocked-model-redirect`. Если ключ отсутствует, маршрутизация не меняется. + +Сопоставление также выполняется с нативной моделью, полученной из псевдонима, поэтому псевдонимы, ведущие к заблокированным нативным моделям, перенаправляются соответствующим образом. Для перенаправлений между провайдерами с указанием аккаунта требуется точный ключ (например, `side/gpt-5.6-terra`); голые ключи в пространстве имён аккаунта завершаются ошибкой (fail closed), а целевые маршруты другого провайдера используют целевой провайдер напрямую без наследования учетных данных исходного провайдера, материалов аутентификации, полей аккаунта (`codexAccountId`, `codexAccountNamespace`) или квот. ```json { - "blockedModelRedirects": { "gpt-5.6-terra": "gpt-5.6-luna" } + "blockedModelRedirects": { + "gpt-5.6-terra": "gpt-5.6-luna", + "gpt-5.6-anon": "google-antigravity/gemini-3.8-flash-high" + } } ``` diff --git a/docs-site/src/content/docs/tr/reference/configuration/routing.md b/docs-site/src/content/docs/tr/reference/configuration/routing.md index b1cbe4b4842..6373681966a 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/routing.md +++ b/docs-site/src/content/docs/tr/reference/configuration/routing.md @@ -43,17 +43,23 @@ olabileceğinde açık ad alanları kullanın. ### Engellenen model yeniden yönlendirmeleri -`blockedModelRedirects`, varsayılan olarak ayarlanmamış, tam çözümlenmiş model -kimliği değiştirmelerinden oluşan isteğe bağlı üst düzey bir -`Record` eşlemesidir. Yukarıdaki çözümleme sırasından sonra -çalışır: bir eşleşme önceden seçilmiş sağlayıcı ve hesap rotasını korur, yalnızca -yukarı akış model kimliğini değiştirir ve rota nedenini -`blocked-model-redirect` olarak kaydeder. Anahtarın atlanması yönlendirmeyi -değiştirmez. +`blockedModelRedirects`, varsayılan olarak ayarlanmamış, model kimliği +değiştirmelerinden oluşan isteğe bağlı üst düzey bir `Record` +eşlemesidir. Gelen bir model anahtarla eşleştiğinde, hedef yedek modele yeniden +yönlendirilir. Hedef modeller aynı sağlayıcı içinde çözülebilir veya farklı bir +sağlayıcıya yeniden yönlendirilebilir (ör. `google-antigravity/gemini-3.8-flash-high`). +Döngü algılama ve en fazla 5 atlama derinliği ile çoklu atlamalı zincirleme +yeniden yönlendirmeler desteklenir. Rota nedeni `blocked-model-redirect` olarak +kaydedilir. Anahtarın atlanması yönlendirmeyi değiştirmez. + +Eşleştirme ayrıca bir takma addan çözümlenen yerel model için de gerçekleştirilir; bu nedenle engellenen yerel modellere çözümlenen takma adlar da uygun şekilde yeniden yönlendirilir. Hesap nitelikli sağlayıcılar arası yeniden yönlendirmeler tam bir anahtar gerektirir (ör. `side/gpt-5.6-terra`); hesap ad alanında yalın anahtarlar güvenli şekilde başarısız olur (fail closed) ve sağlayıcılar arası hedefler, kaynak sağlayıcı kimlik bilgilerini, kimlik doğrulama materyallerini, kaynak hesap alanlarını (`codexAccountId`, `codexAccountNamespace`) veya kotaları devralmadan doğrudan hedef sağlayıcıyı kullanır. ```json { - "blockedModelRedirects": { "gpt-5.6-terra": "gpt-5.6-luna" } + "blockedModelRedirects": { + "gpt-5.6-terra": "gpt-5.6-luna", + "gpt-5.6-anon": "google-antigravity/gemini-3.8-flash-high" + } } ``` @@ -311,4 +317,3 @@ değişmeden ayrıştırılır. Geçmiş dizini tek kullanımlıktır - otomatik bir yeniden oluşturmayı tetikler; `ocx logs rebuild-index` bunu zorlar. Bu sistemdeki hiçbir şey ağırlıkları, bütçeleri veya aday kümelerini otomatik olarak ayarlamaz. - diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md index fa7d04ffc55..6e75cb084f9 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md @@ -35,11 +35,16 @@ opencodex 按以下顺序解析请求的模型: ### 被阻止模型重定向 -`blockedModelRedirects` 是可选的顶层 `Record`,用于精确替换已解析的模型 ID,默认未设置。它在上述解析顺序之后运行:匹配后会保留已选定的提供方和账户路由,仅替换上游模型 ID,并记录路由原因 `blocked-model-redirect`。省略该键则路由保持不变。 +`blockedModelRedirects` 是可选的顶层 `Record`,用于定义模型 ID 的替换规则,默认未设置。当传入的模型匹配键值时,将被重定向至目标替代模型。目标模型可在同一提供方内替换或跨提供方重新路由(例如 `google-antigravity/gemini-3.8-flash-high`),并支持多跳链式重定向(具有循环检测和最多 5 跳深度限制)。路由原因记录为 `blocked-model-redirect`。省略该键则路由保持不变。 + +匹配也会针对从别名解析出的原生模型进行,因此解析为被阻止原生模型的别名也会相应重定向。限定账户的跨提供方重定向需要精确键值(例如 `side/gpt-5.6-terra`);在账户命名空间下裸键会 fail closed,且跨提供方目标直接使用目标提供方,不继承来源提供方凭据、身份验证资料、来源账户字段(`codexAccountId`、`codexAccountNamespace`)或配额。 ```json { - "blockedModelRedirects": { "gpt-5.6-terra": "gpt-5.6-luna" } + "blockedModelRedirects": { + "gpt-5.6-terra": "gpt-5.6-luna", + "gpt-5.6-anon": "google-antigravity/gemini-3.8-flash-high" + } } ``` diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/routing.md b/docs-site/src/content/docs/zh-tw/reference/configuration/routing.md index 2001cf14a9b..85a5ccf2b90 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/routing.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/routing.md @@ -31,11 +31,16 @@ opencodex 依此順序解析請求的模型: ### 封鎖模型重新導向 -`blockedModelRedirects` 是選用的頂層 `Record`,用於精確替換已解析的模型 id,預設不設定。它在上述解析順序後執行:符合時會保留已選取的供應商與帳號路由,僅替換上游模型 id,並記錄路由原因 `blocked-model-redirect`。省略此鍵時,路由維持不變。 +`blockedModelRedirects` 是選用的頂層 `Record`,用於定義模型 ID 的替換規則,預設不設定。當傳入的模型符合鍵值時,將重新導向至目標替代模型。目標模型可維持在相同供應商或重新路由至其他供應商(例如 `google-antigravity/gemini-3.8-flash-high`),並支援多跳鏈式重新導向(具備循環偵測與最多 5 次跳轉深度限制)。路由原因將記錄為 `blocked-model-redirect`。省略此鍵時,路由維持不變。 + +比對亦會針對別名解析出的原生模型進行,因此解析為受封鎖原生模型的別名也會依規則重新導向。帳號限定的跨供應商重新導向需要精確鍵值(例如 `side/gpt-5.6-terra`);在帳號命名空間下裸鍵會 fail closed,且跨供應商目標直接使用目標供應商,不繼承來源供應商憑證、身分驗證資料、來源帳號欄位(`codexAccountId`、`codexAccountNamespace`)或配額。 ```json { - "blockedModelRedirects": { "gpt-5.6-terra": "gpt-5.6-luna" } + "blockedModelRedirects": { + "gpt-5.6-terra": "gpt-5.6-luna", + "gpt-5.6-anon": "google-antigravity/gemini-3.8-flash-high" + } } ``` diff --git a/src/lib/shadow-call.ts b/src/lib/shadow-call.ts index 8e94432da5a..027da25558a 100644 --- a/src/lib/shadow-call.ts +++ b/src/lib/shadow-call.ts @@ -23,7 +23,55 @@ export function resolveBlockedModelRedirect( if (!config?.blockedModelRedirects || typeof config.blockedModelRedirects !== "object") { return undefined; } - return config.blockedModelRedirects[modelId]; + if (!Object.prototype.hasOwnProperty.call(config.blockedModelRedirects, modelId)) { + return undefined; + } + const target = config.blockedModelRedirects[modelId]; + return typeof target === "string" && target.length > 0 ? target : undefined; +} + +/** + * Resolves blocked model redirects recursively with cycle and depth detection. + */ +export interface BlockedModelRedirectState { + visited: Set; + edges: number; +} + +/** + * Resolves blocked model redirects recursively with cycle and depth detection. + */ +export function resolveBlockedModelRedirectChain( + config: { blockedModelRedirects?: Record } | undefined, + modelId: string, + state?: BlockedModelRedirectState, +): { targetModel: string; redirected: boolean } { + if (!config?.blockedModelRedirects || typeof config.blockedModelRedirects !== "object") { + return { targetModel: modelId, redirected: false }; + } + const redirectState = state ?? { visited: new Set(), edges: 0 }; + const { visited } = redirectState; + let current = modelId; + let redirected = false; + + while (true) { + const next = resolveBlockedModelRedirect(config, current); + if (!next || next === current) { + break; + } + if (visited.has(current)) { + throw new Error(`Blocked model redirect cycle detected: ${[...visited, current].join(" -> ")}`); + } + visited.add(current); + redirectState.edges += 1; + if (redirectState.edges > 5) { + throw new Error(`Blocked model redirect exceeded maximum redirect depth (5): ${[...visited, next].join(" -> ")}`); + } + current = next; + redirected = true; + } + + return { targetModel: current, redirected }; } /** Normalize a persisted `sourceModels` override; falls back to the defaults. */ diff --git a/src/router.ts b/src/router.ts index 70e427b74b6..bb869b3c384 100644 --- a/src/router.ts +++ b/src/router.ts @@ -34,7 +34,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 { resolveBlockedModelRedirectChain, type BlockedModelRedirectState } from "./lib/shadow-call"; import { getStaleCached } from "./codex/model-cache"; import { codexAccountNamespaceEntries } from "./codex/account-namespaces"; import { @@ -550,23 +550,19 @@ 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: effectiveModelId, + modelId, routeKind, - routeReason: effectiveRouteReason, + routeReason, ...(codexAccountMode ? { codexAccountMode } : {}), }; } @@ -610,13 +606,36 @@ function comboRouteCandidates( }); } +function wrapRedirectedRoute( + targetRoute: RouteResult, + requestedModel: string, +): RouteResult { + const routeDecision = targetRoute.routeDecision + ? { + ...targetRoute.routeDecision, + requestedModel, + selected: { + ...targetRoute.routeDecision.selected, + reason: "blocked-model-redirect", + }, + } + : undefined; + return { + ...targetRoute, + routeReason: "blocked-model-redirect", + ...(routeDecision ? { routeDecision } : {}), + }; +} + function routeModelInternal( config: OcxConfig, modelId: string, bypassCombos: boolean, policyEvidence?: PolicyRequestEvidence, allowCompactionNativeFallback = false, + redirectState?: BlockedModelRedirectState, ): RouteResult { + const sharedRedirectState = redirectState ?? { visited: new Set(), edges: 0 }; const slash = modelId.indexOf("/"); // Policy namespace is system-reserved: an explicit `policy/` or a // configured profile alias executes the policy evaluator and routes the @@ -642,12 +661,23 @@ function routeModelInternal( } const selected = evaluation.candidates[evaluation.selectedIndex]!; const concrete = `${selected.provider}/${selected.model}`; - const routed = routeModelInternal(config, concrete, true); + const routed = routeModelInternal(config, concrete, true, undefined, false, sharedRedirectState); + const routeReason = routed.routeReason === "blocked-model-redirect" + ? "blocked-model-redirect" + : "policy-selected"; return { ...routed, routeKind: "policy" as const, - routeReason: "policy-selected", - routeDecision: evaluation.trace, + routeReason, + routeDecision: { + ...evaluation.trace, + selected: { + ...evaluation.trace.selected, + provider: routed.providerName, + model: routed.modelId, + reason: routeReason, + }, + }, }; } if (slash > 0) { @@ -655,13 +685,19 @@ function routeModelInternal( const binding = codexAccountNamespaceEntries(config) .find(([candidate]) => candidate === namespace); if (binding) { + const fullRedirect = resolveBlockedModelRedirectChain(config, modelId, sharedRedirectState); + if (fullRedirect.redirected) { + return wrapRedirectedRoute(routeModelInternal(config, fullRedirect.targetModel, bypassCombos, policyEvidence, allowCompactionNativeFallback, sharedRedirectState), modelId); + } const nativeModelId = modelId.slice(slash + 1); - if (!isBareOpenAiFamilyModel(nativeModelId)) { + const redirect = resolveBlockedModelRedirectChain(config, nativeModelId, sharedRedirectState); + const effectiveNativeModelId = redirect.targetModel; + if (!isBareOpenAiFamilyModel(effectiveNativeModelId)) { throw new Error(`Codex account namespace ${namespace} only supports native OpenAI model ids`); } const provider = config.providers[OPENAI_CODEX_PROVIDER_ID]; if (!provider || provider.disabled === true) { - throw new NoEnabledOpenAiProviderError(nativeModelId); + throw new NoEnabledOpenAiProviderError(effectiveNativeModelId); } // Registry routing backfills an omitted authMode on the built-in OpenAI row to forward. // Mirror only that default here; explicit non-forward modes still fail closed. @@ -669,10 +705,16 @@ function routeModelInternal( ? { ...provider, authMode: "forward" as const } : provider; if (!isCanonicalOpenAiForwardProvider(providerForCanonicalCheck)) { - throw new NoEnabledOpenAiProviderError(nativeModelId); + throw new NoEnabledOpenAiProviderError(effectiveNativeModelId); } return { - ...routeResult(config, OPENAI_CODEX_PROVIDER_ID, provider, nativeModelId, "explicit-account", "account-namespace"), + ...routeResult( + OPENAI_CODEX_PROVIDER_ID, + provider, + effectiveNativeModelId, + "explicit-account", + redirect.redirected ? "blocked-model-redirect" : "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", @@ -682,14 +724,30 @@ function routeModelInternal( } } + const redirect = resolveBlockedModelRedirectChain(config, modelId, sharedRedirectState); + if (redirect.redirected) { + const targetRoute = routeModelInternal( + config, + redirect.targetModel, + bypassCombos, + policyEvidence, + allowCompactionNativeFallback, + sharedRedirectState, + ); + return wrapRedirectedRoute(targetRoute, modelId); + } + if (!bypassCombos && !preservesPhysicalComboProvider(config)) { const combo = tryPickComboModel(config, modelId); if (combo) { const concrete = `${combo.target.provider}/${combo.target.model}`; // The selected target is already a concrete provider/model reference. Resolve it without // consulting combo aliases again, otherwise an alias that shadows the target can recurse. - const routed = routeModelInternal(config, concrete, true, undefined); - return { ...routed, combo, routeKind: "combo" as const, routeReason: "combo-pick" }; + const routed = routeModelInternal(config, concrete, true, undefined, false, sharedRedirectState); + const routeReason = routed.routeReason === "blocked-model-redirect" + ? "blocked-model-redirect" + : "combo-pick"; + return { ...routed, combo, routeKind: "combo" as const, routeReason }; } } @@ -749,7 +807,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(config, provName, prov, modelId, "explicit-provider", "explicit-provider-namespace"); + return routeResult(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. @@ -758,8 +816,19 @@ function routeModelInternal( const nativeModel = known.includes(decoded) ? decoded : resolveModelAlias(config, prov, known, requestedModel) ?? decoded; + const providerQualifiedId = `${provName}/${nativeModel}`; + const qualifiedRedirect = resolveBlockedModelRedirectChain(config, providerQualifiedId, sharedRedirectState); + if (qualifiedRedirect.redirected) { + return wrapRedirectedRoute(routeModelInternal( + config, + qualifiedRedirect.targetModel, + bypassCombos, + policyEvidence, + allowCompactionNativeFallback, + sharedRedirectState, + ), modelId); + } return routeResult( - config, provName, prov, nativeModel, @@ -773,7 +842,7 @@ function routeModelInternal( if (isBareOpenAiFamilyModel(modelId)) { const provider = config.providers[OPENAI_CODEX_PROVIDER_ID]; if (provider && provider.disabled !== true) { - return routeResult(config, OPENAI_CODEX_PROVIDER_ID, provider, modelId, "native", "native-family"); + return routeResult(OPENAI_CODEX_PROVIDER_ID, provider, modelId, "native", "native-family"); } // Codex chooses a bare native model for compaction even when the operator's // ordinary route is a third-party provider. Keep the native reservation @@ -788,7 +857,6 @@ function routeModelInternal( if (defaultProvider.disabled !== true) { warnCompactionDefaultProviderFallbackOnce(config.defaultProvider); return routeResult( - config, config.defaultProvider, defaultProvider, modelId, @@ -803,7 +871,7 @@ function routeModelInternal( for (const [provName, prov] of activeProviderEntries(config)) { if (prov.defaultModel === modelId || (typeof prov.defaultModel === "string" && encodeRoutedModelId(prov.defaultModel) === modelId)) { - return routeResult(config, provName, prov, prov.defaultModel as string, "explicit-provider", "configured-default-model"); + return routeResult(provName, prov, prov.defaultModel as string, "explicit-provider", "configured-default-model"); } } @@ -814,7 +882,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(config, provName, prov, hit, "explicit-provider", "configured-model-list"); + return routeResult(provName, prov, hit, "explicit-provider", "configured-model-list"); } } } @@ -834,7 +902,23 @@ function routeModelInternal( } if (aliasMatches[0]) { const match = aliasMatches[0]; - return routeResult(config, match.provider, config.providers[match.provider], match.model, "explicit-provider", "model-alias"); + const providerQualifiedKey = `${match.provider}/${match.model}`; + const qualifiedRedirect = resolveBlockedModelRedirectChain(config, providerQualifiedKey, sharedRedirectState); + const effectiveAliasRedirect = qualifiedRedirect.redirected + ? qualifiedRedirect + : resolveBlockedModelRedirectChain(config, match.model, sharedRedirectState); + if (effectiveAliasRedirect.redirected) { + const targetRoute = routeModelInternal( + config, + effectiveAliasRedirect.targetModel, + bypassCombos, + policyEvidence, + allowCompactionNativeFallback, + sharedRedirectState, + ); + return wrapRedirectedRoute(targetRoute, modelId); + } + return routeResult(match.provider, config.providers[match.provider], match.model, "explicit-provider", "model-alias"); } if (config.defaultProvider === LEGACY_CHATGPT_PROVIDER_ID) { @@ -843,7 +927,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, config.defaultProvider, defaultProv, modelId, "default-provider", "default-provider"); + return routeResult(config.defaultProvider, defaultProv, modelId, "default-provider", "default-provider"); } throw new Error(`No provider configured for model: ${modelId}`); @@ -912,7 +996,7 @@ function routeByKnownModelPattern(config: OcxConfig, modelId: string): RouteResu ); if (matchingProvider) { const [provName, prov] = matchingProvider; - return routeResult(config, provName, prov, modelId, "explicit-provider", "model-pattern"); + return routeResult(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/tests/routing/router.test.ts b/tests/routing/router.test.ts index d8b92dfdc72..7ecf3f85751 100644 --- a/tests/routing/router.test.ts +++ b/tests/routing/router.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { mapReasoningEffort } from "../../src/reasoning-effort"; -import { NoEnabledOpenAiProviderError, routeCompactionModel, routeModel } from "../../src/router"; +import { NoEnabledOpenAiProviderError, routeCompactionModel, routeConcreteModel, routeModel } from "../../src/router"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; describe("routeModel registry effort defaults", () => { @@ -698,6 +698,591 @@ describe("routeModel blocked model redirect", () => { }); expect(routed.routeDecision?.requestedModel).toBe("side/gpt-5.6-terra"); }); + test("opt-in intercepts gpt-5.6-terra and rewrites cross-provider to google-antigravity", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "gpt-5.6-terra": "google-antigravity/gemini-3.8-flash-high", + }, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + "google-antigravity": { + adapter: "google-antigravity", + baseUrl: "https://autopush-alkalimakersuite.sandbox.googleapis.com", + models: ["gemini-3.8-flash-high"], + }, + }, + }; + + const routed = routeModel(config, "gpt-5.6-terra"); + expect(routed).toMatchObject({ + providerName: "google-antigravity", + modelId: "gemini-3.8-flash-high", + routeKind: "explicit-provider", + routeReason: "blocked-model-redirect", + }); + expect(routed.routeDecision?.selected).toMatchObject({ + provider: "google-antigravity", + model: "gemini-3.8-flash-high", + reason: "blocked-model-redirect", + }); + expect(routed.routeDecision?.requestedModel).toBe("gpt-5.6-terra"); + }); + + test("supports multi-hop chained redirects", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "gpt-5.6-terra": "gpt-5.6-luna", + "gpt-5.6-luna": "google-antigravity/gemini-3.8-flash-high", + }, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + "google-antigravity": { + adapter: "google-antigravity", + baseUrl: "https://autopush-alkalimakersuite.sandbox.googleapis.com", + models: ["gemini-3.8-flash-high"], + }, + }, + }; + + const routed = routeModel(config, "gpt-5.6-terra"); + expect(routed).toMatchObject({ + providerName: "google-antigravity", + modelId: "gemini-3.8-flash-high", + routeReason: "blocked-model-redirect", + }); + }); + + test("detects redirect cycles and throws error", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "model-a": "model-b", + "model-b": "model-a", + }, + providers: { + openai: { adapter: "openai-responses" }, + }, + }; + + expect(() => routeModel(config, "model-a")).toThrow(/cycle detected/i); + }); + + test("fails closed when account-namespaced request redirects cross-provider", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "gpt-5.6-terra": "google-antigravity/gemini-3.8-flash-high", + }, + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex" }, + "google-antigravity": { adapter: "google-antigravity", models: ["gemini-3.8-flash-high"] }, + }, + codexAccountNamespaces: { side: "side-account-id" }, + }; + + expect(() => routeModel(config, "side/gpt-5.6-terra")).toThrow(/only supports native OpenAI model ids/i); + }); + + test("does not intercept qualified provider model id when only bare model is configured in blockedModelRedirects", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "claude-3-5-sonnet": "google-antigravity/gemini-3.8-flash-high", + }, + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex" }, + anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", models: ["claude-3-5-sonnet"] }, + "google-antigravity": { + adapter: "google-antigravity", + baseUrl: "https://autopush-alkalimakersuite.sandbox.googleapis.com", + models: ["gemini-3.8-flash-high"], + }, + }, + }; + + const routed = routeModel(config, "anthropic/claude-3-5-sonnet"); + expect(routed).toMatchObject({ + providerName: "anthropic", + modelId: "claude-3-5-sonnet", + routeReason: "explicit-provider-namespace", + }); + }); + + test("rewrites qualified provider prefix model id when qualified model is configured in blockedModelRedirects", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "anthropic/claude-3-5-sonnet": "google-antigravity/gemini-3.8-flash-high", + }, + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex" }, + anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", models: ["claude-3-5-sonnet"] }, + "google-antigravity": { + adapter: "google-antigravity", + baseUrl: "https://autopush-alkalimakersuite.sandbox.googleapis.com", + models: ["gemini-3.8-flash-high"], + }, + }, + }; + + const routed = routeModel(config, "anthropic/claude-3-5-sonnet"); + expect(routed).toMatchObject({ + providerName: "google-antigravity", + modelId: "gemini-3.8-flash-high", + routeReason: "blocked-model-redirect", + }); + }); + + test("prevents cross-provider collision when different providers share bare model name", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "openai/shared-model": "google-antigravity/gemini-3.8-flash-high", + }, + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", models: ["shared-model"] }, + anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", models: ["shared-model"] }, + "google-antigravity": { + adapter: "google-antigravity", + baseUrl: "https://autopush-alkalimakersuite.sandbox.googleapis.com", + models: ["gemini-3.8-flash-high"], + }, + }, + }; + + const anthropicRouted = routeModel(config, "anthropic/shared-model"); + expect(anthropicRouted).toMatchObject({ + providerName: "anthropic", + modelId: "shared-model", + routeReason: "explicit-provider-namespace", + }); + + const openaiRouted = routeModel(config, "openai/shared-model"); + expect(openaiRouted).toMatchObject({ + providerName: "google-antigravity", + modelId: "gemini-3.8-flash-high", + routeReason: "blocked-model-redirect", + }); + }); + + test("resolves chain of exactly 5 hops successfully but rejects 6 hops", () => { + const fiveHopsConfig: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + m1: "m2", + m2: "m3", + m3: "m4", + m4: "m5", + m5: "target-model", + }, + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", models: ["target-model"] }, + }, + }; + + const routed = routeModel(fiveHopsConfig, "m1"); + expect(routed).toMatchObject({ + modelId: "target-model", + routeReason: "blocked-model-redirect", + }); + + const sixHopsConfig: OcxConfig = { + ...fiveHopsConfig, + blockedModelRedirects: { + ...fiveHopsConfig.blockedModelRedirects, + m0: "m1", + }, + }; + + expect(() => routeModel(sixHopsConfig, "m0")).toThrow(/exceeded maximum redirect depth \(5\)/i); + }); + + test("propagates bypassCombos consistently during redirect resolution", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "blocked-combo": "combo-alias", + }, + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", models: ["m-primary"] }, + anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", models: ["m-fallback"] }, + }, + combos: { + "combo-alias": { + strategy: "failover", + alias: "combo-alias", + targets: [ + { provider: "openai", model: "m-primary" }, + { provider: "anthropic", model: "m-fallback" }, + ], + }, + }, + }; + + const routedPublic = routeModel(config, "blocked-combo"); + expect(routedPublic.routeKind).toBe("combo"); + expect(routedPublic.routeReason).toBe("blocked-model-redirect"); + expect(routedPublic.providerName).toBe("openai"); + + const routedConcrete = routeConcreteModel(config, "blocked-combo"); + expect(routedConcrete.routeKind).not.toBe("combo"); + }); + + test("redirects when an alias resolves to a blocked model", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "gpt-5.6-terra": "google-antigravity/gemini-3.8-flash-high", + }, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: ["gpt-5.6-terra", "gpt-5.6-luna"], + modelAliases: { + "gpt-5.6-terra": "fast-work", + }, + }, + "google-antigravity": { + adapter: "google-antigravity", + baseUrl: "https://autopush-alkalimakersuite.sandbox.googleapis.com", + models: ["gemini-3.8-flash-high"], + }, + }, + }; + + const routed = routeModel(config, "fast-work"); + expect(routed).toMatchObject({ + providerName: "google-antigravity", + modelId: "gemini-3.8-flash-high", + routeReason: "blocked-model-redirect", + }); + }); + + test("supports account-qualified key in blockedModelRedirects", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "side/gpt-5.6-terra": "google-antigravity/gemini-3.8-flash-high", + }, + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", models: ["gpt-5.6-terra"] }, + "google-antigravity": { + adapter: "google-antigravity", + baseUrl: "https://autopush-alkalimakersuite.sandbox.googleapis.com", + models: ["gemini-3.8-flash-high"], + }, + }, + codexAccountNamespaces: { + side: "account-2", + }, + }; + + const routed = routeModel(config, "side/gpt-5.6-terra"); + expect(routed).toMatchObject({ + providerName: "google-antigravity", + modelId: "gemini-3.8-flash-high", + routeReason: "blocked-model-redirect", + }); + expect(routed.provider.baseUrl).toBe("https://autopush-alkalimakersuite.sandbox.googleapis.com"); + expect(routed.codexAccountId).toBeUndefined(); + expect(routed.codexAccountNamespace).toBeUndefined(); + }); + + test("redirects provider-qualified alias when resolved native model is blocked", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "anthropic/claude-3-5-sonnet-20241022": "google-antigravity/gemini-3.8-flash-high", + }, + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex" }, + anthropic: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + models: ["claude-3-5-sonnet-20241022"], + modelAliases: { + "claude-3-5-sonnet-20241022": "sonnet", + }, + }, + "google-antigravity": { + adapter: "google-antigravity", + baseUrl: "https://autopush-alkalimakersuite.sandbox.googleapis.com", + models: ["gemini-3.8-flash-high"], + }, + }, + }; + + const routed = routeModel(config, "anthropic/sonnet"); + expect(routed).toMatchObject({ + providerName: "google-antigravity", + modelId: "gemini-3.8-flash-high", + routeReason: "blocked-model-redirect", + }); + }); + + test("ignores inherited object prototype properties in blockedModelRedirects", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: {}, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: ["toString", "constructor", "valueOf"], + }, + }, + }; + + const routedToString = routeModel(config, "toString"); + expect(routedToString.routeReason).not.toBe("blocked-model-redirect"); + expect(routedToString.modelId).toBe("toString"); + + const routedConstructor = routeModel(config, "constructor"); + expect(routedConstructor.routeReason).not.toBe("blocked-model-redirect"); + expect(routedConstructor.modelId).toBe("constructor"); + }); + + test("shares redirect budget across alias boundary: exactly 5 edges succeed", () => { + // 3 edges before alias boundary + 2 edges after alias boundary = 5 edges + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "start-model": "step-1", + "step-1": "step-2", + "step-2": "alias-model", + "native-target": "step-4", + "step-4": "google-antigravity/gemini-3.8-flash-high", + }, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: ["native-target"], + modelAliases: { + "native-target": "alias-model", + }, + }, + "google-antigravity": { + adapter: "google-antigravity", + baseUrl: "https://autopush-alkalimakersuite.sandbox.googleapis.com", + models: ["gemini-3.8-flash-high"], + }, + }, + }; + + const routed = routeModel(config, "start-model"); + expect(routed).toMatchObject({ + providerName: "google-antigravity", + modelId: "gemini-3.8-flash-high", + routeReason: "blocked-model-redirect", + }); + }); + + test("shares redirect budget across alias boundary: 6 edges throw maximum redirect depth", () => { + // 3 edges before alias boundary + 3 edges after alias boundary = 6 edges + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "start-model": "step-1", + "step-1": "step-2", + "step-2": "alias-model", + "native-target": "step-4", + "step-4": "step-5", + "step-5": "google-antigravity/gemini-3.8-flash-high", + }, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: ["native-target"], + modelAliases: { + "native-target": "alias-model", + }, + }, + "google-antigravity": { + adapter: "google-antigravity", + baseUrl: "https://autopush-alkalimakersuite.sandbox.googleapis.com", + models: ["gemini-3.8-flash-high"], + }, + }, + }; + + expect(() => routeModel(config, "start-model")).toThrow(/exceeded maximum redirect depth \(5\)/i); + }); + + test("preserves blocked-model-redirect reason when combo physical target is redirected across providers", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "openai/m1": "google-antigravity/gemini-3.8-flash-high", + }, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: ["m1"], + }, + "google-antigravity": { + adapter: "google-antigravity", + baseUrl: "https://autopush-alkalimakersuite.sandbox.googleapis.com", + models: ["gemini-3.8-flash-high"], + }, + }, + combos: { + "combo-fast": { + alias: "combo-fast", + strategy: "failover", + targets: [{ provider: "openai", model: "m1" }], + }, + }, + }; + + const routed = routeModel(config, "combo-fast"); + expect(routed).toMatchObject({ + routeKind: "combo", + routeReason: "blocked-model-redirect", + providerName: "google-antigravity", + modelId: "gemini-3.8-flash-high", + }); + }); + + test("resolves provider-qualified key before bare model when bare alias matches", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "openai/m1": "google-antigravity/gemini-3.8-flash-high", + m1: "openai/m2", + }, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: ["m1", "m2"], + modelAliases: { + m1: "fast-alias", + }, + }, + "google-antigravity": { + adapter: "google-antigravity", + baseUrl: "https://autopush-alkalimakersuite.sandbox.googleapis.com", + models: ["gemini-3.8-flash-high"], + }, + }, + }; + + const routed = routeModel(config, "fast-alias"); + expect(routed).toMatchObject({ + routeReason: "blocked-model-redirect", + providerName: "google-antigravity", + modelId: "gemini-3.8-flash-high", + }); + }); + + test("falls back to bare model redirect when bare alias matches and no qualified key exists", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "m1": "google-antigravity/gemini-3.8-flash-high", + }, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: ["m1"], + modelAliases: { + m1: "fast-alias", + }, + }, + "google-antigravity": { + adapter: "google-antigravity", + baseUrl: "https://autopush-alkalimakersuite.sandbox.googleapis.com", + models: ["gemini-3.8-flash-high"], + }, + }, + }; + + const routed = routeModel(config, "fast-alias"); + expect(routed).toMatchObject({ + routeReason: "blocked-model-redirect", + providerName: "google-antigravity", + modelId: "gemini-3.8-flash-high", + }); + }); + + test("aligns routeDecision.selected when policy candidate is redirected across providers", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "openai/m1": "google-antigravity/gemini-3.8-flash-high", + }, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: ["m1"], + }, + "google-antigravity": { + adapter: "google-antigravity", + baseUrl: "https://autopush-alkalimakersuite.sandbox.googleapis.com", + models: ["gemini-3.8-flash-high"], + }, + }, + routingProfiles: { + fast: { + candidates: [{ provider: "openai", model: "m1" }], + }, + }, + }; + + const routed = routeModel(config, "policy/fast"); + expect(routed).toMatchObject({ + routeKind: "policy", + routeReason: "blocked-model-redirect", + providerName: "google-antigravity", + modelId: "gemini-3.8-flash-high", + }); + expect(routed.routeDecision).toBeDefined(); + expect(routed.routeDecision?.selected).toEqual({ + candidateIndex: 0, + provider: "google-antigravity", + model: "gemini-3.8-flash-high", + reason: "blocked-model-redirect", + }); + expect(routed.routeDecision?.candidates[0]).toMatchObject({ + provider: "openai", + model: "m1", + eligible: true, + }); + }); + }); describe("routeCompactionModel (#2901)", () => {