From 0bac530ae2d764d9ea035ce87286ef9e880129f1 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 29 Aug 2026 10:28:36 +0900 Subject: [PATCH] fix(codex): keep native eligibility metadata off routed catalog rows deriveEntry deep-clones a native template and deletes a fixed denylist, so supported_in_api, available_in_plans, minimal_client_version, availability_nux and upgrade survive onto routed rows. A model backed by unrelated provider credentials ends up advertising ChatGPT plan eligibility it does not have. findNativeTemplate also accepted ANY bare row carrying base_instructions, so a row the client injects that opencodex has never seen could become the template every routed model inherits from. #2813 reports exactly such a row: a reserve fallback that appears when the native five-hour quota runs out. Adds findSupportedNativeTemplate, restricted to the known native roster, and switches the three template-SELECTION sites to it. The four catalog VALIDITY gates deliberately keep the permissive function: narrowing them would make a catalog holding only a newly launched native model look invalid and get replaced by stale fallback data, trading a latent bug for an active one. A regression test fails if anyone narrows them later. The sanitation lives in ensureStrictCatalogFields rather than normalizeRoutedCatalogEntry because the latter only runs on freshly derived rows. Degraded-provider and foreign routed rows are preserved from disk and reach the merge through ensureStrictCatalogFields alone, so sanitizing there would leave rows already on disk contaminated. Both paths now converge on one guarantee, and native rows keep their own eligibility metadata. None of this can re-enable a picker row the client greyed out; that gate is applied before a request reaches the proxy. Documents the limitation, and the explicit provider/model selection path, in English and all seven locales, saying plainly that proxy-side routing is proven while app behaviour under reserve mode is not. Verification: tests/codex-catalog.test.ts 214 pass; codex-runtime, codex-retained-root-serialization, codex-tool-mode, cursor-static-catalog and provider-registry-parity 94 pass; typecheck clean; privacy:scan passed. Five mutations driven red, each sanitizer independently, plus the narrow-validity mutation that proves the rejected design would break catalog loading. Refs #2813 --- .../docs/fr/guides/codex-app-models.md | 20 ++++ .../content/docs/guides/codex-app-models.md | 32 +++++ .../docs/ja/guides/codex-app-models.md | 20 ++++ .../docs/ko/guides/codex-app-models.md | 20 ++++ .../docs/ru/guides/codex-app-models.md | 20 ++++ .../docs/tr/guides/codex-app-models.md | 21 +++- .../docs/zh-cn/guides/codex-app-models.md | 20 ++++ .../docs/zh-tw/guides/codex-app-models.md | 20 ++++ src/codex/catalog/bundled.ts | 12 +- src/codex/catalog/parsing.ts | 44 +++++++ src/codex/catalog/sync.ts | 5 +- src/codex/convergence.ts | 5 +- tests/codex-catalog.test.ts | 110 ++++++++++++++++++ 13 files changed, 339 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/codex-app-models.md b/docs-site/src/content/docs/fr/guides/codex-app-models.md index 9bec4a20d8..2c782dffe0 100644 --- a/docs-site/src/content/docs/fr/guides/codex-app-models.md +++ b/docs-site/src/content/docs/fr/guides/codex-app-models.md @@ -244,6 +244,26 @@ Tant que Desktop ne permet pas de contrôler cette liste d'autorisation : - Utilisez Codex CLI ou TUI plutôt que le sélecteur de Desktop ; ces interfaces n'appliquent pas la liste d'autorisation et répertorient normalement les modèles routés. ## Actualisation de l'état des modèles +## Limitation du repli sur quota natif + +Lorsque l'application Codex épuise son quota natif de cinq heures, elle peut basculer vers un modèle de repli de réserve et griser les autres lignes de son sélecteur. Signalé dans [#2813](https://github.com/lidge-jun/opencodex/issues/2813), ce filtrage masque aussi les lignes routées par opencodex, alors que celles-ci utilisent des identifiants de fournisseur sans rapport et ne consomment aucun quota ChatGPT. + +Ce filtrage est appliqué par le client avant que la requête n'atteigne le proxy, donc opencodex ne peut pas le lever. Les lignes routées sont écrites avec `visibility: "list"`, le filtrage du catalogue ne consulte que `disabledModels` et le `selectedModels` de chaque fournisseur, et aucune valeur de quota n'intervient dans la visibilité routée. + +Sélectionner un modèle routé explicitement ne passe pas par le sélecteur. Définissez le modèle dans `config.toml` : + +```toml +model = "anthropic/claude-sonnet-5" +``` + +ou envoyez-le directement : + +```bash +ocx access test anthropic/claude-sonnet-5 --protocol responses +``` + +Les deux chemins routent correctement **dès que la requête atteint le proxy** — c'est couvert par des tests. Ce qui n'est pas établi, c'est si l'application envoie encore le modèle configuré pendant le mode réserve ; si le client le réécrit ou le refuse avant l'envoi, aucun réglage côté proxy n'y change quoi que ce soit. Considérez la sélection explicite comme une piste à essayer plutôt qu'un contournement confirmé. + Si le sélecteur affiche encore des entrées obsolètes, actualisez le catalogue et redémarrez l'interface Codex concernée : diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index b0910bd9d5..c84f8f6407 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -105,6 +105,38 @@ them by ignoring `visibility`. See [Codex Desktop native-allowlist compatibility for the command, disable-key semantics, and safety constraints. ## Integration path +## Native quota fallback limitation + +When the Codex app exhausts its native five-hour quota it can switch to a reserve +fallback model and grey out the other rows in its picker. Reported in +[#2813](https://github.com/lidge-jun/opencodex/issues/2813), that gating also hides routed +opencodex rows, even though those use unrelated provider credentials and consume none of the +ChatGPT quota. + +This gate is applied by the client before a request reaches the proxy, so opencodex cannot lift +it. Routed rows are written with `visibility: "list"`, catalog filtering consults only +`disabledModels` and each provider's `selectedModels`, and no quota value takes part in routed +visibility. + +Selecting a routed model explicitly does not go through the picker. Set the model in +`config.toml`: + +```toml +model = "anthropic/claude-sonnet-5" +``` + +or send it directly: + +```bash +ocx access test anthropic/claude-sonnet-5 --protocol responses +``` + +Both paths route correctly **once the request reaches the proxy** — that part is covered by +tests. What is not established is whether the app still sends the configured model while reserve +mode is active; if the client rewrites or refuses it before the request leaves, no proxy-side +setting changes that. Treat the explicit-selection route as worth trying rather than a confirmed +workaround. + `ocx init`, `ocx start`, and `ocx sync` wire the shared Codex config and catalog into the proxy; see [Codex Integration](/guides/codex-integration/) for config injection, catalog sync, shims, WebSocket diff --git a/docs-site/src/content/docs/ja/guides/codex-app-models.md b/docs-site/src/content/docs/ja/guides/codex-app-models.md index 1ec7fbf010..f654b183d0 100644 --- a/docs-site/src/content/docs/ja/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ja/guides/codex-app-models.md @@ -118,6 +118,26 @@ Desktop が許可リストの制御を提供するまでは: - Desktop ピッカーの代わりに Codex CLI または TUI を使用します。これらは許可リストを適用せず、ルーティングモデルを通常どおり一覧表示します。 ## モデルの状態を更新しています +## ネイティブクォータのフォールバック制限 + +Codex アプリがネイティブの 5 時間クォータを使い切ると、リザーブのフォールバックモデルに切り替わり、ピッカーの他の行がグレーアウトすることがあります。[#2813](https://github.com/lidge-jun/opencodex/issues/2813) で報告されたこの制御は、opencodex がルーティングした行も隠します。これらは無関係なプロバイダー資格情報を使い、ChatGPT のクォータを一切消費しません。 + +この制御はリクエストがプロキシに届く前にクライアント側で適用されるため、opencodex では解除できません。ルーティング行は `visibility: "list"` で書き込まれ、カタログのフィルタリングは `disabledModels` と各プロバイダーの `selectedModels` だけを参照し、クォータ値はルーティング行の可視性に一切関与しません。 + +ルーティングモデルを明示的に選ぶ経路はピッカーを通りません。`config.toml` でモデルを指定します。 + +```toml +model = "anthropic/claude-sonnet-5" +``` + +または直接送信します。 + +```bash +ocx access test anthropic/claude-sonnet-5 --protocol responses +``` + +どちらの経路も **リクエストがプロキシに届いた後は** 正しくルーティングされ、これはテストで確認済みです。確認できていないのは、リザーブモード中にアプリが設定したモデルを実際に送るかどうかです。クライアントが送信前に書き換えたり拒否したりする場合、プロキシ側の設定では変えられません。明示的な指定は確定した回避策ではなく、試す価値のある手段として扱ってください。 + ピッカーに古いエントリがまだ表示されている場合は、カタログを更新し、ターゲットの Codex サーフェスを再起動します。 diff --git a/docs-site/src/content/docs/ko/guides/codex-app-models.md b/docs-site/src/content/docs/ko/guides/codex-app-models.md index 4018a2fe87..e114ab2b01 100644 --- a/docs-site/src/content/docs/ko/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ko/guides/codex-app-models.md @@ -204,6 +204,26 @@ Desktop이 허용 목록을 제어할 수 있게 될 때까지: 라우팅 모델을 정상적으로 나열합니다. ## 모델 상태 새로고침 +## 네이티브 쿼터 폴백 제한 + +Codex 앱이 네이티브 5시간 쿼터를 다 쓰면 리저브 폴백 모델로 넘어가면서 피커의 다른 줄을 회색으로 만들 수 있습니다. [#2813](https://github.com/lidge-jun/opencodex/issues/2813)에 보고된 이 차단은 opencodex가 넣은 라우팅 줄까지 가립니다. 그 줄들은 관계없는 프로바이더 자격 증명을 쓰고 ChatGPT 쿼터를 전혀 쓰지 않습니다. + +이 차단은 요청이 프록시에 닿기 전에 클라이언트가 적용하므로 opencodex가 풀 수 없습니다. 라우팅 줄은 `visibility: "list"`로 기록되고, 카탈로그 필터링은 `disabledModels`와 프로바이더별 `selectedModels`만 봅니다. 쿼터 값은 라우팅 줄의 노출에 관여하지 않습니다. + +라우팅 모델을 직접 지정하는 경로는 피커를 거치지 않습니다. `config.toml`에 모델을 적습니다. + +```toml +model = "anthropic/claude-sonnet-5" +``` + +또는 바로 보냅니다. + +```bash +ocx access test anthropic/claude-sonnet-5 --protocol responses +``` + +두 경로 모두 **요청이 프록시에 도달한 뒤에는** 정상 라우팅되고, 이건 테스트로 덮여 있습니다. 확인되지 않은 부분은 리저브 모드에서 앱이 설정한 모델을 실제로 보내는지입니다. 클라이언트가 보내기 전에 바꾸거나 거부하면 프록시 설정으로는 바꿀 수 없습니다. 명시적 지정은 확정된 우회책이 아니라 시도해 볼 방법으로 보세요. + picker에 오래된 항목이 계속 보이면 카탈로그를 새로 쓰고 대상 Codex 서피스를 다시 시작합니다: diff --git a/docs-site/src/content/docs/ru/guides/codex-app-models.md b/docs-site/src/content/docs/ru/guides/codex-app-models.md index 0271a3b577..f7c7f6a2a0 100644 --- a/docs-site/src/content/docs/ru/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ru/guides/codex-app-models.md @@ -187,6 +187,26 @@ Codex сортирует видимые в picker'е записи каталог показывают маршрутизированные модели как обычно. ## Обновление состояния моделей +## Ограничение при откате по нативной квоте + +Когда приложение Codex исчерпывает нативную пятичасовую квоту, оно может переключиться на резервную модель и сделать остальные строки в своём списке недоступными. Судя по [#2813](https://github.com/lidge-jun/opencodex/issues/2813), это ограничение скрывает и маршрутизируемые строки opencodex, хотя они используют не связанные с ChatGPT учётные данные провайдеров и не расходуют его квоту. + +Ограничение применяет клиент до того, как запрос доходит до прокси, поэтому opencodex не может его снять. Маршрутизируемые строки записываются с `visibility: "list"`, фильтрация каталога смотрит только на `disabledModels` и `selectedModels` каждого провайдера, и никакое значение квоты не участвует в видимости маршрутизируемых строк. + +Явный выбор маршрутизируемой модели не проходит через список. Укажите модель в `config.toml`: + +```toml +model = "anthropic/claude-sonnet-5" +``` + +или отправьте её напрямую: + +```bash +ocx access test anthropic/claude-sonnet-5 --protocol responses +``` + +Оба пути маршрутизируются корректно **после того, как запрос дошёл до прокси**, и это покрыто тестами. Не установлено другое: отправляет ли приложение настроенную модель в резервном режиме. Если клиент переписывает или отклоняет её до отправки, никакая настройка на стороне прокси этого не изменит. Считайте явный выбор способом, который стоит попробовать, а не подтверждённым обходным путём. + Если picker всё ещё показывает устаревшие записи, обновите каталог и перезапустите нужную поверхность Codex: diff --git a/docs-site/src/content/docs/tr/guides/codex-app-models.md b/docs-site/src/content/docs/tr/guides/codex-app-models.md index fb38739257..93a1db6a62 100644 --- a/docs-site/src/content/docs/tr/guides/codex-app-models.md +++ b/docs-site/src/content/docs/tr/guides/codex-app-models.md @@ -283,6 +283,26 @@ Desktop izin listesi için bir denetim sunana kadar: uygulamaz ve yönlendirilen modelleri normal şekilde listeler. ## Model durumunu yenileme +## Yerel kota geri dönüş kısıtı + +Codex uygulaması yerel beş saatlik kotasını tükettiğinde bir rezerv yedek modeline geçip seçicideki diğer satırları soluklaştırabilir. [#2813](https://github.com/lidge-jun/opencodex/issues/2813) numaralı raporda görüldüğü gibi bu kısıtlama, ilgisiz sağlayıcı kimlik bilgileri kullanan ve ChatGPT kotasından hiç tüketmeyen opencodex yönlendirmeli satırları da gizliyor. + +Bu kısıt istek proxy'ye ulaşmadan önce istemci tarafında uygulanır, dolayısıyla opencodex onu kaldıramaz. Yönlendirilen satırlar `visibility: "list"` ile yazılır, katalog filtrelemesi yalnızca `disabledModels` ve her sağlayıcının `selectedModels` değerine bakar ve hiçbir kota değeri yönlendirilen görünürlüğe katılmaz. + +Yönlendirilen bir modeli açıkça seçmek seçiciden geçmez. Modeli `config.toml` içinde ayarlayın: + +```toml +model = "anthropic/claude-sonnet-5" +``` + +ya da doğrudan gönderin: + +```bash +ocx access test anthropic/claude-sonnet-5 --protocol responses +``` + +Her iki yol da **istek proxy'ye ulaştıktan sonra** doğru yönlendirilir; bu testlerle kapsanıyor. Kanıtlanmayan nokta, rezerv modu etkinken uygulamanın yapılandırılan modeli hâlâ gönderip göndermediğidir; istemci onu göndermeden önce değiştirir ya da reddederse proxy tarafındaki hiçbir ayar bunu değiştirmez. Açık seçimi doğrulanmış bir geçici çözüm değil, denemeye değer bir yol olarak görün. + Seçici hala eski girdileri gösteriyorsa kataloğu yenileyin ve hedef Codex yüzeyini yeniden başlatın: @@ -295,4 +315,3 @@ opencodex, katalog görünürlüğü, önceliği veya meta verileri her değişt `models_cache.json` dosyasını kasıtlı olarak eski bir önbellek sarmalayıcısıyla yeniden yazar, böylece bir sonraki Codex model yenilemesi yeni kataloğu okur. - diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md index 123b424d95..7709de7721 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md @@ -131,6 +131,26 @@ Codex Desktop 的远程服务器模式会针对客户端自己的 `available_mod - 改用 Codex CLI 或 TUI,而不是 Desktop 选择器;它们不应用该白名单,会正常列出路由模型。 ## 刷新模型状态 +## 原生配额回退限制 + +Codex 应用用完原生的五小时配额后,可能切换到预备回退模型,并把选择器里其他行置灰。正如 [#2813](https://github.com/lidge-jun/opencodex/issues/2813) 所报告的,这个限制同样会隐藏 opencodex 路由的行,而那些行使用的是无关的提供方凭据,不消耗任何 ChatGPT 配额。 + +这个限制由客户端在请求到达代理之前施加,因此 opencodex 无法解除。路由行写入时带 `visibility: "list"`,目录过滤只读取 `disabledModels` 和各提供方的 `selectedModels`,任何配额值都不参与路由行的可见性。 + +显式选择路由模型不经过选择器。在 `config.toml` 中设置模型: + +```toml +model = "anthropic/claude-sonnet-5" +``` + +或者直接发送: + +```bash +ocx access test anthropic/claude-sonnet-5 --protocol responses +``` + +**请求到达代理之后**,两条路径都能正确路由,这一点有测试覆盖。尚未确认的是:预备模式生效时,应用是否仍会发送已配置的模型。如果客户端在发出之前重写或拒绝它,代理端的任何设置都改变不了。请把显式选择当作值得一试的做法,而不是已确认的规避方案。 + 如果选择器里仍然显示旧条目,请刷新目录并重启目标 Codex 界面: diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md b/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md index 0b6b0cb7c1..b2299af4d6 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md @@ -178,6 +178,26 @@ allowlist 上的項目。opencodex 無法介入該清單;上游錯誤追蹤於 - 改用 Codex CLI 或 TUI 而不是 Desktop 選擇器;它們不會套用 allowlist,會正常列出路由模型。 ## 重新整理模型狀態 +## 原生配額回退限制 + +Codex 應用程式用完原生的五小時配額後,可能切換到預備回退模型,並把選擇器裡其他列變灰。如 [#2813](https://github.com/lidge-jun/opencodex/issues/2813) 所報告,這個限制同樣會隱藏 opencodex 路由的列,而那些列使用的是無關的供應商憑證,不消耗任何 ChatGPT 配額。 + +這個限制由用戶端在請求抵達代理之前施加,因此 opencodex 無法解除。路由列寫入時帶 `visibility: "list"`,目錄過濾只讀取 `disabledModels` 與各供應商的 `selectedModels`,任何配額值都不參與路由列的可見性。 + +明確選擇路由模型不會經過選擇器。在 `config.toml` 中設定模型: + +```toml +model = "anthropic/claude-sonnet-5" +``` + +或直接送出: + +```bash +ocx access test anthropic/claude-sonnet-5 --protocol responses +``` + +**請求抵達代理之後**,兩條路徑都能正確路由,這點有測試覆蓋。尚未確認的是:預備模式生效時,應用程式是否仍會送出已設定的模型。如果用戶端在送出前改寫或拒絕它,代理端的任何設定都改變不了。請把明確選擇當成值得一試的做法,而非已確認的規避方案。 + 如果選擇器仍顯示舊條目,請重新整理目錄並重新開啟目標 Codex 介面: diff --git a/src/codex/catalog/bundled.ts b/src/codex/catalog/bundled.ts index 267da154ac..ff6e909866 100644 --- a/src/codex/catalog/bundled.ts +++ b/src/codex/catalog/bundled.ts @@ -31,7 +31,7 @@ import { redactSecretString } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; -import { activeCodexModelsCachePath, catalogBackupPathFor, findNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing"; +import { activeCodexModelsCachePath, catalogBackupPathFor, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing"; import type { RawCatalog, RawEntry } from "./parsing"; import { codexExecInvocation, isSpawnableCodexCandidate } from "../exec-invocation"; import { @@ -541,9 +541,11 @@ export function readCurrentCodexModelsCache(): RawCatalog | null { export function loadCatalogTemplate(): RawEntry | null { const catalogPath = readCodexCatalogPath(); const bundled = loadBundledCodexCatalog(); - const native = findNativeTemplate(readCatalog(catalogPath)) - ?? findNativeTemplate(readCatalogBackup(catalogPath)) - ?? findNativeTemplate(readCatalog(activeCodexModelsCachePath())) - ?? findNativeTemplate(bundled ? JSON.parse(JSON.stringify(bundled)) as RawCatalog : null); + // Template inheritance only. The validity gates in this file keep `findNativeTemplate` + // so a catalog carrying only a newly launched native row stays valid (#2813). + const native = findSupportedNativeTemplate(readCatalog(catalogPath)) + ?? findSupportedNativeTemplate(readCatalogBackup(catalogPath)) + ?? findSupportedNativeTemplate(readCatalog(activeCodexModelsCachePath())) + ?? findSupportedNativeTemplate(bundled ? JSON.parse(JSON.stringify(bundled)) as RawCatalog : null); return native ? JSON.parse(JSON.stringify(native)) : null; } diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 94241e8a75..218a806824 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -264,6 +264,34 @@ export function findNativeTemplate(catalog: RawCatalog | null): RawEntry | null ) ?? null; } +/** + * Template selection, as opposed to catalog VALIDITY. + * + * `findNativeTemplate` answers "does this look like a real catalog?" and must stay + * permissive: four call sites use it as a validity gate, and a catalog holding only a + * newly launched native model has to keep passing or sync falls back to stale data. + * + * This answers a different question — "which row should every routed model inherit + * from?" — and must be strict. `deriveEntry` deep-clones the chosen row, so an unknown + * bare row carrying `base_instructions` would become the template for every routed + * model and hand them its native eligibility metadata. #2813 is the report that made + * that concrete: a Reserve-shaped row injected by the client is exactly such a row. + * + * Returning null is safe and expected; `deriveEntry` falls back to a conservative + * synthetic template. + */ +export function findSupportedNativeTemplate(catalog: RawCatalog | null): RawEntry | null { + return catalog?.models?.find( + m => typeof m.slug === "string" + && SUPPORTED_NATIVE_OPENAI_SLUGS.has(m.slug) + && !m.slug.includes("/") + && "base_instructions" in m + && m.opencodex_catalog_kind !== CODEX_NATIVE_ALIAS_CATALOG_KIND + && m.owned_by !== COMBO_NAMESPACE + && !(typeof m.description === "string" && m.description.startsWith("Routed via opencodex → ")), + ) ?? null; +} + /** * Native OpenAI slugs that do NOT support the Fast (priority) service tier. * Upstream may advertise service_tiers for these models, but the tier is not @@ -443,6 +471,22 @@ export function ensureStrictCatalogFields( } if (typeof entry.effective_context_window_percent !== "number") entry.effective_context_window_percent = 95; if (typeof entry.comp_hash !== "string") entry.comp_hash = "opencodex"; + // Routed rows must not carry NATIVE eligibility metadata. `deriveEntry` deep-clones a + // native template and deletes a fixed denylist, so these five survive onto rows backed + // by unrelated provider credentials — advertising ChatGPT plan eligibility for a model + // that never touches a ChatGPT account (#2813). + // + // This lives here rather than only in `normalizeRoutedCatalogEntry` because that runs on + // freshly derived rows only. Degraded-provider and foreign routed rows are preserved + // from disk and reach the merge through this function alone, so sanitizing there would + // leave already-contaminated rows contaminated forever. + if (options.isRouted === true) { + entry.supported_in_api = true; + delete entry.available_in_plans; + delete entry.minimal_client_version; + delete entry.availability_nux; + delete entry.upgrade; + } return ensureAutoCompactTokenLimit(entry); } diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index fda9724849..fb50db039e 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -41,7 +41,7 @@ import { } from "../model-entitlements"; -import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata"; import { @@ -1543,7 +1543,8 @@ function writeRetainedCatalogSync({ catalog, onDiskCatalog, ); - const template = findNativeTemplate(catalog); + // Strict selector for template inheritance; the validity gate above keeps the broad one. + const template = findSupportedNativeTemplate(catalog); try { // Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index b338aa9d3b..e33e654481 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -33,7 +33,7 @@ import { import { catalogBackupPathFor, catalogHasRoutedEntries, - findNativeTemplate, + findSupportedNativeTemplate, legacyCatalogBackupPath, parseCatalogJson, type RawCatalog, @@ -238,7 +238,8 @@ function prepareCatalog( observedAccountNativeEntries: readonly RawEntry[] = [], ): RawCatalog { const catalog = JSON.parse(JSON.stringify(source.catalog)) as RawCatalog; - const template = findNativeTemplate(catalog); + // Strict selector: an unknown bare row must never become the routed template (#2813). + const template = findSupportedNativeTemplate(catalog); const enabled = filterCatalogVisibleModels(routedModels, config); const featured = config.subagentModels ?? []; const ordered = orderForSubagents(enabled, featured); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index a2f5b52fdc..f6c96420ec 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -7,7 +7,9 @@ import { applyProviderConfigHints } from "../src/codex/catalog/provider-fetch"; import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, + ensureStrictCatalogFields, findNativeTemplate, + findSupportedNativeTemplate, } from "../src/codex/catalog/parsing"; import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; import { @@ -5863,3 +5865,111 @@ describe("#2465 model preset management routes", () => { expect(status).toBe(400); }); }); + +/** + * #2813: a Reserve-shaped row injected by the Codex client carries `base_instructions`, + * so the permissive selector would let it become the template every routed model clones + * from. Template selection has to be strict — but catalog VALIDITY must stay permissive, + * or a catalog holding only a newly launched native model gets replaced by stale data. + * Plan review rejected restricting the shared function for exactly that reason. + */ +describe("routed template selection is strict, catalog validity is not", () => { + const unknownBareRow = (): Record => ({ + slug: "gpt-reserve", + display_name: "Luna Reserve", + description: "Reserve fallback", + visibility: "list", + base_instructions: "You are Codex.", + available_in_plans: ["reserve"], + supported_in_api: false, + }); + + test("the strict selector skips an unknown row ordered before a known one", () => { + // The unknown row is FIRST, so a first-match implementation would pick it. + const catalog = { models: [unknownBareRow(), nativeTemplate()] } as never; + + expect(findSupportedNativeTemplate(catalog)?.slug).toBe("gpt-5.5"); + }); + + test("the strict selector returns null rather than inheriting from an unknown row", () => { + const catalog = { models: [unknownBareRow()] } as never; + + expect(findSupportedNativeTemplate(catalog)).toBeNull(); + }); + + // The regression guard for the rejected fix: if this ever goes red, catalog validity + // has been narrowed and a new upstream model can invalidate a healthy catalog. + test("the permissive selector still accepts an unknown row, so validity stays forward-compatible", () => { + const catalog = { models: [unknownBareRow()] } as never; + + expect(findNativeTemplate(catalog)?.slug).toBe("gpt-reserve"); + }); +}); + +/** + * Each eligibility field is asserted through `ensureStrictCatalogFields` DIRECTLY. + * Review found the build path cannot prove them: `deriveEntry` already neutralizes + * `upgrade` and `availability_nux` on its own, so a build-path assertion stays green + * after the corresponding sanitizer line is deleted. + */ +describe("routed rows never carry native eligibility metadata", () => { + const contaminated = (): Record => ({ + slug: "anthropic/claude-sonnet-5", + display_name: "claude-sonnet-5", + supported_in_api: false, + available_in_plans: ["reserve", "plus"], + minimal_client_version: "999.0.0", + availability_nux: { message: "reserve only" }, + upgrade: { message: "subscribe" }, + }); + + test("supported_in_api is forced true", () => { + const entry = ensureStrictCatalogFields(contaminated() as never, { isRouted: true }); + + expect(entry.supported_in_api).toBe(true); + }); + + test("available_in_plans is stripped", () => { + expect(ensureStrictCatalogFields(contaminated() as never, { isRouted: true })) + .not.toHaveProperty("available_in_plans"); + }); + + test("minimal_client_version is stripped", () => { + expect(ensureStrictCatalogFields(contaminated() as never, { isRouted: true })) + .not.toHaveProperty("minimal_client_version"); + }); + + test("availability_nux is stripped", () => { + expect(ensureStrictCatalogFields(contaminated() as never, { isRouted: true })) + .not.toHaveProperty("availability_nux"); + }); + + test("upgrade is stripped", () => { + expect(ensureStrictCatalogFields(contaminated() as never, { isRouted: true })) + .not.toHaveProperty("upgrade"); + }); + + // Routed-only. Native rows legitimately carry availability_nux and plan eligibility; + // sanitizing them here would corrupt the native picker. + test("a native row keeps its own eligibility metadata", () => { + const native = ensureStrictCatalogFields(contaminated() as never, {}); + + expect(native.available_in_plans).toEqual(["reserve", "plus"]); + expect(native.availability_nux).toEqual({ message: "reserve only" }); + expect(native.supported_in_api).toBe(false); + }); + + // Review blocker 3: preserved degraded/foreign rows never pass through + // normalizeRoutedCatalogEntry, so sanitizing only there would leave rows already on + // disk contaminated. Both paths end in ensureStrictCatalogFields, which is why it owns + // the sanitation. + test("the routed normalizer inherits the same guarantees", () => { + const entry = normalizeRoutedCatalogEntry(contaminated() as never); + + expect(entry.supported_in_api).toBe(true); + expect(entry).not.toHaveProperty("available_in_plans"); + expect(entry).not.toHaveProperty("minimal_client_version"); + expect(entry).not.toHaveProperty("availability_nux"); + expect(entry).not.toHaveProperty("upgrade"); + }); +});