From 585662c961e397270d045d98fe91fa6baaf588d6 Mon Sep 17 00:00:00 2001 From: chilung Date: Thu, 10 Sep 2026 11:03:02 +0800 Subject: [PATCH 1/7] feat(router): support cross-provider blocked model redirects with cycle detection --- src/lib/shadow-call.ts | 43 +++++++++- src/router.ts | 72 +++++++++++++--- tests/routing/router.test.ts | 155 +++++++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 11 deletions(-) diff --git a/src/lib/shadow-call.ts b/src/lib/shadow-call.ts index 8e94432da5..3e2f254dc2 100644 --- a/src/lib/shadow-call.ts +++ b/src/lib/shadow-call.ts @@ -23,7 +23,48 @@ export function resolveBlockedModelRedirect( if (!config?.blockedModelRedirects || typeof config.blockedModelRedirects !== "object") { return undefined; } - return config.blockedModelRedirects[modelId]; + if (config.blockedModelRedirects[modelId] !== undefined) { + return config.blockedModelRedirects[modelId]; + } + const slash = modelId.indexOf("/"); + if (slash > 0) { + const bare = modelId.slice(slash + 1); + return config.blockedModelRedirects[bare]; + } + return undefined; +} + +/** + * Resolves blocked model redirects recursively with cycle and depth detection. + */ +export function resolveBlockedModelRedirectChain( + config: { blockedModelRedirects?: Record } | undefined, + modelId: string, +): { targetModel: string; redirected: boolean } { + if (!config?.blockedModelRedirects || typeof config.blockedModelRedirects !== "object") { + return { targetModel: modelId, redirected: false }; + } + const visited = new Set(); + 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); + if (visited.size > 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 70e427b74b..f9b53c6286 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 { resolveBlockedModelRedirect, resolveBlockedModelRedirectChain } from "./lib/shadow-call"; import { getStaleCached } from "./codex/model-cache"; import { codexAccountNamespaceEntries } from "./codex/account-namespaces"; import { @@ -557,16 +557,33 @@ function routeResult( routeKind: RouteDecisionKind, routeReason: string, ): RouteResult { - const redirected = resolveBlockedModelRedirect(config, modelId); - const effectiveModelId = redirected ?? modelId; - const effectiveRouteReason = redirected ? "blocked-model-redirect" : routeReason; + const redirect = resolveBlockedModelRedirectChain(config, modelId); + if (redirect.redirected && config) { + const targetRoute = routeModelInternal(config, redirect.targetModel, true); + return { + ...targetRoute, + routeReason: "blocked-model-redirect", + ...(targetRoute.routeDecision + ? { + routeDecision: { + ...targetRoute.routeDecision, + requestedModel: modelId, + selected: { + ...targetRoute.routeDecision.selected, + reason: "blocked-model-redirect", + }, + }, + } + : {}), + }; + } const codexAccountMode = providerCodexAccountMode(providerName, provider); return { providerName, provider: routedProviderConfig(providerName, provider), - modelId: effectiveModelId, + modelId, routeKind, - routeReason: effectiveRouteReason, + routeReason, ...(codexAccountMode ? { codexAccountMode } : {}), }; } @@ -656,12 +673,14 @@ function routeModelInternal( .find(([candidate]) => candidate === namespace); if (binding) { const nativeModelId = modelId.slice(slash + 1); - if (!isBareOpenAiFamilyModel(nativeModelId)) { + const redirect = resolveBlockedModelRedirectChain(config, nativeModelId); + 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 +688,17 @@ 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( + config, + 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,6 +708,32 @@ function routeModelInternal( } } + const redirect = resolveBlockedModelRedirectChain(config, modelId); + if (redirect.redirected) { + const targetRoute = routeModelInternal( + config, + redirect.targetModel, + bypassCombos, + policyEvidence, + allowCompactionNativeFallback, + ); + const routeDecision = targetRoute.routeDecision + ? { + ...targetRoute.routeDecision, + requestedModel: modelId, + selected: { + ...targetRoute.routeDecision.selected, + reason: "blocked-model-redirect", + }, + } + : undefined; + return { + ...targetRoute, + routeReason: "blocked-model-redirect", + ...(routeDecision ? { routeDecision } : {}), + }; + } + if (!bypassCombos && !preservesPhysicalComboProvider(config)) { const combo = tryPickComboModel(config, modelId); if (combo) { diff --git a/tests/routing/router.test.ts b/tests/routing/router.test.ts index d8b92dfdc7..378c7c5417 100644 --- a/tests/routing/router.test.ts +++ b/tests/routing/router.test.ts @@ -698,6 +698,161 @@ 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("rewrites qualified provider prefix model id when bare model is blocked", () => { + 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: "google-antigravity", + modelId: "gemini-3.8-flash-high", + routeReason: "blocked-model-redirect", + }); + }); + + 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", + }); + }); + }); describe("routeCompactionModel (#2901)", () => { From 950e5b5598658a592862d5cb4ecd3cb2956e9126 Mon Sep 17 00:00:00 2001 From: chilung Date: Thu, 10 Sep 2026 05:21:02 +0000 Subject: [PATCH 2/7] fix(router): address maintainer review on blocked model redirects --- .../fr/reference/configuration/routing.md | 8 +- .../ja/reference/configuration/routing.md | 8 +- .../ko/reference/configuration/routing.md | 8 +- .../docs/reference/configuration/routing.md | 16 +- .../ru/reference/configuration/routing.md | 17 +- .../tr/reference/configuration/routing.md | 21 +- .../zh-cn/reference/configuration/routing.md | 8 +- .../zh-tw/reference/configuration/routing.md | 8 +- src/lib/shadow-call.ts | 10 +- src/router.ts | 96 ++++---- tests/routing/router.test.ts | 213 +++++++++++++++++- 11 files changed, 334 insertions(+), 79 deletions(-) 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 64713d645c..baee19ddd6 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,14 @@ 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. ```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" + } } ``` @@ -169,3 +172,4 @@ CLI : `ocx logs explain `, `ocx logs rebuild-index`, `ocx logs index `routingProfiles` est facultatif et uniquement additif : les fichiers de configuration existants continuent de se charger sans modification. Les anciennes lignes de `usage.jsonl` dépourvues de `routeDecision` continuent d’être analysées sans modification. L’index d’historique peut être supprimé : la suppression de `routing-history.sqlite` déclenche sa reconstruction automatique à partir de `usage.jsonl` lors de la requête suivante ; `ocx logs rebuild-index` force cette reconstruction. Ce système n’ajuste automatiquement ni les poids, ni les budgets, ni les ensembles de candidats. + 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 c6a6d80772..d8705d8b09 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,14 @@ opencodex は、要求されたモデルを次の順序で解決します。 ### ブロック対象モデルのリダイレクト -`blockedModelRedirects` は、完全一致する解決済みモデル ID の置換を指定する任意のトップレベル `Record` で、デフォルトでは未設定です。上記の解決順序の後に適用されます。一致した場合、すでに選択されたプロバイダーとアカウントのルートは維持され、上流モデル ID のみが置き換えられ、ルート理由として `blocked-model-redirect` が記録されます。このキーを省略すると、ルーティングは変更されません。 +`blockedModelRedirects` は、モデル ID の置換を指定する任意のトップレベル `Record` で、デフォルトでは未設定です。受信したモデルがキーに一致した場合、対象の置換モデルにリダイレクトされます。対象モデルは同一プロバイダー内での置換だけでなく、別プロバイダーへの再ルーティング(例: `google-antigravity/gemini-3.8-flash-high`)も可能で、複数ホップの連鎖リダイレクト(ループ検出および最大 5 ホップ制限)をサポートします。ルート理由として `blocked-model-redirect` が記録されます。このキーを省略すると、ルーティングは変更されません。 ```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" + } } ``` @@ -126,3 +129,4 @@ CLI: `ocx logs explain `、`ocx logs rebuild-index`、`ocx logs inde ## 移行 `routingProfiles` は任意の追加設定です。既存の設定ファイルと古い `usage.jsonl` 行はそのまま読み込めます。インデックスは使い捨てで、削除すると次回クエリ時に `usage.jsonl` から自動再構築されます。自動チューニングは行われません。 + 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 2f617695ce..661c7094ef 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,14 @@ opencodex는 요청된 model을 다음 순서로 해석합니다: ### 차단된 모델 리디렉션 -`blockedModelRedirects`는 기본적으로 설정되지 않는 선택적 최상위 `Record`이며, 정확히 일치하는 해석된 모델 ID의 대체값을 정의합니다. 위 해석 순서가 끝난 후 적용됩니다. 일치하면 이미 선택된 공급자와 계정 경로는 유지하고 업스트림 모델 ID만 교체하며, 경로 사유를 `blocked-model-redirect`로 기록합니다. 이 키를 생략하면 라우팅이 바뀌지 않습니다. +`blockedModelRedirects`는 기본적으로 설정되지 않는 선택적 최상위 `Record`이며, 모델 ID의 대체값을 정의합니다. 수신된 모델이 키와 일치하면 대상 대체 모델로 리디렉션됩니다. 대상 모델은 동일한 공급자 내에서 대체되거나 다른 공급자로 교차 재라우팅(예: `google-antigravity/gemini-3.8-flash-high`)될 수 있으며, 멀티홉 체인 리디렉션(사이클 감지 및 최대 5홉 깊이 제한)을 지원합니다. 경로 사유는 `blocked-model-redirect`로 기록됩니다. 이 키를 생략하면 라우팅이 바뀌지 않습니다. ```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" + } } ``` @@ -124,3 +127,4 @@ CLI: `ocx logs explain `, `ocx logs rebuild-index`, `ocx logs index- ## 마이그레이션 `routingProfiles`는 선택적 추가 설정입니다. 기존 설정 파일과 이전 `usage.jsonl` 행은 그대로 읽힙니다. 인덱스는 일회용이며 삭제 시 다음 쿼리에서 `usage.jsonl`로 자동 재구축됩니다. 자동 튜닝은 없습니다. + diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index d830faafae..372e625506 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -36,14 +36,19 @@ 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. ```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" + } } ``` @@ -265,3 +270,4 @@ The history index is disposable - deleting `routing-history.sqlite` triggers an automatic rebuild from `usage.jsonl` on the next query; `ocx logs rebuild-index` forces one. Nothing in this system auto-tunes weights, budgets, or candidate sets. + 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 595916aebd..31dec95ca5 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,19 @@ opencodex разрешает запрошенную модель в следую ### Перенаправления заблокированных моделей -`blockedModelRedirects` — необязательный верхнеуровневый `Record` точных замен -разрешённых идентификаторов моделей; по умолчанию не задан. Он применяется после описанного выше -порядка разрешения: при совпадении уже выбранный маршрут провайдера и аккаунта сохраняется, заменяется -только идентификатор вышестоящей модели, а причиной маршрута записывается `blocked-model-redirect`. -Если ключ отсутствует, маршрутизация не меняется. +`blockedModelRedirects` — необязательный верхнеуровневый `Record` замен +идентификаторов моделей; по умолчанию не задан. При совпадении входящей модели с ключом запрос +перенаправляется на целевую модель. Целевая модель может находиться у того же провайдера или +перемаршрутизироваться к другому провайдеру (например, `google-antigravity/gemini-3.8-flash-high`), +с поддержкой многозвенных цепочек перенаправления (до 5 переходов с обнаружением циклов). Причиной +маршрута записывается `blocked-model-redirect`. Если ключ отсутствует, маршрутизация не меняется. ```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" + } } ``` @@ -151,3 +155,4 @@ CLI: `ocx logs explain `, `ocx logs rebuild-index`, `ocx logs index- ## Миграция `routingProfiles` — необязательная аддитивная настройка. Существующие конфиги и старые строки `usage.jsonl` загружаются без изменений. Индекс одноразовый: при удалении он автоматически перестраивается из `usage.jsonl` при следующем запросе. Автонастройки нет. + 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 b1cbe4b484..559fb63676 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,21 @@ 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. ```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" + } } ``` @@ -312,3 +316,4 @@ otomatik bir yeniden oluşturmayı tetikler; `ocx logs rebuild-index` bunu zorla 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 fa7d04ffc5..570e87c133 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,14 @@ opencodex 按以下顺序解析请求的模型: ### 被阻止模型重定向 -`blockedModelRedirects` 是可选的顶层 `Record`,用于精确替换已解析的模型 ID,默认未设置。它在上述解析顺序之后运行:匹配后会保留已选定的提供方和账户路由,仅替换上游模型 ID,并记录路由原因 `blocked-model-redirect`。省略该键则路由保持不变。 +`blockedModelRedirects` 是可选的顶层 `Record`,用于定义模型 ID 的替换规则,默认未设置。当传入的模型匹配键值时,将被重定向至目标替代模型。目标模型可在同一提供方内替换或跨提供方重新路由(例如 `google-antigravity/gemini-3.8-flash-high`),并支持多跳链式重定向(具有循环检测和最多 5 跳深度限制)。路由原因记录为 `blocked-model-redirect`。省略该键则路由保持不变。 ```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" + } } ``` @@ -134,3 +137,4 @@ CLI:`ocx logs explain `、`ocx logs rebuild-index`、`ocx logs ind ## 迁移 `routingProfiles` 是可选的增量配置:现有配置文件与旧 `usage.jsonl` 行均可原样加载。索引是一次性的——删除后会在下次查询时从 `usage.jsonl` 自动重建。系统不会自动调优。 + 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 2001cf14a9..8a0ce52113 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,14 @@ opencodex 依此順序解析請求的模型: ### 封鎖模型重新導向 -`blockedModelRedirects` 是選用的頂層 `Record`,用於精確替換已解析的模型 id,預設不設定。它在上述解析順序後執行:符合時會保留已選取的供應商與帳號路由,僅替換上游模型 id,並記錄路由原因 `blocked-model-redirect`。省略此鍵時,路由維持不變。 +`blockedModelRedirects` 是選用的頂層 `Record`,用於定義模型 ID 的替換規則,預設不設定。當傳入的模型符合鍵值時,將重新導向至目標替代模型。目標模型可維持在相同供應商或重新路由至其他供應商(例如 `google-antigravity/gemini-3.8-flash-high`),並支援多跳鏈式重新導向(具備循環偵測與最多 5 次跳轉深度限制)。路由原因將記錄為 `blocked-model-redirect`。省略此鍵時,路由維持不變。 ```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" + } } ``` @@ -164,3 +167,4 @@ CLI:`ocx logs explain `、`ocx logs rebuild-index`、`ocx logs ind ## 遷移 `routingProfiles` 為可選且附加式:既有設定檔載入不變。舊 `usage.jsonl` 列(無 `routeDecision`)解析不變。歷史索引可拋棄——刪除 `routing-history.sqlite` 會在下一次查詢時從 `usage.jsonl` 自動重建;`ocx logs rebuild-index` 強制執行一次。此系統中沒有任何東西會自動調校權重、預算或候選集。 + diff --git a/src/lib/shadow-call.ts b/src/lib/shadow-call.ts index 3e2f254dc2..209abb55e4 100644 --- a/src/lib/shadow-call.ts +++ b/src/lib/shadow-call.ts @@ -23,15 +23,7 @@ export function resolveBlockedModelRedirect( if (!config?.blockedModelRedirects || typeof config.blockedModelRedirects !== "object") { return undefined; } - if (config.blockedModelRedirects[modelId] !== undefined) { - return config.blockedModelRedirects[modelId]; - } - const slash = modelId.indexOf("/"); - if (slash > 0) { - const bare = modelId.slice(slash + 1); - return config.blockedModelRedirects[bare]; - } - return undefined; + return config.blockedModelRedirects[modelId]; } /** diff --git a/src/router.ts b/src/router.ts index f9b53c6286..1da2a0476c 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, resolveBlockedModelRedirectChain } from "./lib/shadow-call"; +import { resolveBlockedModelRedirectChain } from "./lib/shadow-call"; import { getStaleCached } from "./codex/model-cache"; import { codexAccountNamespaceEntries } from "./codex/account-namespaces"; import { @@ -557,26 +557,6 @@ function routeResult( routeKind: RouteDecisionKind, routeReason: string, ): RouteResult { - const redirect = resolveBlockedModelRedirectChain(config, modelId); - if (redirect.redirected && config) { - const targetRoute = routeModelInternal(config, redirect.targetModel, true); - return { - ...targetRoute, - routeReason: "blocked-model-redirect", - ...(targetRoute.routeDecision - ? { - routeDecision: { - ...targetRoute.routeDecision, - requestedModel: modelId, - selected: { - ...targetRoute.routeDecision.selected, - reason: "blocked-model-redirect", - }, - }, - } - : {}), - }; - } const codexAccountMode = providerCodexAccountMode(providerName, provider); return { providerName, @@ -627,13 +607,38 @@ 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, + depth = 0, ): RouteResult { + if (depth > 5) { + throw new Error(`routeModel exceeded maximum redirect depth (5) for model: ${modelId}`); + } const slash = modelId.indexOf("/"); // Policy namespace is system-reserved: an explicit `policy/` or a // configured profile alias executes the policy evaluator and routes the @@ -659,7 +664,7 @@ 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, depth + 1); return { ...routed, routeKind: "policy" as const, @@ -672,6 +677,10 @@ function routeModelInternal( const binding = codexAccountNamespaceEntries(config) .find(([candidate]) => candidate === namespace); if (binding) { + const fullRedirect = resolveBlockedModelRedirectChain(config, modelId); + if (fullRedirect.redirected) { + return wrapRedirectedRoute(routeModelInternal(config, fullRedirect.targetModel, bypassCombos, policyEvidence, allowCompactionNativeFallback, depth + 1), modelId); + } const nativeModelId = modelId.slice(slash + 1); const redirect = resolveBlockedModelRedirectChain(config, nativeModelId); const effectiveNativeModelId = redirect.targetModel; @@ -716,22 +725,9 @@ function routeModelInternal( bypassCombos, policyEvidence, allowCompactionNativeFallback, + depth + 1, ); - const routeDecision = targetRoute.routeDecision - ? { - ...targetRoute.routeDecision, - requestedModel: modelId, - selected: { - ...targetRoute.routeDecision.selected, - reason: "blocked-model-redirect", - }, - } - : undefined; - return { - ...targetRoute, - routeReason: "blocked-model-redirect", - ...(routeDecision ? { routeDecision } : {}), - }; + return wrapRedirectedRoute(targetRoute, modelId); } if (!bypassCombos && !preservesPhysicalComboProvider(config)) { @@ -740,7 +736,7 @@ function routeModelInternal( 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); + const routed = routeModelInternal(config, concrete, true, undefined, false, depth + 1); return { ...routed, combo, routeKind: "combo" as const, routeReason: "combo-pick" }; } } @@ -810,6 +806,18 @@ function routeModelInternal( const nativeModel = known.includes(decoded) ? decoded : resolveModelAlias(config, prov, known, requestedModel) ?? decoded; + const providerQualifiedId = `${provName}/${nativeModel}`; + const qualifiedRedirect = resolveBlockedModelRedirectChain(config, providerQualifiedId); + if (qualifiedRedirect.redirected) { + return wrapRedirectedRoute(routeModelInternal( + config, + qualifiedRedirect.targetModel, + bypassCombos, + policyEvidence, + allowCompactionNativeFallback, + depth + 1, + ), modelId); + } return routeResult( config, provName, @@ -886,6 +894,18 @@ function routeModelInternal( } if (aliasMatches[0]) { const match = aliasMatches[0]; + const effectiveAliasRedirect = resolveBlockedModelRedirectChain(config, match.model); + if (effectiveAliasRedirect.redirected) { + const targetRoute = routeModelInternal( + config, + effectiveAliasRedirect.targetModel, + bypassCombos, + policyEvidence, + allowCompactionNativeFallback, + depth + 1, + ); + return wrapRedirectedRoute(targetRoute, modelId); + } return routeResult(config, match.provider, config.providers[match.provider], match.model, "explicit-provider", "model-alias"); } diff --git a/tests/routing/router.test.ts b/tests/routing/router.test.ts index 378c7c5417..23f2252f72 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", () => { @@ -795,7 +795,7 @@ describe("routeModel blocked model redirect", () => { expect(() => routeModel(config, "side/gpt-5.6-terra")).toThrow(/only supports native OpenAI model ids/i); }); - test("rewrites qualified provider prefix model id when bare model is blocked", () => { + test("does not intercept qualified provider model id when only bare model is configured in blockedModelRedirects", () => { const config: OcxConfig = { port: 10100, defaultProvider: "openai", @@ -813,6 +813,32 @@ describe("routeModel blocked model redirect", () => { }, }; + 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", @@ -821,6 +847,104 @@ describe("routeModel 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, @@ -845,7 +969,68 @@ describe("routeModel blocked model redirect", () => { }, }; - const routed = routeModel(config, "fast-work"); + 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", + }); + }); + + 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", @@ -853,6 +1038,28 @@ describe("routeModel blocked model redirect", () => { }); }); + test("detects cross-layer recursion between aliases and blocked redirects exceeding depth 5", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + blockedModelRedirects: { + "m-native": "m-alias", + }, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: ["m-native"], + modelAliases: { + "m-native": "m-alias", + }, + }, + }, + }; + + expect(() => routeModel(config, "m-alias")).toThrow("routeModel exceeded maximum redirect depth (5)"); + }); + }); describe("routeCompactionModel (#2901)", () => { From c83070c425e976ec0677bec508a24ebfdf532255 Mon Sep 17 00:00:00 2001 From: chilung Date: Thu, 10 Sep 2026 07:12:42 +0000 Subject: [PATCH 3/7] fix(router): share redirect budget across alias boundaries and document account isolation --- .../fr/reference/configuration/routing.md | 2 + .../ja/reference/configuration/routing.md | 2 + .../ko/reference/configuration/routing.md | 2 + .../docs/reference/configuration/routing.md | 2 + .../ru/reference/configuration/routing.md | 2 + .../tr/reference/configuration/routing.md | 2 + .../zh-cn/reference/configuration/routing.md | 2 + .../zh-tw/reference/configuration/routing.md | 2 + src/lib/shadow-call.ts | 21 ++++- src/router.ts | 48 +++++------ tests/routing/router.test.ts | 84 +++++++++++++++++-- 11 files changed, 134 insertions(+), 35 deletions(-) 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 baee19ddd6..7017948325 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/routing.md +++ b/docs-site/src/content/docs/fr/reference/configuration/routing.md @@ -33,6 +33,8 @@ Les fournisseurs désactivés sont exclus. Un espace de noms explicite qui dési `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 champs de compte (`codexAccountId`, `codexAccountNamespace`) ni des quotas. + ```json { "blockedModelRedirects": { 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 d8705d8b09..24551c48fa 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ja/reference/configuration/routing.md @@ -33,6 +33,8 @@ opencodex は、要求されたモデルを次の順序で解決します。 `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": { 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 661c7094ef..37eddc60b5 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ko/reference/configuration/routing.md @@ -32,6 +32,8 @@ opencodex는 요청된 model을 다음 순서로 해석합니다: `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": { diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 372e625506..a4ad526850 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -43,6 +43,8 @@ target replacement model. Target models can resolve to the same provider or re-r 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 account fields (`codexAccountId`, `codexAccountNamespace`) or quotas. + ```json { "blockedModelRedirects": { 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 31dec95ca5..b47d500291 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ru/reference/configuration/routing.md @@ -44,6 +44,8 @@ opencodex разрешает запрошенную модель в следую с поддержкой многозвенных цепочек перенаправления (до 5 переходов с обнаружением циклов). Причиной маршрута записывается `blocked-model-redirect`. Если ключ отсутствует, маршрутизация не меняется. +Сопоставление также выполняется с нативной моделью, полученной из псевдонима, поэтому псевдонимы, ведущие к заблокированным нативным моделям, перенаправляются соответствующим образом. Для перенаправлений между провайдерами с указанием аккаунта требуется точный ключ (например, `side/gpt-5.6-terra`); голые ключи в пространстве имён аккаунта завершаются ошибкой (fail closed), а целевые маршруты другого провайдера используют целевой провайдер напрямую без наследования полей аккаунта (`codexAccountId`, `codexAccountNamespace`) или квот. + ```json { "blockedModelRedirects": { 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 559fb63676..778200ef6e 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/routing.md +++ b/docs-site/src/content/docs/tr/reference/configuration/routing.md @@ -52,6 +52,8 @@ Döngü algılama ve en fazla 5 atlama derinliği ile çoklu atlamalı zincirlem 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 hesap alanlarını (`codexAccountId`, `codexAccountNamespace`) veya kotaları devralmadan doğrudan hedef sağlayıcıyı kullanır. + ```json { "blockedModelRedirects": { 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 570e87c133..542b95f4bb 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 @@ -37,6 +37,8 @@ opencodex 按以下顺序解析请求的模型: `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": { 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 8a0ce52113..aac6196910 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 @@ -33,6 +33,8 @@ opencodex 依此順序解析請求的模型: `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": { diff --git a/src/lib/shadow-call.ts b/src/lib/shadow-call.ts index 209abb55e4..027da25558 100644 --- a/src/lib/shadow-call.ts +++ b/src/lib/shadow-call.ts @@ -23,7 +23,19 @@ 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; } /** @@ -32,11 +44,13 @@ export function resolveBlockedModelRedirect( 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 visited = new Set(); + const redirectState = state ?? { visited: new Set(), edges: 0 }; + const { visited } = redirectState; let current = modelId; let redirected = false; @@ -49,7 +63,8 @@ export function resolveBlockedModelRedirectChain( throw new Error(`Blocked model redirect cycle detected: ${[...visited, current].join(" -> ")}`); } visited.add(current); - if (visited.size > 5) { + redirectState.edges += 1; + if (redirectState.edges > 5) { throw new Error(`Blocked model redirect exceeded maximum redirect depth (5): ${[...visited, next].join(" -> ")}`); } current = next; diff --git a/src/router.ts b/src/router.ts index 1da2a0476c..8ea09876c6 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 { resolveBlockedModelRedirectChain } 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,7 +550,6 @@ function isBareOpenAiFamilyModel(modelId: string): boolean { } function routeResult( - config: OcxConfig | undefined, providerName: string, provider: OcxProviderConfig, modelId: string, @@ -634,11 +633,9 @@ function routeModelInternal( bypassCombos: boolean, policyEvidence?: PolicyRequestEvidence, allowCompactionNativeFallback = false, - depth = 0, + redirectState?: BlockedModelRedirectState, ): RouteResult { - if (depth > 5) { - throw new Error(`routeModel exceeded maximum redirect depth (5) for model: ${modelId}`); - } + 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 @@ -664,7 +661,7 @@ function routeModelInternal( } const selected = evaluation.candidates[evaluation.selectedIndex]!; const concrete = `${selected.provider}/${selected.model}`; - const routed = routeModelInternal(config, concrete, true, undefined, false, depth + 1); + const routed = routeModelInternal(config, concrete, true, undefined, false, sharedRedirectState); return { ...routed, routeKind: "policy" as const, @@ -677,12 +674,12 @@ function routeModelInternal( const binding = codexAccountNamespaceEntries(config) .find(([candidate]) => candidate === namespace); if (binding) { - const fullRedirect = resolveBlockedModelRedirectChain(config, modelId); + const fullRedirect = resolveBlockedModelRedirectChain(config, modelId, sharedRedirectState); if (fullRedirect.redirected) { - return wrapRedirectedRoute(routeModelInternal(config, fullRedirect.targetModel, bypassCombos, policyEvidence, allowCompactionNativeFallback, depth + 1), modelId); + return wrapRedirectedRoute(routeModelInternal(config, fullRedirect.targetModel, bypassCombos, policyEvidence, allowCompactionNativeFallback, sharedRedirectState), modelId); } const nativeModelId = modelId.slice(slash + 1); - const redirect = resolveBlockedModelRedirectChain(config, 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`); @@ -701,7 +698,6 @@ function routeModelInternal( } return { ...routeResult( - config, OPENAI_CODEX_PROVIDER_ID, provider, effectiveNativeModelId, @@ -717,7 +713,7 @@ function routeModelInternal( } } - const redirect = resolveBlockedModelRedirectChain(config, modelId); + const redirect = resolveBlockedModelRedirectChain(config, modelId, sharedRedirectState); if (redirect.redirected) { const targetRoute = routeModelInternal( config, @@ -725,7 +721,7 @@ function routeModelInternal( bypassCombos, policyEvidence, allowCompactionNativeFallback, - depth + 1, + sharedRedirectState, ); return wrapRedirectedRoute(targetRoute, modelId); } @@ -736,7 +732,7 @@ function routeModelInternal( 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, false, depth + 1); + const routed = routeModelInternal(config, concrete, true, undefined, false, sharedRedirectState); return { ...routed, combo, routeKind: "combo" as const, routeReason: "combo-pick" }; } } @@ -797,7 +793,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. @@ -807,7 +803,7 @@ function routeModelInternal( ? decoded : resolveModelAlias(config, prov, known, requestedModel) ?? decoded; const providerQualifiedId = `${provName}/${nativeModel}`; - const qualifiedRedirect = resolveBlockedModelRedirectChain(config, providerQualifiedId); + const qualifiedRedirect = resolveBlockedModelRedirectChain(config, providerQualifiedId, sharedRedirectState); if (qualifiedRedirect.redirected) { return wrapRedirectedRoute(routeModelInternal( config, @@ -815,11 +811,10 @@ function routeModelInternal( bypassCombos, policyEvidence, allowCompactionNativeFallback, - depth + 1, + sharedRedirectState, ), modelId); } return routeResult( - config, provName, prov, nativeModel, @@ -833,7 +828,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 @@ -848,7 +843,6 @@ function routeModelInternal( if (defaultProvider.disabled !== true) { warnCompactionDefaultProviderFallbackOnce(config.defaultProvider); return routeResult( - config, config.defaultProvider, defaultProvider, modelId, @@ -863,7 +857,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"); } } @@ -874,7 +868,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"); } } } @@ -894,7 +888,7 @@ function routeModelInternal( } if (aliasMatches[0]) { const match = aliasMatches[0]; - const effectiveAliasRedirect = resolveBlockedModelRedirectChain(config, match.model); + const effectiveAliasRedirect = resolveBlockedModelRedirectChain(config, match.model, sharedRedirectState); if (effectiveAliasRedirect.redirected) { const targetRoute = routeModelInternal( config, @@ -902,11 +896,11 @@ function routeModelInternal( bypassCombos, policyEvidence, allowCompactionNativeFallback, - depth + 1, + sharedRedirectState, ); return wrapRedirectedRoute(targetRoute, modelId); } - return routeResult(config, match.provider, config.providers[match.provider], match.model, "explicit-provider", "model-alias"); + return routeResult(match.provider, config.providers[match.provider], match.model, "explicit-provider", "model-alias"); } if (config.defaultProvider === LEGACY_CHATGPT_PROVIDER_ID) { @@ -915,7 +909,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}`); @@ -984,7 +978,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 23f2252f72..1259bf6f25 100644 --- a/tests/routing/router.test.ts +++ b/tests/routing/router.test.ts @@ -1003,6 +1003,9 @@ describe("routeModel blocked model redirect", () => { 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", () => { @@ -1038,26 +1041,97 @@ describe("routeModel blocked model redirect", () => { }); }); - test("detects cross-layer recursion between aliases and blocked redirects exceeding depth 5", () => { + 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: { - "m-native": "m-alias", + "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: ["m-native"], + models: ["native-target"], modelAliases: { - "m-native": "m-alias", + "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, "m-alias")).toThrow("routeModel exceeded maximum redirect depth (5)"); + expect(() => routeModel(config, "start-model")).toThrow(/exceeded maximum redirect depth \(5\)/i); }); }); From 201106a4f158bd8d6a3255c1023624182627291b Mon Sep 17 00:00:00 2001 From: chilung Date: Thu, 10 Sep 2026 08:06:06 +0000 Subject: [PATCH 4/7] fix(router): align policy route decision trace on redirect and clarify auth boundary docs --- .../fr/reference/configuration/routing.md | 2 +- .../ja/reference/configuration/routing.md | 2 +- .../ko/reference/configuration/routing.md | 2 +- .../docs/reference/configuration/routing.md | 2 +- .../ru/reference/configuration/routing.md | 2 +- .../tr/reference/configuration/routing.md | 2 +- .../zh-cn/reference/configuration/routing.md | 2 +- .../zh-tw/reference/configuration/routing.md | 2 +- src/router.ts | 15 +++++- tests/routing/router.test.ts | 47 +++++++++++++++++++ 10 files changed, 68 insertions(+), 10 deletions(-) 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 7017948325..5f5c10482f 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/routing.md +++ b/docs-site/src/content/docs/fr/reference/configuration/routing.md @@ -33,7 +33,7 @@ Les fournisseurs désactivés sont exclus. Un espace de noms explicite qui dési `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 champs de compte (`codexAccountId`, `codexAccountNamespace`) ni des quotas. +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 { 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 24551c48fa..deb86f0238 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ja/reference/configuration/routing.md @@ -33,7 +33,7 @@ opencodex は、要求されたモデルを次の順序で解決します。 `blockedModelRedirects` は、モデル ID の置換を指定する任意のトップレベル `Record` で、デフォルトでは未設定です。受信したモデルがキーに一致した場合、対象の置換モデルにリダイレクトされます。対象モデルは同一プロバイダー内での置換だけでなく、別プロバイダーへの再ルーティング(例: `google-antigravity/gemini-3.8-flash-high`)も可能で、複数ホップの連鎖リダイレクト(ループ検出および最大 5 ホップ制限)をサポートします。ルート理由として `blocked-model-redirect` が記録されます。このキーを省略すると、ルーティングは変更されません。 -マッチングはエイリアスから解決されたネイティブ モデルに対しても実行されるため、ブロック対象のネイティブ モデルに解決されるエイリアスも同様にリダイレクトされます。アカウント修飾されたクロスプロバイダー リダイレクトには完全一致するキー(例: `side/gpt-5.6-terra`)が必要であり、アカウント名前空間付きのベア キーは fail closed します。クロスプロバイダーの宛先は、アカウント フィールド(`codexAccountId`、`codexAccountNamespace`)やクォータを継承せずにターゲット プロバイダーを直接使用します。 +マッチングはエイリアスから解決されたネイティブ モデルに対しても実行されるため、ブロック対象のネイティブ モデルに解決されるエイリアスも同様にリダイレクトされます。アカウント修飾されたクロスプロバイダー リダイレクトには完全一致するキー(例: `side/gpt-5.6-terra`)が必要であり、アカウント名前空間付きのベア キーは fail closed します。クロスプロバイダーの宛先は、ソース プロバイダーの資格情報や認証情報、アカウント フィールド(`codexAccountId`、`codexAccountNamespace`)、クォータを継承せずにターゲット プロバイダーを直接使用します。 ```json { 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 37eddc60b5..f34a88268f 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ko/reference/configuration/routing.md @@ -32,7 +32,7 @@ opencodex는 요청된 model을 다음 순서로 해석합니다: `blockedModelRedirects`는 기본적으로 설정되지 않는 선택적 최상위 `Record`이며, 모델 ID의 대체값을 정의합니다. 수신된 모델이 키와 일치하면 대상 대체 모델로 리디렉션됩니다. 대상 모델은 동일한 공급자 내에서 대체되거나 다른 공급자로 교차 재라우팅(예: `google-antigravity/gemini-3.8-flash-high`)될 수 있으며, 멀티홉 체인 리디렉션(사이클 감지 및 최대 5홉 깊이 제한)을 지원합니다. 경로 사유는 `blocked-model-redirect`로 기록됩니다. 이 키를 생략하면 라우팅이 바뀌지 않습니다. -매칭은 별칭에서 확인된 네이티브 모델에 대해서도 수행되므로 차단된 네이티브 모델로 해석되는 별칭도 적절히 리디렉션됩니다. 계정 한정 교차 공급자 리디렉션에는 정확한 키(예: `side/gpt-5.6-terra`)가 필요하며, 계정 네임스페이스 요청에서 베어 키는 fail closed 합니다. 교차 공급자 대상은 소스 계정 필드(`codexAccountId`, `codexAccountNamespace`)나 할당량을 상속하지 않고 대상 공급자를 직접 사용합니다. +매칭은 별칭에서 확인된 네이티브 모델에 대해서도 수행되므로 차단된 네이티브 모델로 해석되는 별칭도 적절히 리디렉션됩니다. 계정 한정 교차 공급자 리디렉션에는 정확한 키(예: `side/gpt-5.6-terra`)가 필요하며, 계정 네임스페이스 요청에서 베어 키는 fail closed 합니다. 교차 공급자 대상은 소스 공급자의 자격 증명, 인증 정보, 소스 계정 필드(`codexAccountId`, `codexAccountNamespace`) 또는 할당량을 상속하지 않고 대상 공급자를 직접 사용합니다. ```json { diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index a4ad526850..e524faa078 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -43,7 +43,7 @@ target replacement model. Target models can resolve to the same provider or re-r 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 account fields (`codexAccountId`, `codexAccountNamespace`) or quotas. +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 { 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 b47d500291..eee60c8b31 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ru/reference/configuration/routing.md @@ -44,7 +44,7 @@ opencodex разрешает запрошенную модель в следую с поддержкой многозвенных цепочек перенаправления (до 5 переходов с обнаружением циклов). Причиной маршрута записывается `blocked-model-redirect`. Если ключ отсутствует, маршрутизация не меняется. -Сопоставление также выполняется с нативной моделью, полученной из псевдонима, поэтому псевдонимы, ведущие к заблокированным нативным моделям, перенаправляются соответствующим образом. Для перенаправлений между провайдерами с указанием аккаунта требуется точный ключ (например, `side/gpt-5.6-terra`); голые ключи в пространстве имён аккаунта завершаются ошибкой (fail closed), а целевые маршруты другого провайдера используют целевой провайдер напрямую без наследования полей аккаунта (`codexAccountId`, `codexAccountNamespace`) или квот. +Сопоставление также выполняется с нативной моделью, полученной из псевдонима, поэтому псевдонимы, ведущие к заблокированным нативным моделям, перенаправляются соответствующим образом. Для перенаправлений между провайдерами с указанием аккаунта требуется точный ключ (например, `side/gpt-5.6-terra`); голые ключи в пространстве имён аккаунта завершаются ошибкой (fail closed), а целевые маршруты другого провайдера используют целевой провайдер напрямую без наследования учетных данных исходного провайдера, материалов аутентификации, полей аккаунта (`codexAccountId`, `codexAccountNamespace`) или квот. ```json { 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 778200ef6e..a573c31731 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/routing.md +++ b/docs-site/src/content/docs/tr/reference/configuration/routing.md @@ -52,7 +52,7 @@ Döngü algılama ve en fazla 5 atlama derinliği ile çoklu atlamalı zincirlem 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 hesap alanlarını (`codexAccountId`, `codexAccountNamespace`) veya kotaları devralmadan doğrudan hedef sağlayıcıyı kullanır. +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 { 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 542b95f4bb..40b26146f5 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 @@ -37,7 +37,7 @@ opencodex 按以下顺序解析请求的模型: `blockedModelRedirects` 是可选的顶层 `Record`,用于定义模型 ID 的替换规则,默认未设置。当传入的模型匹配键值时,将被重定向至目标替代模型。目标模型可在同一提供方内替换或跨提供方重新路由(例如 `google-antigravity/gemini-3.8-flash-high`),并支持多跳链式重定向(具有循环检测和最多 5 跳深度限制)。路由原因记录为 `blocked-model-redirect`。省略该键则路由保持不变。 -匹配也会针对从别名解析出的原生模型进行,因此解析为被阻止原生模型的别名也会相应重定向。限定账户的跨提供方重定向需要精确键值(例如 `side/gpt-5.6-terra`);在账户命名空间下裸键会 fail closed,且跨提供方目标直接使用目标提供方,不继承来源账户字段(`codexAccountId`、`codexAccountNamespace`)或配额。 +匹配也会针对从别名解析出的原生模型进行,因此解析为被阻止原生模型的别名也会相应重定向。限定账户的跨提供方重定向需要精确键值(例如 `side/gpt-5.6-terra`);在账户命名空间下裸键会 fail closed,且跨提供方目标直接使用目标提供方,不继承来源提供方凭据、身份验证资料、来源账户字段(`codexAccountId`、`codexAccountNamespace`)或配额。 ```json { 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 aac6196910..44fd731c22 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 @@ -33,7 +33,7 @@ opencodex 依此順序解析請求的模型: `blockedModelRedirects` 是選用的頂層 `Record`,用於定義模型 ID 的替換規則,預設不設定。當傳入的模型符合鍵值時,將重新導向至目標替代模型。目標模型可維持在相同供應商或重新路由至其他供應商(例如 `google-antigravity/gemini-3.8-flash-high`),並支援多跳鏈式重新導向(具備循環偵測與最多 5 次跳轉深度限制)。路由原因將記錄為 `blocked-model-redirect`。省略此鍵時,路由維持不變。 -比對亦會針對別名解析出的原生模型進行,因此解析為受封鎖原生模型的別名也會依規則重新導向。帳號限定的跨供應商重新導向需要精確鍵值(例如 `side/gpt-5.6-terra`);在帳號命名空間下裸鍵會 fail closed,且跨供應商目標直接使用目標供應商,不繼承來源帳號欄位(`codexAccountId`、`codexAccountNamespace`)或配額。 +比對亦會針對別名解析出的原生模型進行,因此解析為受封鎖原生模型的別名也會依規則重新導向。帳號限定的跨供應商重新導向需要精確鍵值(例如 `side/gpt-5.6-terra`);在帳號命名空間下裸鍵會 fail closed,且跨供應商目標直接使用目標供應商,不繼承來源供應商憑證、身分驗證資料、來源帳號欄位(`codexAccountId`、`codexAccountNamespace`)或配額。 ```json { diff --git a/src/router.ts b/src/router.ts index 8ea09876c6..4b32ede08b 100644 --- a/src/router.ts +++ b/src/router.ts @@ -662,11 +662,22 @@ function routeModelInternal( const selected = evaluation.candidates[evaluation.selectedIndex]!; const concrete = `${selected.provider}/${selected.model}`; 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) { diff --git a/tests/routing/router.test.ts b/tests/routing/router.test.ts index 1259bf6f25..a510c6aa73 100644 --- a/tests/routing/router.test.ts +++ b/tests/routing/router.test.ts @@ -1134,6 +1134,53 @@ describe("routeModel blocked model redirect", () => { expect(() => routeModel(config, "start-model")).toThrow(/exceeded maximum redirect depth \(5\)/i); }); + 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)", () => { From 3334eba79fb75042631ff02552162f17ca33e42a Mon Sep 17 00:00:00 2001 From: chilung Date: Thu, 10 Sep 2026 08:39:01 +0000 Subject: [PATCH 5/7] fix(router): preserve combo redirect reason and prioritize qualified alias redirects --- src/router.ts | 11 +++- tests/routing/router.test.ts | 101 +++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/src/router.ts b/src/router.ts index 4b32ede08b..bb869b3c38 100644 --- a/src/router.ts +++ b/src/router.ts @@ -744,7 +744,10 @@ function routeModelInternal( // 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, false, sharedRedirectState); - return { ...routed, combo, routeKind: "combo" as const, routeReason: "combo-pick" }; + const routeReason = routed.routeReason === "blocked-model-redirect" + ? "blocked-model-redirect" + : "combo-pick"; + return { ...routed, combo, routeKind: "combo" as const, routeReason }; } } @@ -899,7 +902,11 @@ function routeModelInternal( } if (aliasMatches[0]) { const match = aliasMatches[0]; - const effectiveAliasRedirect = resolveBlockedModelRedirectChain(config, match.model, sharedRedirectState); + 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, diff --git a/tests/routing/router.test.ts b/tests/routing/router.test.ts index a510c6aa73..3a74121466 100644 --- a/tests/routing/router.test.ts +++ b/tests/routing/router.test.ts @@ -1134,6 +1134,107 @@ describe("routeModel blocked model redirect", () => { 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", + }, + 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("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, From 157ba8812d0de9a8f1ad53f941e3ad6b93ecbbdc Mon Sep 17 00:00:00 2001 From: chilung Date: Thu, 10 Sep 2026 09:08:34 +0000 Subject: [PATCH 6/7] test(router): ensure provider-qualified alias redirect precedence is discriminating --- tests/routing/router.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/routing/router.test.ts b/tests/routing/router.test.ts index 3a74121466..7ecf3f8575 100644 --- a/tests/routing/router.test.ts +++ b/tests/routing/router.test.ts @@ -1177,12 +1177,13 @@ describe("routeModel blocked model redirect", () => { 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"], + models: ["m1", "m2"], modelAliases: { m1: "fast-alias", }, From f42c6eb99c40ec049ed81e9b7d7811db0602a52a Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 15 Sep 2026 21:40:13 +0900 Subject: [PATCH 7/7] chore(docs): normalize rebased redirect documentation endings Preserves all six original feature patches. Runtime and documentation builds were not run on the connected host. Co-authored-by: chilung --- .../src/content/docs/fr/reference/configuration/routing.md | 1 - .../src/content/docs/ja/reference/configuration/routing.md | 1 - .../src/content/docs/ko/reference/configuration/routing.md | 1 - docs-site/src/content/docs/reference/configuration/routing.md | 1 - .../src/content/docs/ru/reference/configuration/routing.md | 1 - .../src/content/docs/tr/reference/configuration/routing.md | 2 -- .../src/content/docs/zh-cn/reference/configuration/routing.md | 1 - .../src/content/docs/zh-tw/reference/configuration/routing.md | 1 - 8 files changed, 9 deletions(-) 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 5f5c10482f..672091acc2 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/routing.md +++ b/docs-site/src/content/docs/fr/reference/configuration/routing.md @@ -174,4 +174,3 @@ CLI : `ocx logs explain `, `ocx logs rebuild-index`, `ocx logs index `routingProfiles` est facultatif et uniquement additif : les fichiers de configuration existants continuent de se charger sans modification. Les anciennes lignes de `usage.jsonl` dépourvues de `routeDecision` continuent d’être analysées sans modification. L’index d’historique peut être supprimé : la suppression de `routing-history.sqlite` déclenche sa reconstruction automatique à partir de `usage.jsonl` lors de la requête suivante ; `ocx logs rebuild-index` force cette reconstruction. Ce système n’ajuste automatiquement ni les poids, ni les budgets, ni les ensembles de candidats. - 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 deb86f0238..2851cfc451 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ja/reference/configuration/routing.md @@ -131,4 +131,3 @@ CLI: `ocx logs explain `、`ocx logs rebuild-index`、`ocx logs inde ## 移行 `routingProfiles` は任意の追加設定です。既存の設定ファイルと古い `usage.jsonl` 行はそのまま読み込めます。インデックスは使い捨てで、削除すると次回クエリ時に `usage.jsonl` から自動再構築されます。自動チューニングは行われません。 - 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 f34a88268f..f87564b647 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ko/reference/configuration/routing.md @@ -129,4 +129,3 @@ CLI: `ocx logs explain `, `ocx logs rebuild-index`, `ocx logs index- ## 마이그레이션 `routingProfiles`는 선택적 추가 설정입니다. 기존 설정 파일과 이전 `usage.jsonl` 행은 그대로 읽힙니다. 인덱스는 일회용이며 삭제 시 다음 쿼리에서 `usage.jsonl`로 자동 재구축됩니다. 자동 튜닝은 없습니다. - diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index e524faa078..30a4790a2f 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -272,4 +272,3 @@ The history index is disposable - deleting `routing-history.sqlite` triggers an automatic rebuild from `usage.jsonl` on the next query; `ocx logs rebuild-index` forces one. Nothing in this system auto-tunes weights, budgets, or candidate sets. - 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 eee60c8b31..4ef3134707 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ru/reference/configuration/routing.md @@ -157,4 +157,3 @@ CLI: `ocx logs explain `, `ocx logs rebuild-index`, `ocx logs index- ## Миграция `routingProfiles` — необязательная аддитивная настройка. Существующие конфиги и старые строки `usage.jsonl` загружаются без изменений. Индекс одноразовый: при удалении он автоматически перестраивается из `usage.jsonl` при следующем запросе. Автонастройки нет. - 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 a573c31731..6373681966 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/routing.md +++ b/docs-site/src/content/docs/tr/reference/configuration/routing.md @@ -317,5 +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 40b26146f5..6e75cb084f 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 @@ -139,4 +139,3 @@ CLI:`ocx logs explain `、`ocx logs rebuild-index`、`ocx logs ind ## 迁移 `routingProfiles` 是可选的增量配置:现有配置文件与旧 `usage.jsonl` 行均可原样加载。索引是一次性的——删除后会在下次查询时从 `usage.jsonl` 自动重建。系统不会自动调优。 - 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 44fd731c22..85a5ccf2b9 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 @@ -169,4 +169,3 @@ CLI:`ocx logs explain `、`ocx logs rebuild-index`、`ocx logs ind ## 遷移 `routingProfiles` 為可選且附加式:既有設定檔載入不變。舊 `usage.jsonl` 列(無 `routeDecision`)解析不變。歷史索引可拋棄——刪除 `routing-history.sqlite` 會在下一次查詢時從 `usage.jsonl` 自動重建;`ocx logs rebuild-index` 強制執行一次。此系統中沒有任何東西會自動調校權重、預算或候選集。 -