diff --git a/devlog/_plan/260813_routed_tool_discovery_profiles/000_master_plan.md b/devlog/_plan/260813_routed_tool_discovery_profiles/000_master_plan.md index 5f27a142af..319bcb8c6f 100644 --- a/devlog/_plan/260813_routed_tool_discovery_profiles/000_master_plan.md +++ b/devlog/_plan/260813_routed_tool_discovery_profiles/000_master_plan.md @@ -12,9 +12,11 @@ Base change: PR #1596, `fix(codex): restore deferred tool discovery for non-Curs > bundle was authored without a mounted checkout; every claim below was > re-verified on 2026-08-13 against a real worktree, the upstream `codex-rs` > source and live GitHub state. Eight corrections were recorded, the most -> consequential being that an **eligible** MCP tool stays callable in **both** -> discovery modes under code mode — so for those tools `direct` is a -> comprehension/compatibility lever with a payload cost, not a reachability fix. +> consequential being that a source reading indicates an **eligible** MCP tool stays +> callable in **both** discovery modes under code mode — so for those tools `direct` +> reads as a comprehension/compatibility lever with a payload cost rather than a +> reachability fix. That conclusion is not yet backed by an executed single-variable +> differential; `020` still owes it and `029` records it as open. > "Eligible" excludes `direct_only_tool_namespaces`, `excluded_tool_namespaces`, > and anything removed by MCP/App policy filtering; see `094` for the exclusion > table and the differential test that must prove the claim. @@ -48,7 +50,9 @@ At the verified `dev` head: ### PR A — profile resolver and explicit escape hatch -Default behavior remains byte-for-byte equivalent to #1596: +Default behavior is intended to be equivalent to #1596, and is asserted per-key on both +construction paths rather than by a normalized diff against a real prior build (that +comparison is still open — see `029`): - non-Cursor: deferred - Cursor: direct diff --git a/devlog/_plan/260813_routed_tool_discovery_profiles/029_phase2_exit_gate.md b/devlog/_plan/260813_routed_tool_discovery_profiles/029_phase2_exit_gate.md index f924c19caf..1e2711fab3 100644 --- a/devlog/_plan/260813_routed_tool_discovery_profiles/029_phase2_exit_gate.md +++ b/devlog/_plan/260813_routed_tool_discovery_profiles/029_phase2_exit_gate.md @@ -1,5 +1,37 @@ # 029 - Phase 2 exit gate +> **Status 2026-08-13: Phase 2 is NOT closed.** The configuration and +> catalog-policy half shipped (see PR "feat(codex): add routed tool-discovery +> compatibility profiles"), and an earlier revision of this note claimed the +> `020` differential was the only thing left. An independent audit disproved +> that. The honest remaining list: +> +> - **`020` single-variable code-mode differential** — still owed. Until it +> runs, the claim in `004`/`094` that `direct` is a comprehension lever rather +> than a reachability fix rests on a source reading of a 2026-07-23 upstream +> clone. It needs a running Codex client, so it belongs to the live phase +> rather than the configuration PR. The docs now label that conclusion +> source-derived rather than proven. +> - **`023` zero-config comparison against a real prior build** — the suite pins +> the exact emitted key set on both construction paths, which is strong +> regression coverage, but it is not the documented normalized diff of a +> current-`dev` catalog against a patched one. +> - **`025` forcing-member diagnostic** — the combo explain surface does not yet +> name which member forced `direct`. Derivation itself is covered. +> +> Closed since that earlier revision, with ablation evidence recorded in +> `tests/codex-tool-discovery-mode.test.ts`: the `024` concurrency set (a +> latched provider `fetch` proves identical policies JOIN one flight and +> differing policies SPLIT, including model-map divergence and warm-cache +> re-resolution), the full `025` five-row matrix plus deferred-key omission and +> every alias shape (bare, slashed, native), each pinned to an exact emitted slug so +> deleting alias propagation turns them red, the `023` on-disk load/save +> round trip and downgrade preservation, and the `020` malformed-load warning, including the blank-model-key case where the +> schema drops the entire map. +> +> The retracted claim is left visible on purpose: this unit exists because a +> plan asserted more verification than it had. + Phase 2 is complete only when: - [ ] pure resolver tests pass; diff --git a/devlog/_plan/260813_routed_tool_discovery_profiles/094_landing_verification_pass.md b/devlog/_plan/260813_routed_tool_discovery_profiles/094_landing_verification_pass.md index 486a5f0317..2d826004cf 100644 --- a/devlog/_plan/260813_routed_tool_discovery_profiles/094_landing_verification_pass.md +++ b/devlog/_plan/260813_routed_tool_discovery_profiles/094_landing_verification_pass.md @@ -157,9 +157,10 @@ executed differential rather than on a source reading. The expected delta is `exec.description` content/size **plus** `tool_search` construction and the deferred-guidance text — not `exec.description` alone. -This reframes the override honestly: for an eligible tool under code mode it is a -*model-comprehension* lever (the model sees full schemas inline instead of having -to consult `ALL_TOOLS`), not a *reachability* lever. `010`'s non-goal list already +This reframes the override honestly: on the source reading above, for an eligible tool +under code mode it is a *model-comprehension* lever (the model sees full schemas inline +instead of having to consult `ALL_TOOLS`) rather than a *reachability* lever — still +pending the executed differential `020` owes. `010`'s non-goal list already says "no claim that `direct` is cheaper or preferred"; it must also say direct is not a reachability fix for eligible tools under code mode. `044`'s weak-model fallback rationale is the honest use case. diff --git a/devlog/_plan/260813_routed_tool_discovery_profiles/SHA256SUMS b/devlog/_plan/260813_routed_tool_discovery_profiles/SHA256SUMS index b4741547f7..68fc53cf61 100644 --- a/devlog/_plan/260813_routed_tool_discovery_profiles/SHA256SUMS +++ b/devlog/_plan/260813_routed_tool_discovery_profiles/SHA256SUMS @@ -1,4 +1,4 @@ -4e5dd4520224a37fddfd8fb0dc532cd62c9daa9f703d2037c2875a44f8073b58 ./000_master_plan.md +d6538ee7c9a522a99746ee860e7f6d0e4a98e4a7baeffbba78d57f00fafe0295 ./000_master_plan.md 7e853a53e344412cc4aa2fcbe64b140195e9825a1988c5e245204c19a8f22449 ./001_verified_dev_baseline.md 32d7a7ffff41bd0c036cef153f53047ce416624032dae9dff43e88ff24d653fc ./002_incident_history_1522_1529_1596.md 343ee877dde67978b7ffd6a1e6f7825751c5fb113fd51dead6554036f92fa904 ./003_current_code_map.md @@ -20,7 +20,7 @@ a8ea01e49ccb8c964c56581455e45288651d84a13f12a1ad9fcbdfad41b8641f ./021_catalog_ bd18baa3e16b54fe262435a6ee28c1ca5b389ec8b54433be1b05466559df58b1 ./023_backward_compatibility_tests.md 9542600beb52c958f6065a2c76a18c104c791c01b2035860b9730296ae45b4de ./024_catalog_cache_identity_tests.md 4fdb03fa022bf684257f95752e545bd4e9541b9258361bd9f5da5fc137b822cc ./025_combo_policy_tests.md -00c0913b15591630290c4a08429393570467cdc6799a1433b0eb16532b0630b0 ./029_phase2_exit_gate.md +ac0cd8816fcf2f032d02798f25383220803084d2c3642f16eafa0c1cc2d13e37 ./029_phase2_exit_gate.md 1f49005dbac81b2c3f3548d97f9aa32d6c8ada99f533d0fe197bcb047c553464 ./030_phase3_protocol_conformance.md 5e450a7557f7be2fad3f06c3c35288eb8654f156c402f826ef9c9b9703a52bf6 ./031_responses_lite_additional_tools.md 7bc16b20b8abe848726cb662865d542f2a8a19d6acb400a5473b0e9e3bc003e1 ./032_custom_namespace_roundtrip.md @@ -63,7 +63,7 @@ e4d78f0db15cfe7819c98c5fde166c863a74d3a751ffe8196ddb6492691dc799 ./083_incident 28cb35f98ab0827a18f7ab52f28ad8cfd3f21ef475142c38ff8d49d41b43c106 ./091_pr_stack_and_commits.md 5bf3d16bea1a3ef12d68e3a467b271d3fd6631202fa43fb5f862f4ed505a43a3 ./092_definition_of_done.md 4cd63329dc69b6af90d2bdd31e64926ebc4acc916e1b34ccbef66e44f9a10937 ./093_execution_order.md -d68e52650b5b4dd7a6047b77f7942148f9ee36414a193828847a65b239411124 ./094_landing_verification_pass.md +6923be79563be75d1933f1dde0aadd3998ba8387d3ca2f42621fbabc1ef28ef9 ./094_landing_verification_pass.md 383ed01bca1fda60f5b57fa13e0f9cc8408d1ed02a8eab802600f6afe4097c73 ./KOREAN_SUMMARY.md 61be1a43ef84b272e6d04170dbd3e0a617aac36d9d3fb0fc2c4d96da2a62f775 ./README.md 82522bf74dd85254d7162ce103886b2a483d1c274a241afb80fa488a8a7d4356 ./patches/0001-add-tool-discovery-module.patch diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index a990b52263..0181ea98d5 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -95,6 +95,8 @@ account を削除しても mapping は保持され、同じ id を再追加す | `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` エンドポイントが `response_format` を拒否する正確なモデル ID。要求モデルが項目と完全一致する場合だけフィールドを省略し、その他の `openai-chat` モデルでは structured-output 変換を維持します。 | | `parallelToolCalls?` | `boolean` |並列ツール呼び出しを切り替えます。 OpenAI Chat はデフォルトでオンになっています。非チャット アダプターは明示的な `true` でのみアドバタイズします。 | +| `routedToolDiscovery?` | `"auto" \| "deferred" \| "direct"` | ルーティング行の Codex ツール探索ポリシーです。`auto`(デフォルト)は出荷時の挙動を保ちます。非 Cursor のルーティング行は deferred、Cursor は direct で、Cursor はハードフェンスのため `deferred` を設定しても無視されます。deferred 探索と非互換だと実証された経路にのみ `direct` を使ってください。最初のリクエストに MCP 宣言がすべて載って turn-1 ペイロードが実測で 2.7 倍になり、コードモードでは Codex がどちらでも `tools`/`ALL_TOOLS` グローバルに入れ子ツールを設置するため、本来利用可能なツールの到達性が回復するわけでは**なさそうです**(上流 `codex-rs` のソースを読んだ判断であり、変数を 1 つだけ変えた実行比較ではまだ確認していません)。ホスト型ウェブ検索(`web_search_tool_type`)とは独立です。 | +| `modelRoutedToolDiscovery?` | `Record` | `routedToolDiscovery` のモデル単位の上書きです。混在ゲートウェイで非互換な 1 モデルのために兄弟モデルが不利にならないようにします。マッチングは通常のモデルキー規則(完全一致の id、`:` より前のファミリー、大文字小文字を無視)に従い、日付付きの `-YYYYMMDD` 変種はマッチしないため、失敗したモデル id を正確に指定してください。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` |正確なプレースホルダー ID、欠落している端末 ID、および(`repairInvalidIds` で)正規の `msg_`/`rs_` 接頭辞を欠く message/reasoning ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。組み込み DeepSeek は最後の 2 つをデフォルトで有効にします。 | | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key プロバイダーのみ(`authMode: "key"`)。オプトインの同一ターゲット 429 リトライ: `retryOn429` が無ければ無効で、オブジェクトがあれば `enabled: false` でない限り有効になります。429 時に待機(上流の `Retry-After` または固定間隔)してから、キー フェイルオーバーの前に同一キーで同一リクエストを再送します — メインのテキストターン回復ループ、Responses passthrough、画像/動画ブリッジ、web-search サイドカー、ターミナル継続要求をすべてカバーします。再送の対象はプリストリームの HTTP 429 応答のみで、カスタム `runTurn` トランスポートは HTTP リトライループの対象外です。`attempts` は最初の 429 以降の同一キー再送回数(合計送信数 = `attempts` + 1)で、メインの回復ループ・ターミナルガード継続・ブリッジ再試行で共有されるリクエスト単位の予算です。`attempts` を使い切っても同一キーでの再送が止まるだけで、通常のキー フェイルオーバーまたは最終エラー処理が利用可能なターゲットに応じて続きます — キー認証の passthrough ワイヤにはフェイルオーバーがないため、使い切った 429 はそのまま返ります。Codex 自体は 429 をリトライしないため、単一キーのプロバイダーでは唯一の防御です。デフォルト: `enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(1回の待機は `maxIntervalMs` で上限、その上限は 600000)、`respectRetryAfter: true`。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 7aed7c7771..7748f1c82b 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -95,6 +95,8 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 엔드포인트가 `response_format`을 거부하는 정확한 모델 ID입니다. 요청 모델이 항목과 정확히 일치할 때만 필드를 생략하며, 그 외 `openai-chat` 모델에서는 structured-output 변환을 유지합니다. | | `parallelToolCalls?` | `boolean` | 병렬 도구 호출을 켜거나 끕니다. OpenAI Chat은 기본으로 켜져 있고, 비-chat 어댑터는 명시적으로 `true`일 때만 이를 노출합니다. | +| `routedToolDiscovery?` | `"auto" \| "deferred" \| "direct"` | 라우팅된 행의 Codex 도구 탐색 정책입니다. `auto`(기본값)는 출하된 동작을 그대로 유지합니다. 비-Cursor 라우팅 행은 deferred, Cursor는 direct이며 Cursor는 하드 펜스라 `deferred`를 설정해도 무시합니다. deferred 탐색과 맞지 않는다고 입증된 경로에만 `direct`를 쓰십시오. 첫 요청에 모든 MCP 선언이 실려 turn-1 페이로드가 측정상 2.7배로 늘고, 코드 모드에서는 Codex가 어느 쪽이든 `tools`/`ALL_TOOLS` 전역에 중첩 도구를 심기 때문에 자격을 갖춘 도구의 도달성을 되살려 주지는 **않는 것으로 보입니다**(업스트림 `codex-rs` 소스를 읽어 판단했고, 변수를 하나만 바꾼 실행 대조 실험으로는 아직 확인하지 않았습니다). 호스티드 웹 검색(`web_search_tool_type`)과는 무관합니다. | +| `modelRoutedToolDiscovery?` | `Record` | `routedToolDiscovery`의 모델별 재정의입니다. 혼합 게이트웨이에서 호환되지 않는 한 모델 때문에 형제 모델까지 손해 보지 않게 합니다. 매칭은 기존 모델 키 규칙(정확한 id, `:` 앞 계열, 대소문자 무시)을 따르며 날짜가 붙은 `-YYYYMMDD` 변형은 매칭하지 않으므로 실패한 모델 id를 정확히 적으십시오. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id, 누락된 종료 id, 그리고(`repairInvalidIds`) 정규 `msg_`/`rs_` 접두사가 없는 message/reasoning id를 복구합니다. function-call id는 다시 쓰지 않습니다. 내장 DeepSeek은 마지막 두 가지를 기본으로 켭니다. | | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key 프로바이더 전용(`authMode: "key"`). 동일 대상 429 재시도: `retryOn429`가 없으면 기능이 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 429 시 대기(업스트림 `Retry-After` 또는 고정 간격) 후 키 장애 조치 전에 동일 키로 동일 요청을 재전송합니다 — 일반 텍스트 턴 복구 루프, Responses passthrough, 이미지/비디오 브리지, web-search 사이드카, 터미널 연속 요청을 모두 포함합니다. 재전송 대상은 프리스트림 HTTP 429 응답뿐이며, 커스텀 `runTurn` 전송은 HTTP 재시도 루프에서 제외됩니다. `attempts`는 첫 429 이후의 동일 키 재전송 횟수(총 전송 = `attempts` + 1)이며, 메인 복구 루프·터미널 가드 연속 요청·브리지 재시도가 공유하는 요청 단위 예산입니다. `attempts`를 모두 소진해도 동일 키 재전송만 중단되며, 이후에는 일반 키 장애 조치 또는 최종 오류 처리가 사용 가능한 대상에 따라 진행됩니다 — 키 인증 passthrough 와이어에는 장애 조치가 없으므로 소진된 429가 그대로 반환됩니다. Codex 자체는 429를 재시도하지 않으므로 단일 키 프로바이더의 유일한 방어선입니다. 기본값: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000`(단일 대기는 `maxIntervalMs`로 상한, 그 자체는 600000으로 상한), `respectRetryAfter: true`. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 62203e285d..faf552cada 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -105,6 +105,8 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. | | `noStructuredOutputModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects `response_format`. Only an exact requested-model match omits the field; structured-output translation stays enabled for every other `openai-chat` model. | | `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. | +| `routedToolDiscovery?` | `"auto" \| "deferred" \| "direct"` | Routed-row Codex tool-discovery policy. `auto` (default) keeps the shipped behavior: deferred for non-Cursor routed rows, direct for Cursor, which is hard-fenced and ignores a configured `deferred`. Set `direct` only for a route proven incompatible with deferred discovery — it embeds every MCP declaration in the first request (a measured 2.7x turn-1 payload cost) and does **not** appear to make an otherwise eligible tool reachable under code mode, where Codex installs nested tools on the `tools`/`ALL_TOOLS` globals either way (read from the upstream `codex-rs` source, not yet confirmed by an executed single-variable differential). Independent of hosted web search (`web_search_tool_type`). | +| `modelRoutedToolDiscovery?` | `Record` | Per-model override of `routedToolDiscovery`, so one incompatible model on a mixed gateway does not penalize its siblings. Matching follows the usual model-key rules (exact id, family before `:`, case-insensitive); dated `-YYYYMMDD` variants are not matched, so name the exact failing model id. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Disabled-by-default downstream SSE repair for exact placeholder ids, missing terminal ids, and (with `repairInvalidIds`) message/reasoning ids missing the canonical `msg_`/`rs_` prefix. Function-call ids are never rewritten. Built-in DeepSeek enables the last two by default. | | `responsesSnapshotRepair?` | `boolean` | Disabled-by-default client-facing repair for sparse Responses lifecycle snapshots in SSE and JSON. Fills missing canonical status, output, and tool metadata while raw inspection and persistence remain unchanged. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key providers only (`authMode: "key"`). Opt-in same-target 429 retry: when `retryOn429` is absent the feature is off; object presence enables it unless `enabled: false`. On 429 the proxy waits (upstream `Retry-After` or the fixed interval) and replays the identical request on the same key before any key failover — across the main text-turn recovery loop, the Responses passthrough wire, the image/video bridge, the web-search sidecar, and terminal continuations. Only pre-stream HTTP 429 responses are eligible for replay; custom `runTurn` transports are outside the HTTP retry loop. `attempts` counts same-key replays after the first 429 (total sends = `attempts` + 1) and is one request-wide budget shared by the main recovery loop, the terminal-guard continuation, and bridge retries. Exhausting `attempts` only stops further same-key replays: normal key failover or final-error handling then applies per the available targets — on the key-auth passthrough wire there is no failover, so the exhausted 429 surfaces as-is. Codex itself never retries 429, so this is the only defense for single-key providers. Defaults: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (any single wait is capped at `maxIntervalMs`, itself capped at 600000), `respectRetryAfter: true`. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index b1bb26c78c..1f90765fca 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -108,6 +108,8 @@ cross-route credential fallback не существует. Строки API GPT- | `noPenaltyModels?` | `string[]` | Модели, отвергающие penalty presence/frequency. | | `noStructuredOutputModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format`. Поле опускается только при точном совпадении запрошенной модели; для остальных моделей `openai-chat` преобразование structured output остаётся включённым. | | `parallelToolCalls?` | `boolean` | Переключатель parallel tool call'ов. Для OpenAI Chat по умолчанию включено; не-chat adapter'ы рекламируют это только при явном `true`. | +| `routedToolDiscovery?` | `"auto" \| "deferred" \| "direct"` | Политика обнаружения Codex-инструментов для routed-строк. `auto` (по умолчанию) сохраняет поставляемое поведение: не-Cursor routed-строки — deferred, Cursor — direct, причём Cursor жёстко зафиксирован и игнорирует заданный `deferred`. Указывайте `direct` только для маршрута, несовместимость которого с deferred-обнаружением доказана: он вкладывает все объявления MCP в первый запрос (измеренная плата — 2.7x к turn-1 payload) и, по-видимому, **не** возвращает доступность иначе пригодного инструмента в code mode (вывод сделан по исходникам upstream `codex-rs` и пока не подтверждён исполненным экспериментом с одной переменной), где Codex в любом случае помещает вложенные инструменты в глобальные `tools`/`ALL_TOOLS`. Независимо от hosted web search (`web_search_tool_type`). | +| `modelRoutedToolDiscovery?` | `Record` | Переопределение `routedToolDiscovery` для конкретной модели, чтобы одна несовместимая модель на смешанном шлюзе не наказывала соседние. Сопоставление следует обычным правилам ключей моделей (точный id, семейство до `:`, без учёта регистра); варианты с датой `-YYYYMMDD` не сопоставляются, поэтому указывайте точный id проблемной модели. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id, отсутствующих terminal-id и (с `repairInvalidIds`) message/reasoning id без канонического префикса `msg_`/`rs_`. Function-call id никогда не переписываются. Встроенный DeepSeek включает последние два по умолчанию. | | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Только для провайдеров с API-ключом (`authMode: "key"`). Опциональный повтор при 429 на том же таргете: если `retryOn429` отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. При 429: ожидание (`Retry-After` апстрима или фиксированный интервал) и повтор идентичного запроса на том же ключе до любого фейловера ключей — покрывает основной цикл восстановления текстовых ходов, passthrough-канал Responses, мост изображений/видео, sidecar web-search и терминальные продолжения. Повтор допустим только для HTTP 429, полученных до начала потока; пользовательские транспорты `runTurn` не входят в цикл HTTP-повторов. `attempts` — это число повторов на том же ключе после первого 429 (всего отправок = `attempts` + 1) и единый бюджет на запрос, общий для основного цикла восстановления, терминального продолжения и повторов моста. Исчерпание `attempts` лишь останавливает дальнейшие повторы на том же ключе; далее применяется обычный фейловер ключей или финальная обработка ошибки в зависимости от доступных таргетов — на passthrough-канале с ключевой аутентификацией фейловера нет, поэтому исчерпанный 429 возвращается как есть. Codex сам никогда не повторяет 429, поэтому это единственная защита для провайдеров с одним ключом. По умолчанию: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (любое ожидание ограничено `maxIntervalMs`, который сам ограничен 600000), `respectRetryAfter: true`. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 1d2a8c10c9..6a1fe0f657 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -95,6 +95,8 @@ selector,而不是分配一个新名称。 | `noPenaltyModels?` | `string[]` | 会拒绝 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 端点拒绝 `response_format` 的精确模型 ID。仅当请求模型与条目完全匹配时才省略该字段;其他 `openai-chat` 模型仍启用 structured-output 转换。 | | `parallelToolCalls?` | `boolean` | 切换并行工具调用。OpenAI Chat 默认开启;非 chat 适配器只有显式 `true` 时才会声明支持。 | +| `routedToolDiscovery?` | `"auto" \| "deferred" \| "direct"` | routed 行的 Codex 工具发现策略。`auto`(默认)保持出厂行为:非 Cursor 的 routed 行为 deferred,Cursor 为 direct,且 Cursor 是硬围栏,即使配置 `deferred` 也会被忽略。只有在已证明与 deferred 发现不兼容的路由上才设置 `direct`:它会把全部 MCP 声明塞进首个请求(实测 turn-1 负载增加 2.7 倍),而且在 code mode 下似乎并**不会**让本来就可用的工具重新可达(该结论来自阅读上游 `codex-rs` 源码,尚未通过只改一个变量的实际对照实验验证)——Codex 无论哪种模式都会把嵌套工具装到 `tools`/`ALL_TOOLS` 全局上。与托管网页搜索(`web_search_tool_type`)互不相关。 | +| `modelRoutedToolDiscovery?` | `Record` | 按模型覆盖 `routedToolDiscovery`,使混合网关上某个不兼容的模型不会牵连同组其他模型。匹配遵循通常的模型键规则(精确 id、`:` 之前的系列名、忽略大小写);带日期的 `-YYYYMMDD` 变体不参与匹配,因此请写出确切失败的模型 id。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id、缺失的终止 id,以及(`repairInvalidIds`)缺少规范 `msg_`/`rs_` 前缀的 message/reasoning id。function-call id 永远不会被重写。内置 DeepSeek 默认启用后两项。 | | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | 仅限 API-key 提供商(`authMode: "key"`)。可选的同目标 429 重试:未配置 `retryOn429` 时功能关闭;对象存在即启用,除非 `enabled: false`。收到 429 时等待(上游 `Retry-After` 或固定间隔)后在相同 key 上重放完全相同请求,再进入任何 key 故障转移——覆盖主文本恢复循环、Responses passthrough、图像/视频桥、web-search 侧车与终结续接。重放仅适用于流开始前的 HTTP 429 响应;自定义 `runTurn` 传输不在 HTTP 重试循环范围内。`attempts` 是首个 429 之后的同 key 重放次数(总发送次数 = `attempts` + 1),是主恢复循环、终结守卫续接与桥接重试共享的按请求统一预算;`attempts` 耗尽只会停止进一步的同 key 重放:随后按可用目标进行正常的 key 故障转移或最终错误处理——key 认证的 passthrough 线路上没有故障转移,因此耗尽的 429 会原样透出。Codex 自身从不重试 429,因此这是单 key 提供商唯一的防线。默认值:`enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(单次等待以 `maxIntervalMs` 为上限,其本身上限 600000)、`respectRetryAfter: true`。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 2f729dc355..2f931f4b66 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -74,6 +74,8 @@ description: 供應商項目、認證、端點、模型目錄、配額、context | `noPenaltyModels?` | `string[]` | 拒絕 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `response_format` 的精確模型 ID。僅精確符合的請求模型會省略該欄位;structured-output 轉譯對其他每個 `openai-chat` 模型保持啟用。 | | `parallelToolCalls?` | `boolean` | 切換平行工具呼叫。OpenAI Chat 預設開啟;非 chat adapter 僅在明確 `true` 時廣告。 | +| `routedToolDiscovery?` | `"auto" \| "deferred" \| "direct"` | routed 列的 Codex 工具探索政策。`auto`(預設)維持出廠行為:非 Cursor 的 routed 列為 deferred,Cursor 為 direct,且 Cursor 屬硬圍籬,即使設定 `deferred` 也會被忽略。只在已證實與 deferred 探索不相容的路由上設定 `direct`:它會把所有 MCP 宣告塞進第一個請求(實測 turn-1 負載增加 2.7 倍),而且在 code mode 下似乎並**不會**讓原本可用的工具重新可達(此結論來自閱讀上游 `codex-rs` 原始碼,尚未經由只改一個變數的實際對照實驗驗證)——Codex 無論哪種模式都會把巢狀工具裝到 `tools`/`ALL_TOOLS` 全域上。與代管網頁搜尋(`web_search_tool_type`)彼此獨立。 | +| `modelRoutedToolDiscovery?` | `Record` | 依模型覆寫 `routedToolDiscovery`,讓混合閘道上某個不相容的模型不會拖累同組其他模型。比對遵循一般模型鍵規則(精確 id、`:` 之前的系列、忽略大小寫);帶日期的 `-YYYYMMDD` 變體不會比對,因此請寫出確切失敗的模型 id。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 預設停用的下游 SSE 修復,用於精確佔位 id 與缺失的終端 id。Function-call id 永不被重寫。 | | `autoToolChoiceOnlyModels?` | `string[]` | 其 `tool_choice` 僅接受 `auto` 或 `none` 的模型;強制選擇被降級。 | | `preserveReasoningContentModels?` | `string[]` | 需要在 chat 歷史中保留先前 assistant `reasoning_content` 的模型。 | diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index 1031f073a1..4b8be37d6a 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -34,6 +34,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import { catalogModelSlug } from "./parsing"; import type { CatalogModel } from "./parsing"; +import { deriveComboToolDiscoveryMode } from "./tool-discovery"; export const openAiApiCollisionWarnings = new Set(); @@ -173,6 +174,13 @@ export function deriveComboCatalogModel( ...(members.every(member => member.parallelToolCalls === true) ? { parallelToolCalls: true } : {}), + // One public combo row cannot change capabilities after a target is selected, so a + // single direct-only member forces the whole combo direct rather than stranding it. + // Emitted only when it departs from the default, matching parallelToolCalls and the + // summary flag: an all-deferred combo stays byte-identical to the pre-override shape. + ...(deriveComboToolDiscoveryMode(members.map(member => member.toolDiscoveryMode)) === "direct" + ? { toolDiscoveryMode: "direct" as const } + : {}), ...(members.some(member => member.supportsReasoningSummaries === false) ? { supportsReasoningSummaries: false } : {}), }; } @@ -201,6 +209,7 @@ export function comboCatalogWarningSignature( inputModalities: [...new Set(member?.inputModalities ?? [])].sort(), reasoningEfforts: [...new Set(member?.reasoningEfforts ?? [])].sort(), parallelToolCalls: member?.parallelToolCalls === true, + toolDiscoveryMode: member?.toolDiscoveryMode ?? "deferred", supportsReasoningSummaries: member?.supportsReasoningSummaries !== false, }; }).sort((a, b) => a.key.localeCompare(b.key))); diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 442d1378dc..00c2c8768f 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -34,6 +34,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES, nativeMultiAgentVersion } from "./metadata"; import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; +import { isCursorRoute, type ResolvedRoutedToolDiscoveryMode } from "./tool-discovery"; export function legacyCatalogBackupPath(): string { return join(getConfigDir(), "catalog-backup.json"); @@ -116,6 +117,19 @@ export interface CatalogModel { inputModalities?: string[]; /** Provider opted into parallel tool calls (OcxProviderConfig.parallelToolCalls). */ parallelToolCalls?: boolean; + /** + * Resolved routed tool-discovery policy (OcxProviderConfig.routedToolDiscovery and its + * per-model map). Only ever `deferred` or `direct` — `auto` is resolved before it + * reaches a catalog row. Carried here so `normalizeRoutedCatalogEntry` never reaches + * back into global config, matching how parallelToolCalls and the modality hints flow. + */ + toolDiscoveryMode?: ResolvedRoutedToolDiscoveryMode; + /** + * Cursor fence resolved where the provider name AND adapter were both known + * (applyProviderConfigHints). Serialization cannot re-derive this: it sees only the + * provider id, so a Cursor-adapter gateway under a custom provider name would escape. + */ + cursorRoute?: boolean; /** Whether Codex may send Responses text.verbosity for this routed model. */ supportsVerbosity?: boolean; supportsReasoningSummaries?: boolean; @@ -378,7 +392,25 @@ export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode, v return entries; } -export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = false): RawEntry { +/** + * Route-scoped policy inputs. An options object rather than a third positional argument: + * the Cursor fence needs the provider identity as well as the mode, and the two existing + * positions stay put for the public callers (src/codex/catalog.ts re-export, tests). + */ +export interface RoutedCatalogEntryOptions { + /** Resolved discovery mode for this row. Defaults to `deferred` (the #1596 default). */ + toolDiscoveryMode?: ResolvedRoutedToolDiscoveryMode; + /** Canonical provider id from the CatalogModel, when the caller has one. */ + providerId?: string; + /** Fence resolved upstream from provider name + adapter (CatalogModel.cursorRoute). */ + cursorRoute?: boolean; +} + +export function normalizeRoutedCatalogEntry( + entry: RawEntry, + parallelToolCalls = false, + options: RoutedCatalogEntryOptions = {}, +): RawEntry { delete entry.model_messages; delete entry.tool_mode; applyRoutedCodexToolMode(entry); @@ -392,7 +424,12 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = // Routed rows cloned from native templates must not inherit OpenAI-only summary delivery. // Per-model routed opt-ins can be added once provider metadata exposes this capability. delete entry.supports_reasoning_summaries; - const isCursorEntry = typeof entry.slug === "string" && entry.slug.startsWith("cursor/"); + // One shared fence for both construction paths, unioning every available signal so that + // reconciling the two historical checks can never UNFENCE a row (see isCursorRoute). + const isCursorEntry = isCursorRoute(entry.slug, { + providerId: options.providerId, + cursorRoute: options.cursorRoute, + }); // `supports_search_tool` selects Codex's deferred tool-discovery surface; it is not the hosted // web-search capability. Routed rows also carry tool_mode=code_mode_only (below), and under code // mode DEFERRED MCP tools remain callable through exec's `tools` global / ALL_TOOLS without any @@ -408,7 +445,12 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = } else { entry.web_search_tool_type = "text_and_image"; } - entry.supports_search_tool = !isCursorEntry; + // Cursor is hard-fenced to direct; every other routed row follows its resolved mode, + // which defaults to deferred so an unconfigured tree is byte-identical to #1596. + const effectiveMode: ResolvedRoutedToolDiscoveryMode = isCursorEntry + ? "direct" + : options.toolDiscoveryMode ?? "deferred"; + entry.supports_search_tool = effectiveMode === "deferred"; // Cursor's transport already serializes overlapping tool calls into atomic Responses tool events. // Advertising parallel calls lets Codex send the same native capability bit it sends for OpenAI. // Opt-in providers (OcxProviderConfig.parallelToolCalls, e.g. xAI) advertise it too: the diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index a723fe0878..d4b3cdf3cd 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -69,6 +69,7 @@ import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } fr import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing"; import type { CatalogModel } from "./parsing"; +import { isCursorProviderIdentity, resolveConfiguredRoutedToolDiscoveryMode } from "./tool-discovery"; import { disabledNativeSlugs, hasComboTargets, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata"; import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation"; import type { ComboCatalogOmission } from "./aggregation"; @@ -554,6 +555,8 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco rsDel: prov.modelReasoningSummaryDelivery ?? null, noVis: [...(prov.noVisionModels ?? [])].sort(), ptc: prov.parallelToolCalls ?? null, + rtd: prov.routedToolDiscovery ?? null, + mrtd: prov.modelRoutedToolDiscovery ?? null, gMode: prov.googleMode ?? null, }; } @@ -608,7 +611,6 @@ function configuredReasoningSummarySupport(prov: OcxProviderConfig | undefined, } export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel { - void name; const configuredCap = configuredContextWindow(prov, model.id); const configuredMaxInput = configuredMaxInputTokens(prov, model.id); let inputModalities = configuredInputModalities(prov, model.id); @@ -649,6 +651,12 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, ...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false) ? { parallelToolCalls: true } : {}), + // Resolve routed discovery here so configured, live-discovered, cached and combo-derived + // rows all carry the same value and `auto` never reaches serialization. + toolDiscoveryMode: resolveConfiguredRoutedToolDiscoveryMode(name, prov, model.id).mode, + // Resolve the fence here too: this is the only place that sees BOTH the provider name and + // its adapter, so a Cursor-adapter gateway under a custom name cannot escape at emit time. + ...(isCursorProviderIdentity(name, prov.adapter) ? { cursorRoute: true } : {}), }; const capped = applyProviderContextCap(hinted.contextWindow, providerCap); if (providerCap !== undefined && capped !== hinted.contextWindow) { @@ -1724,6 +1732,15 @@ async function gatherRoutedModelsUncached( ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}), ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), + // Custom rows never pass through applyProviderConfigHints, so resolve the routed + // discovery policy here too — otherwise a provider-level `direct` silently degrades to + // deferred for exactly the models an operator hand-declared. + ...(rawProvider + ? { + toolDiscoveryMode: resolveConfiguredRoutedToolDiscoveryMode(cm.provider, rawProvider, cm.modelId).mode, + ...(isCursorProviderIdentity(cm.provider, rawProvider.adapter) ? { cursorRoute: true } : {}), + } + : {}), }; // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls, @@ -1743,6 +1760,8 @@ async function gatherRoutedModelsUncached( ...(base.supportsVerbosity === undefined && replaced.supportsVerbosity !== undefined ? { supportsVerbosity: replaced.supportsVerbosity } : {}), ...(base.supportsReasoningSummaries === undefined && replaced.supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries: replaced.supportsReasoningSummaries } : {}), ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}), + ...(base.toolDiscoveryMode === undefined && replaced.toolDiscoveryMode !== undefined ? { toolDiscoveryMode: replaced.toolDiscoveryMode } : {}), + ...(base.cursorRoute === undefined && replaced.cursorRoute !== undefined ? { cursorRoute: replaced.cursorRoute } : {}), } : base; // Vision-sidecar coverage ONLY: if the custom model is in the enriched provider's // noVisionModels, advertise image input so the Codex app lets images reach the sidecar @@ -1822,6 +1841,9 @@ function augmentRoutedModelsWithCapturedOpenAiApiRows( ...(maxInputTokens ? { maxInputTokens } : {}), ...(policy.modelInputModalities?.[id] ? { inputModalities: [...policy.modelInputModalities[id]!] } : {}), ...(policy.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...policy.modelReasoningEfforts[id]!] } : {}), + // Rebuilt from the trusted snapshot rather than through applyProviderConfigHints, so the + // routed discovery policy has to be resolved here as well. + toolDiscoveryMode: resolveConfiguredRoutedToolDiscoveryMode(OPENAI_API_PROVIDER_ID, configured, id).mode, }; }); diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 5afa06c106..8fbb73b963 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -34,6 +34,7 @@ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../accou 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, readNativeBaseline } from "./parsing"; +import { isCursorRoute } from "./tool-discovery"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; import { @@ -284,7 +285,11 @@ export function deriveEntry( e.base_instructions = identifyRoutedModel(e.base_instructions, modelName); } applyReasoningLevels(e, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact); - normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true); + normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true, { + toolDiscoveryMode: model?.toolDiscoveryMode, + providerId: model?.provider, + cursorRoute: model?.cursorRoute, + }); if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap); applyCatalogModelMetadata(e, model); if (model?.catalogKind) e.opencodex_catalog_kind = model.catalogKind; @@ -314,7 +319,17 @@ export function deriveEntry( // web-search metadata (runTurn transport bypasses the sidecar). Non-Cursor routed fallbacks // advertise deferred discovery — code mode keeps deferred MCP callable (devlog // 260813_tool_catalog_deferral/010+020); search=false costs a measured 2.7x turn-1 payload. - const isCursorFallback = isRouted && model?.provider === "cursor"; + // Same fence helper as the template path, so a row cannot be classified one way here and + // another way there. The union includes the slug, which this path previously ignored. + const isCursorFallback = isRouted && isCursorRoute(slug, { + providerId: model?.provider, + cursorRoute: model?.cursorRoute, + }); + // Cursor is hard-fenced to direct; every other routed fallback follows its resolved mode, + // defaulting to deferred so an unconfigured tree stays byte-identical to #1596. + const fallbackDiscoveryMode = isCursorFallback + ? "direct" + : model?.toolDiscoveryMode ?? "deferred"; const entry: RawEntry = { slug, display_name: routedDisplayName(slug), description: desc, shell_type: "shell_command", visibility: "list", supported_in_api: true, @@ -322,7 +337,10 @@ export function deriveEntry( ...(isRouted ? isCursorFallback ? { supports_search_tool: false } - : { web_search_tool_type: "text_and_image", supports_search_tool: true } + : { + web_search_tool_type: "text_and_image", + supports_search_tool: fallbackDiscoveryMode === "deferred", + } : {}), }; if (isRouted) { diff --git a/src/codex/catalog/tool-discovery.ts b/src/codex/catalog/tool-discovery.ts new file mode 100644 index 0000000000..ca34180ae2 --- /dev/null +++ b/src/codex/catalog/tool-discovery.ts @@ -0,0 +1,167 @@ +import type { OcxProviderConfig, OcxRoutedToolDiscoveryMode } from "../../types"; +import { modelRecordValue } from "../../reasoning-effort"; + +/** + * Route-scoped Codex tool-discovery policy. + * + * `supports_search_tool` selects Codex's DEFERRED tool-discovery surface. It is not the + * hosted web-search capability (`web_search_tool_type`), and under `tool_mode = + * code_mode_only` it appears not to decide whether an eligible MCP tool is reachable: upstream + * installs nested tool specs on the code-mode `tools`/`ALL_TOOLS` globals in BOTH + * exposures (codex-rs `spec_plan.rs` build_code_mode_executors → `code-mode/src/runtime/ + * globals.rs`). What changes is where the schemas live — direct exposure embeds every MCP + * declaration in `exec.description`, the measured 96,699 → 258,929 char turn-1 regression + * behind PR #1596 — plus `tool_search` construction and the deferred-guidance text. + * + * So `direct` reads as a compatibility/comprehension lever with a payload cost rather than a + * reachability fix. That conclusion comes from the upstream source above, not from an executed + * single-variable differential — devlog `020` still owes that run, so treat it as well-grounded + * but unproven. It also cannot repair a tool removed by `direct_only_tool_namespaces`, + * `excluded_tool_namespaces`, or MCP/App policy filtering, all of which are independent of + * this flag. Full analysis and citations: + * devlog/_plan/260813_routed_tool_discovery_profiles/094_landing_verification_pass.md. + */ +export type ResolvedRoutedToolDiscoveryMode = "deferred" | "direct"; + +export const ROUTED_TOOL_DISCOVERY_MODES: readonly OcxRoutedToolDiscoveryMode[] = [ + "auto", + "deferred", + "direct", +] as const; + +export function isRoutedToolDiscoveryMode(value: unknown): value is OcxRoutedToolDiscoveryMode { + return value === "auto" || value === "deferred" || value === "direct"; +} + +/** Why a row resolved the way it did. Diagnostics only — never serialized into the catalog. */ +export type RoutedToolDiscoverySource = + | "cursor-hard-fence" + | "model-override" + | "provider-override" + | "default"; + +export interface ResolvedRoutedToolDiscovery { + readonly mode: ResolvedRoutedToolDiscoveryMode; + readonly source: RoutedToolDiscoverySource; + /** The configured value before resolution; `auto` never reaches serialization. */ + readonly configured: OcxRoutedToolDiscoveryMode; + readonly reason: string; + readonly warning?: string; +} + +export const CURSOR_PROVIDER_ID = "cursor"; +const CURSOR_SLUG_PREFIX = `${CURSOR_PROVIDER_ID}/`; + +/** + * Cursor identity as the RESOLVER sees it: provider name or adapter. A gateway can be named + * anything while speaking Cursor's custom runTurn transport, and it is the transport — not + * the label — that has no deferred path. + */ +export function isCursorProviderIdentity(providerName: string | undefined, adapter: string | undefined): boolean { + return providerName === CURSOR_PROVIDER_ID || adapter === CURSOR_PROVIDER_ID; +} + +export interface CursorRouteSignals { + /** Canonical provider id from the CatalogModel, when the caller has one. */ + providerId?: string; + /** Fence resolved upstream where the provider name AND adapter were both known. */ + cursorRoute?: boolean; +} + +/** + * The single Cursor fence, shared by the template and template-less catalog paths. + * + * This is a UNION of every available signal, deliberately, because the two construction + * paths historically disagreed: `parsing.ts` tested `slug.startsWith("cursor/")` while + * `sync.ts` tested `model?.provider === "cursor"`, so a `cursor/`-aliased combo whose + * canonical provider is `combo` was classified differently depending on whether a template + * happened to exist (unresolved P2 on PR #1596). Any single-signal reconciliation would + * silently UNFENCE rows that one path used to fence, and an under-fenced Cursor row + * advertises a deferred surface its transport cannot serve. + * + * The union is therefore the compatibility-preserving direction: it keeps every result the + * template path already produced. It is not free — combo aliases may legally carry one + * slash and only `combo/` is reserved, so a combo aliased `cursor/` is routed by the + * combo subsystem yet still gets fenced, losing hosted-search metadata and taking Cursor's + * parallel-tool advertisement, not merely paying payload. That row's fenced shape is + * exactly what shipped before this change, so this preserves behavior rather than + * regressing it. The real fix is to reserve the `cursor/` alias prefix (or move to an + * authoritative route identity) as a deliberate, documented compatibility break. + * + * `cursorRoute` carries the resolver's verdict (provider name or adapter), which is the only + * signal that catches a Cursor-adapter gateway under a custom provider name. + */ +export function isCursorRoute(slug: unknown, signals: CursorRouteSignals = {}): boolean { + if (signals.cursorRoute === true) return true; + if (signals.providerId === CURSOR_PROVIDER_ID) return true; + return typeof slug === "string" && slug.startsWith(CURSOR_SLUG_PREFIX); +} + +/** + * Resolve one routed provider/model to a catalog-facing mode. + * + * Precedence: Cursor hard fence > exact model override > provider override > auto default. + * `auto` resolves to `deferred` for non-Cursor rows, preserving PR #1596 byte-for-byte. + * + * Model keys follow `modelRecordValue()` semantics — exact id, the family before a `:`, or + * a case-insensitive full-id match. Dated `-YYYYMMDD` variants are NOT matched; name the + * exact model id that failed. + */ +export function resolveConfiguredRoutedToolDiscoveryMode( + providerName: string, + provider: Pick, + modelId: string, +): ResolvedRoutedToolDiscovery { + const modelConfigured = modelRecordValue(provider.modelRoutedToolDiscovery, modelId); + const providerConfigured = provider.routedToolDiscovery; + const configured: OcxRoutedToolDiscoveryMode = modelConfigured ?? providerConfigured ?? "auto"; + const source: RoutedToolDiscoverySource = modelConfigured !== undefined + ? "model-override" + : providerConfigured !== undefined + ? "provider-override" + : "default"; + + // Cursor's runTurn transport bypasses the web-search sidecar and has no proven deferred + // path, so the fence outranks any configuration rather than silently accepting it. + if (isCursorProviderIdentity(providerName, provider.adapter)) { + return { + mode: "direct", + source: "cursor-hard-fence", + configured, + reason: "Cursor's runTurn transport has no verified deferred discovery path.", + ...(configured === "deferred" + ? { warning: "Configured deferred discovery was ignored for Cursor." } + : {}), + }; + } + + if (configured === "direct") { + return { + mode: "direct", + source, + configured, + reason: "A route-scoped compatibility override selected direct discovery.", + warning: "Direct discovery embeds full MCP declarations in the first request.", + }; + } + + return { + mode: "deferred", + source, + configured, + reason: configured === "deferred" + ? "The route explicitly selected Codex deferred discovery." + : "Non-Cursor auto mode preserves the Code Mode default.", + }; +} + +/** + * One public combo row cannot change capabilities after a target is selected, so a single + * direct-only member forces the whole combo direct. Advertising deferred discovery when a + * possible target was explicitly marked incompatible would strand that target. + */ +export function deriveComboToolDiscoveryMode( + memberModes: readonly (ResolvedRoutedToolDiscoveryMode | undefined)[], +): ResolvedRoutedToolDiscoveryMode { + return memberModes.some(mode => mode === "direct") ? "direct" : "deferred"; +} diff --git a/src/config.ts b/src/config.ts index ef3580e1a2..ca4f918238 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,6 +28,9 @@ import { MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET, } from "./codex/account-namespace-match"; import { isCodexAccountPriorityKey } from "./codex/account-priority"; +// Safe import direction: tool-discovery.ts depends only on ./types and ./reasoning-effort, +// never on this module, so the policy predicate is shared rather than duplicated here. +import { isRoutedToolDiscoveryMode } from "./codex/catalog/tool-discovery"; import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "./codex/upstream-host-health"; import { adoptCustomModelCatalogMigration, @@ -613,6 +616,9 @@ const retryOn429PolicySchema = z.object({ respectRetryAfter: z.boolean().optional(), }).strict(); +/** Routed-row Codex tool-discovery policy; `auto` keeps the shipped per-surface default. */ +const routedToolDiscoveryModeSchema = z.enum(["auto", "deferred", "direct"]); + /** * Zod schema for one provider entry: known fields are validated strictly while unknown * fields pass through (preserved for runtime extensions). @@ -642,6 +648,16 @@ const providerConfigSchema = z.object({ repairInvalidIds: z.boolean().optional(), }).strict().optional(), responsesSnapshotRepair: z.boolean().optional(), + // Hand-edited configs degrade instead of losing the whole provider (credentials, ports). + // The write boundary rejects the same values outright — see routedToolDiscoveryError(). + routedToolDiscovery: routedToolDiscoveryModeSchema.optional().catch(undefined), + // `.min(1)` alone would accept a whitespace-only key, which the write boundary rejects as + // blank — the load path must use the SAME nonblank rule or the degrade-warning lies about + // what happened to the map. + modelRoutedToolDiscovery: z.record( + z.string().refine(key => key.trim() !== ""), + routedToolDiscoveryModeSchema, + ).optional().catch(undefined), }).passthrough(); const RESERVED_PROVIDER_NAMES = new Set([ @@ -1597,6 +1613,53 @@ function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig): void * provider/key behind a default config. Invalid fields are dropped with a warning; the management * write boundary still rejects invalid policies explicitly. */ +/** + * Warn when a hand-edited routed tool-discovery policy is dropped on load. + * + * The schema degrades these fields with `.catch(undefined)` so a typo cannot cost a user + * their providers or credentials, but silent degradation is its own failure: an operator + * whose emergency escape hatch was discarded by a typo would never learn it is inactive + * (devlog 014 — the same observability failure as #1529, at smaller scope). The write + * boundary still rejects these values outright; this only covers the tolerant load path. + */ +function warnDegradedRoutedToolDiscoveryForLoad(parsed: unknown): void { + if (!parsed || typeof parsed !== "object") return; + const providers = (parsed as Record).providers; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) return; + for (const [name, provider] of Object.entries(providers as Record)) { + if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue; + // Runs BEFORE schema validation, so the provider name is untrusted: redact + // secret-shaped names and JSON-escape control characters before warning. + const safeProviderName = JSON.stringify(redactSecretString(name)); + const p = provider as Record; + const mode = p.routedToolDiscovery; + // Log only the received type, never the value: provider config can hold secrets. + if (mode !== undefined && !isRoutedToolDiscoveryMode(mode)) { + console.warn(`⚠️ config.json providers.${safeProviderName}.routedToolDiscovery (${typeof mode}) is invalid — ignoring the policy`); + } + const map = p.modelRoutedToolDiscovery; + if (map === undefined) continue; + if (!map || typeof map !== "object" || Array.isArray(map)) { + console.warn(`⚠️ config.json providers.${safeProviderName}.modelRoutedToolDiscovery (${typeof map}) is invalid — ignoring the map`); + continue; + } + for (const [modelId, value] of Object.entries(map as Record)) { + // A blank key fails the schema's trimmed-nonblank refinement and takes the WHOLE map + // with it, so it needs its own warning: reporting only bad values would drop the map + // silently. `.min(1)` would NOT be enough here — it accepts a whitespace-only key, + // which is exactly the mismatch that once made this warning lie. + if (modelId.trim() === "") { + console.warn(`⚠️ config.json providers.${safeProviderName}.modelRoutedToolDiscovery has a blank model key — ignoring the whole map`); + break; + } + if (isRoutedToolDiscoveryMode(value)) continue; + const safeModelId = JSON.stringify(redactSecretString(modelId)); + console.warn(`⚠️ config.json providers.${safeProviderName}.modelRoutedToolDiscovery.${safeModelId} (${typeof value}) is invalid — ignoring the whole map`); + break; + } + } +} + function sanitizeRetryOn429ForLoad(parsed: unknown): void { if (!parsed || typeof parsed !== "object") return; const root = parsed as Record; @@ -2017,6 +2080,7 @@ export function loadConfig(): OcxConfig { const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, ""); const parsed = JSON.parse(raw); sanitizeRetryOn429ForLoad(parsed); + warnDegradedRoutedToolDiscoveryForLoad(parsed); sanitizeModelCostsForLoad(parsed); const result = configSchema.safeParse(parsed); if (result.success) { @@ -2239,6 +2303,95 @@ function googleAntigravityStaticCatalogVersionError(value: unknown): string | nu return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1, 2, or omitted"; } +const ROUTED_TOOL_DISCOVERY_EXPECTED = "must be auto, deferred, or direct"; + +/** + * Read an own DATA property without triggering a getter. + * + * The write boundary must reject accessor- and prototype-polluted candidates BEFORE it + * reads them: a validator that touches `candidate.providers[x].modelRoutedToolDiscovery` + * and only then checks for getters has already run attacker-supplied code. + * + * The three results are distinguished deliberately. Collapsing any of them to `undefined` + * makes a getter-backed or prototype-sourced field look like a missing field, so the + * validator waves it through and Zod invokes the getter (or copies the inherited value) + * moments later — the exact bypass this helper exists to stop. `inherited` covers a key + * reachable only through the prototype chain, whose descriptor is not own. + */ +type OwnDataProperty = + | { readonly kind: "absent" } + | { readonly kind: "accessor" } + | { readonly kind: "inherited" } + | { readonly kind: "data"; readonly value: unknown }; + +function ownDataProperty(target: object, key: string): OwnDataProperty { + const descriptor = Object.getOwnPropertyDescriptor(target, key); + // `in` walks the prototype chain WITHOUT reading, so an inherited accessor is classified + // rather than invoked. + if (!descriptor) return key in target ? { kind: "inherited" } : { kind: "absent" }; + if (!("value" in descriptor)) return { kind: "accessor" }; + return { kind: "data", value: descriptor.value }; +} + +/** + * Reject malformed routed tool-discovery policy at the write boundary. + * + * The load path degrades these fields with `.catch(undefined)` so a typo cannot cost a user + * their providers or credentials. That same tolerance would silently discard a deliberate + * write, so live writes must fail loudly instead — the pattern already used by + * `activeCodexAccountPinned`. + */ +function routedToolDiscoveryError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw) return null; + const providersProperty = ownDataProperty(raw, "providers"); + if (providersProperty.kind === "accessor" || providersProperty.kind === "inherited") { + return "schema_invalid: providers: must be a plain object"; + } + const providers = providersProperty.kind === "data" ? providersProperty.value : undefined; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) return null; + + for (const name of Object.getOwnPropertyNames(providers)) { + const providerProperty = ownDataProperty(providers, name); + if (providerProperty.kind === "accessor" || providerProperty.kind === "inherited") { + return `schema_invalid: providers.${name}: must be a plain object`; + } + const provider = providerProperty.kind === "data" ? providerProperty.value : undefined; + if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue; + + const modeProperty = ownDataProperty(provider, "routedToolDiscovery"); + if (modeProperty.kind === "accessor" || modeProperty.kind === "inherited") { + return `schema_invalid: providers.${name}.routedToolDiscovery: ${ROUTED_TOOL_DISCOVERY_EXPECTED}`; + } + if (modeProperty.kind === "data" + && modeProperty.value !== undefined + && !isRoutedToolDiscoveryMode(modeProperty.value)) { + return `schema_invalid: providers.${name}.routedToolDiscovery: ${ROUTED_TOOL_DISCOVERY_EXPECTED}`; + } + + const mapProperty = ownDataProperty(provider, "modelRoutedToolDiscovery"); + if (mapProperty.kind === "accessor" || mapProperty.kind === "inherited") { + return `schema_invalid: providers.${name}.modelRoutedToolDiscovery: must be a plain object of model overrides`; + } + if (mapProperty.kind === "absent") continue; + const map = mapProperty.value; + if (map === undefined) continue; + if (typeof map !== "object" || map === null || Array.isArray(map)) { + return `schema_invalid: providers.${name}.modelRoutedToolDiscovery: must be a plain object of model overrides`; + } + for (const modelId of Object.getOwnPropertyNames(map)) { + if (modelId.trim() === "") { + return `schema_invalid: providers.${name}.modelRoutedToolDiscovery: model keys must be nonblank`; + } + const modelModeProperty = ownDataProperty(map, modelId); + if (modelModeProperty.kind !== "data" || !isRoutedToolDiscoveryMode(modelModeProperty.value)) { + return `schema_invalid: providers.${name}.modelRoutedToolDiscovery.${modelId}: ${ROUTED_TOOL_DISCOVERY_EXPECTED}`; + } + } + } + return null; +} + function codexAccountPickerEnabledError(value: unknown): string | null { const raw = rawConfigRecord(value); if (!raw) return null; @@ -2304,6 +2457,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? googleAntigravityStaticCatalogVersionError(value) ?? codexAccountPrioritiesError(value) ?? codexAccountPickerEnabledError(value) + ?? routedToolDiscoveryError(value) ?? loopbackListenerPortError(value); if (boundaryError) return { ok: false, error: boundaryError }; const result = configSchema.safeParse(value); @@ -2318,6 +2472,7 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics { // schema and send the caller a default-config fallback (the config command could then // persist that fallback over the user's providers/keys). sanitizeRetryOn429ForLoad(parsed); + warnDegradedRoutedToolDiscoveryForLoad(parsed); sanitizeModelCostsForLoad(parsed); const result = configSchema.safeParse(parsed); if (result.success) { diff --git a/src/types.ts b/src/types.ts index d24811f430..3622712e81 100644 --- a/src/types.ts +++ b/src/types.ts @@ -940,6 +940,13 @@ export interface OcxConfig { export type OcxAccountPoolRotationStrategy = "quota" | "round-robin" | "fill-first"; +/** + * Routed-row Codex tool-discovery policy. `auto` resolves to `deferred` for non-Cursor + * routed rows and `direct` for Cursor; the resolved catalog value is only ever + * `deferred` or `direct`. + */ +export type OcxRoutedToolDiscoveryMode = "auto" | "deferred" | "direct"; + export type OcxComboStrategy = "failover" | "round-robin"; export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra"; @@ -1450,6 +1457,23 @@ export interface OcxProviderConfig { * only on explicit `true`. See devlog/_plan/260709_parallel_tool_calls. */ parallelToolCalls?: boolean; + /** + * Codex routed-row tool-discovery policy. `auto` (default) keeps the shipped behavior: + * deferred for non-Cursor routed rows, direct for Cursor. Set `direct` only for a route + * PROVEN incompatible with deferred discovery — it embeds every MCP declaration in the + * first request (a measured 2.7x turn-1 payload cost) and does NOT make an otherwise + * eligible tool reachable under code mode, where upstream installs nested tools on the + * `tools`/`ALL_TOOLS` globals either way. See + * devlog/_plan/260813_routed_tool_discovery_profiles. + */ + routedToolDiscovery?: OcxRoutedToolDiscoveryMode; + /** + * Per-model routed tool-discovery override; wins over the provider-level value so one + * incompatible model on a mixed gateway does not penalize its siblings. Keys follow + * `modelRecordValue()` matching (exact id, family before `:`, case-insensitive full id); + * dated `-YYYYMMDD` variants are NOT matched, so name the exact failing model id. + */ + modelRoutedToolDiscovery?: Record; /** * Opt-in: forward `prompt_cache_key` to the upstream `/chat/completions` body. * OpenAI-specific extension; strict backends (Groq, Cerebras, etc.) reject unknown diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 54bfaaf1cb..be48d4e79b 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -157,8 +157,37 @@ deferred MCP tools remain callable through exec's `tools` global / `ALL_TOOLS` w kimi/k3 executed `tools.mcp__node_repl__js`, devlog `260813_tool_catalog_deferral/010+020`). Stamping `false` instead forces every MCP declaration into `exec.description` — a measured 2.7x turn-1 payload regression (96,699 → 258,929 chars). Non-Cursor routed rows independently keep -`web_search_tool_type: "text_and_image"` for the OpenCodex search sidecar; Cursor advertises -neither flag because its runTurn transport bypasses that sidecar and has no proven deferred path. +`web_search_tool_type: "text_and_image"` for the OpenCodex search sidecar. Cursor rows emit +`supports_search_tool: false` and omit `web_search_tool_type`, because its runTurn transport +bypasses that sidecar and has no proven deferred path — the row carries the discovery flag as +`false` rather than omitting it. + +That per-surface default is now the `auto` case of a resolved policy rather than a hard-coded +boolean. `OcxProviderConfig.routedToolDiscovery` and its per-model map +`modelRoutedToolDiscovery` let one proven-incompatible route opt into `direct` without moving +any sibling. `auto` reproduces the shipped routed-row shape, asserted per-key on both the +template and template-less paths rather than by a whole-catalog diff against a prior build. +Resolution happens once in `applyProviderConfigHints()` and rides +`CatalogModel.toolDiscoveryMode`; `auto` never reaches serialization. Custom-model and trusted +`openai-apikey` rows are rebuilt outside that pass and resolve the policy explicitly, so the +propagation is deliberate at each construction site rather than automatic. +Precedence is Cursor hard fence > model override > provider override > auto; a combo resolves +conservatively, since one public row cannot vary after target selection. + +Both catalog construction paths share one Cursor fence (`isCursorRoute()`): provider identity +decides, and the `cursor/` slug prefix is only a fallback for callers with no `CatalogModel`. +Before that helper the template path tested the slug while the template-less path tested the +provider, so a `cursor/`-aliased combo whose canonical provider is `combo` was classified +differently depending on whether a template existed — discovery mode and payload size varied +with template availability (unresolved P2 on #1596). + +Scope of the reachability claim: it holds for an ELIGIBLE MCP tool. `direct_only_tool_namespaces`, +`excluded_tool_namespaces`, and MCP/App policy filtering remove tools independently of this flag, +and no override repairs them. On that reading `direct` is a comprehension/compatibility lever with +a payload cost rather than a reachability fix — a conclusion drawn from the upstream `codex-rs` +source, NOT yet from an executed single-variable differential (devlog `020` still owes that run, +and `029` records it as open). Analysis and citations: +`devlog/_plan/260813_routed_tool_discovery_profiles/094_landing_verification_pass.md`. [Decision Log] - 목적과 의도: keep routed plugin/MCP tools reachable without paying the full-catalog turn-1 payload tax. diff --git a/tests/codex-tool-discovery-mode.test.ts b/tests/codex-tool-discovery-mode.test.ts new file mode 100644 index 0000000000..ba68bc6b17 --- /dev/null +++ b/tests/codex-tool-discovery-mode.test.ts @@ -0,0 +1,858 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { applyProviderConfigHints, buildCatalogEntries, normalizeRoutedCatalogEntry } from "../src/codex/catalog"; +import { gatherRoutedModels } from "../src/codex/catalog"; +import { deriveComboCatalogModel } from "../src/codex/catalog/aggregation"; +import type { CatalogModel } from "../src/codex/catalog/parsing"; +import { + deriveComboToolDiscoveryMode, + isCursorRoute, + resolveConfiguredRoutedToolDiscoveryMode, +} from "../src/codex/catalog/tool-discovery"; +import { clearModelCache } from "../src/codex/model-cache"; +import { getConfigPath, loadConfig, saveConfig } from "../src/config"; +import { validateConfigCandidate } from "../src/config"; +import type { OcxProviderConfig } from "../src/types"; + +function provider(extra: Partial = {}): OcxProviderConfig { + return { adapter: "openai-responses", baseUrl: "https://example.invalid", ...extra } as OcxProviderConfig; +} + +function model(id: string, providerName: string): CatalogModel { + return { id, provider: providerName }; +} + +describe("routed tool-discovery resolution", () => { + // The precedence table from devlog/_plan/260813_routed_tool_discovery_profiles/011. + const cases: Array<{ + label: string; + prov: OcxProviderConfig; + expected: "deferred" | "direct"; + source: string; + }> = [ + { label: "unset provider, unset model", prov: provider(), expected: "deferred", source: "default" }, + { label: "provider auto", prov: provider({ routedToolDiscovery: "auto" }), expected: "deferred", source: "provider-override" }, + { label: "provider direct", prov: provider({ routedToolDiscovery: "direct" }), expected: "direct", source: "provider-override" }, + { + label: "provider direct, model deferred", + prov: provider({ routedToolDiscovery: "direct", modelRoutedToolDiscovery: { "glm-5.2": "deferred" } }), + expected: "deferred", + source: "model-override", + }, + { + label: "provider deferred, model direct", + prov: provider({ routedToolDiscovery: "deferred", modelRoutedToolDiscovery: { "glm-5.2": "direct" } }), + expected: "direct", + source: "model-override", + }, + { + label: "provider deferred, model auto", + prov: provider({ routedToolDiscovery: "deferred", modelRoutedToolDiscovery: { "glm-5.2": "auto" } }), + expected: "deferred", + source: "model-override", + }, + ]; + + for (const testCase of cases) { + it(`resolves ${testCase.label} to ${testCase.expected}`, () => { + const resolved = resolveConfiguredRoutedToolDiscoveryMode("deepseek", testCase.prov, "glm-5.2"); + expect(resolved.mode).toBe(testCase.expected); + expect(resolved.source).toBe(testCase.source as never); + }); + } + + it("keeps Cursor direct even when explicitly configured deferred, and says so", () => { + const resolved = resolveConfiguredRoutedToolDiscoveryMode( + "cursor", + provider({ adapter: "cursor", routedToolDiscovery: "deferred" }), + "gpt-5.5", + ); + expect(resolved.mode).toBe("direct"); + expect(resolved.source).toBe("cursor-hard-fence"); + // Silent acceptance is not allowed (INV-4): the ignored configuration must surface. + expect(resolved.warning).toContain("ignored for Cursor"); + }); + + it("fences Cursor by adapter even when the provider is named differently", () => { + const resolved = resolveConfiguredRoutedToolDiscoveryMode("my-cursor-gateway", provider({ adapter: "cursor" }), "gpt-5.5"); + expect(resolved.mode).toBe("direct"); + expect(resolved.source).toBe("cursor-hard-fence"); + }); + + it("warns when a non-Cursor route opts into direct discovery", () => { + const resolved = resolveConfiguredRoutedToolDiscoveryMode("deepseek", provider({ routedToolDiscovery: "direct" }), "glm-5.2"); + expect(resolved.warning).toContain("full MCP declarations"); + }); + + it("matches model keys with modelRecordValue semantics but not dated variants", () => { + const prov = provider({ modelRoutedToolDiscovery: { "glm-5.2": "direct" } }); + expect(resolveConfiguredRoutedToolDiscoveryMode("deepseek", prov, "glm-5.2").mode).toBe("direct"); + expect(resolveConfiguredRoutedToolDiscoveryMode("deepseek", prov, "GLM-5.2").mode).toBe("direct"); + expect(resolveConfiguredRoutedToolDiscoveryMode("deepseek", prov, "glm-5.2:free").mode).toBe("direct"); + // Dated variants are deliberately NOT matched — name the exact failing model id. + expect(resolveConfiguredRoutedToolDiscoveryMode("deepseek", prov, "glm-5.2-20260813").mode).toBe("deferred"); + }); +}); + +describe("routed tool-discovery catalog emission", () => { + it("defaults to the PR #1596 shape with no configuration", () => { + const entry = normalizeRoutedCatalogEntry({ slug: "deepseek/glm-5.2" } as never) as Record; + expect(entry.supports_search_tool).toBe(true); + expect(entry.web_search_tool_type).toBe("text_and_image"); + expect(entry.tool_mode).toBe("code_mode_only"); + }); + + it("emits direct discovery when the resolved mode says so", () => { + const entry = normalizeRoutedCatalogEntry({ slug: "deepseek/glm-5.2" } as never, false, { + toolDiscoveryMode: "direct", + providerId: "deepseek", + }) as Record; + expect(entry.supports_search_tool).toBe(false); + // INV-3: hosted web search is a separate capability and must survive direct mode. + expect(entry.web_search_tool_type).toBe("text_and_image"); + expect(entry.tool_mode).toBe("code_mode_only"); + }); + + it("ignores a deferred mode for Cursor rows", () => { + const entry = normalizeRoutedCatalogEntry({ slug: "cursor/gpt-5.5" } as never, false, { + toolDiscoveryMode: "deferred", + providerId: "cursor", + }) as Record; + expect(entry.supports_search_tool).toBe(false); + expect(entry.web_search_tool_type).toBeUndefined(); + }); + + it("classifies a cursor/-aliased combo by provider identity, not by its public slug", () => { + // The unresolved #1596 P2: the template path fenced on the slug while the template-less + // path fenced on provider identity, so this row's discovery mode depended on whether a + // template happened to exist. Both paths now share isCursorRoute(), which UNIONS the + // signals — reconciling them must never unfence a row that one path used to fence, since + // an under-fenced Cursor row advertises a surface its transport cannot serve. + expect(isCursorRoute("cursor/gpt-5.5", { providerId: "combo" })).toBe(true); + expect(isCursorRoute("cursor/gpt-5.5", { providerId: "cursor" })).toBe(true); + expect(isCursorRoute("combo/mix", { cursorRoute: true })).toBe(true); + // No CatalogModel available: the slug prefix remains the only signal. + expect(isCursorRoute("cursor/gpt-5.5")).toBe(true); + expect(isCursorRoute("deepseek/glm-5.2")).toBe(false); + expect(isCursorRoute("deepseek/glm-5.2", { providerId: "deepseek" })).toBe(false); + + // Byte-identical to the pre-change template path for this row. + const entry = normalizeRoutedCatalogEntry({ slug: "cursor/gpt-5.5" } as never, false, { + toolDiscoveryMode: "deferred", + providerId: "combo", + }) as Record; + expect(entry.supports_search_tool).toBe(false); + expect(entry.web_search_tool_type).toBeUndefined(); + }); + + it("fences a Cursor-adapter gateway published under a custom provider name", () => { + // Only the resolver sees the adapter, so it stamps CatalogModel.cursorRoute and + // serialization honors it. Without that hop the row would keep hosted-search metadata + // while the resolver had already hard-fenced it to direct. + const resolved = resolveConfiguredRoutedToolDiscoveryMode("my-gw", provider({ adapter: "cursor" }), "gpt-5.5"); + expect(resolved.mode).toBe("direct"); + + const entry = normalizeRoutedCatalogEntry({ slug: "my-gw/gpt-5.5" } as never, false, { + toolDiscoveryMode: resolved.mode, + providerId: "my-gw", + cursorRoute: true, + }) as Record; + expect(entry.supports_search_tool).toBe(false); + expect(entry.web_search_tool_type).toBeUndefined(); + expect(entry.supports_parallel_tool_calls).toBe(true); + }); + + it("stamps cursorRoute on the CatalogModel for a Cursor-adapter gateway", () => { + const hinted = applyProviderConfigHints("my-gw", provider({ adapter: "cursor" }), model("gpt-5.5", "my-gw")); + expect(hinted.cursorRoute).toBe(true); + expect(hinted.toolDiscoveryMode).toBe("direct"); + // A plain provider must not gain the marker. + expect(applyProviderConfigHints("deepseek", provider(), model("glm-5.2", "deepseek")).cursorRoute).toBeUndefined(); + }); +}); + +describe("routed tool-discovery propagation", () => { + // These drive the REAL construction paths in sync.ts rather than calling normalization + // directly, so the template-less fallback branch and its ensureStrictCatalogFields + // interaction are actually covered. + it("carries a direct override through the template-less fallback path", () => { + const entries = buildCatalogEntries(null, [], [ + { provider: "deepseek", id: "glm-5.2", toolDiscoveryMode: "direct" }, + ]); + const routed = entries.find(entry => entry.slug === "deepseek/glm-5.2"); + expect(routed?.supports_search_tool).toBe(false); + // Hosted search is a separate capability and survives direct mode. + expect(routed?.web_search_tool_type).toBe("text_and_image"); + expect(routed?.tool_mode).toBe("code_mode_only"); + }); + + it("keeps the fallback path on deferred with no configuration", () => { + const entries = buildCatalogEntries(null, [], [{ provider: "deepseek", id: "glm-5.2" }]); + const routed = entries.find(entry => entry.slug === "deepseek/glm-5.2"); + expect(routed?.supports_search_tool).toBe(true); + expect(routed?.web_search_tool_type).toBe("text_and_image"); + }); + + it("fences a Cursor-adapter gateway on the fallback path via cursorRoute", () => { + const entries = buildCatalogEntries(null, [], [ + { provider: "my-gw", id: "gpt-5.5", toolDiscoveryMode: "direct", cursorRoute: true }, + ]); + const routed = entries.find(entry => entry.slug === "my-gw/gpt-5.5"); + expect(routed?.supports_search_tool).toBe(false); + expect(routed?.web_search_tool_type).toBeUndefined(); + }); + + it("carries the resolved mode onto the CatalogModel", () => { + const hinted = applyProviderConfigHints("deepseek", provider({ routedToolDiscovery: "direct" }), model("glm-5.2", "deepseek")); + expect(hinted.toolDiscoveryMode).toBe("direct"); + }); + + it("leaves an unconfigured provider on deferred", () => { + const hinted = applyProviderConfigHints("deepseek", provider(), model("glm-5.2", "deepseek")); + expect(hinted.toolDiscoveryMode).toBe("deferred"); + }); + + it("changes only the targeted model on a mixed gateway", () => { + const prov = provider({ modelRoutedToolDiscovery: { "glm-5.2": "direct" } }); + expect(applyProviderConfigHints("gateway", prov, model("glm-5.2", "gateway")).toolDiscoveryMode).toBe("direct"); + expect(applyProviderConfigHints("gateway", prov, model("kimi-k3", "gateway")).toolDiscoveryMode).toBe("deferred"); + }); +}); + +describe("combo tool-discovery derivation", () => { + // Full matrix from devlog 025_combo_policy_tests.md, including the migration rows where a + // member has not resolved a mode yet. + it("is conservative: one direct member forces the combo direct", () => { + expect(deriveComboToolDiscoveryMode(["deferred", "deferred"])).toBe("deferred"); + expect(deriveComboToolDiscoveryMode(["deferred", "direct"])).toBe("direct"); + expect(deriveComboToolDiscoveryMode(["direct", "direct"])).toBe("direct"); + expect(deriveComboToolDiscoveryMode([undefined, "deferred"])).toBe("deferred"); + expect(deriveComboToolDiscoveryMode([undefined, "direct"])).toBe("direct"); + expect(deriveComboToolDiscoveryMode([undefined, undefined])).toBe("deferred"); + expect(deriveComboToolDiscoveryMode([])).toBe("deferred"); + }); + + it("propagates the conservative mode through deriveComboCatalogModel", () => { + const members: CatalogModel[] = [ + { id: "a", provider: "p1", contextWindow: 200_000, toolDiscoveryMode: "deferred" }, + { id: "b", provider: "p2", contextWindow: 200_000, toolDiscoveryMode: "direct" }, + ]; + const combo = deriveComboCatalogModel( + "combo/mix", + { targets: [{ provider: "p1", model: "a" }, { provider: "p2", model: "b" }], strategy: "failover" } as never, + members, + ); + expect(combo?.toolDiscoveryMode).toBe("direct"); + }); + + it("covers the remaining member compositions", () => { + // 025_combo_policy_tests.md: a single direct member must dominate regardless of position + // or arity, and an unknown member must not be read as direct. + expect(deriveComboToolDiscoveryMode(["direct", "deferred"])).toBe("direct"); + expect(deriveComboToolDiscoveryMode(["deferred", "deferred", "direct"])).toBe("direct"); + expect(deriveComboToolDiscoveryMode(["direct", "direct"])).toBe("direct"); + expect(deriveComboToolDiscoveryMode(["deferred", undefined])).toBe("deferred"); + expect(deriveComboToolDiscoveryMode([undefined, "direct"])).toBe("direct"); + expect(deriveComboToolDiscoveryMode(["direct"])).toBe("direct"); + }); + + it("omits the field entirely for an all-deferred combo", () => { + // Emitting it unconditionally broke codex-catalog's combo-shape assertion during + // development; an all-deferred combo must stay byte-identical to the pre-override shape. + const combo = deriveComboCatalogModel( + "combo/plain", + { targets: [{ provider: "p1", model: "a" }, { provider: "p2", model: "b" }], strategy: "failover" } as never, + [ + { id: "a", provider: "p1", contextWindow: 200_000, toolDiscoveryMode: "deferred" }, + { id: "b", provider: "p2", contextWindow: 200_000, toolDiscoveryMode: "deferred" }, + ], + ); + expect(combo).not.toHaveProperty("toolDiscoveryMode"); + }); + + // 025 alias coverage: the policy must survive every combo alias SHAPE, because the alias + // is what Codex sees and a shape-dependent policy is the class of defect the unified + // Cursor fence already had to fix once. + it("carries a direct combo policy through every alias shape", () => { + const members: CatalogModel[] = [ + { id: "a", provider: "p1", contextWindow: 200_000, maxInputTokens: 200_000, inputModalities: ["text"], toolDiscoveryMode: "deferred" }, + { id: "b", provider: "p2", contextWindow: 200_000, maxInputTokens: 200_000, inputModalities: ["text"], toolDiscoveryMode: "direct" }, + ]; + const targets = [{ provider: "p1", model: "a" }, { provider: "p2", model: "b" }]; + + for (const alias of [undefined, "bare-combo", "vendor/slashed-combo"]) { + const combo = deriveComboCatalogModel( + "mixed", + { targets, strategy: "failover", ...(alias ? { alias } : {}) } as never, + members, + ); + expect(combo?.toolDiscoveryMode).toBe("direct"); + // The derived model must CARRY the alias; re-attaching it here would let a broken + // derivation pass, which is exactly how an earlier revision of this test fooled itself. + expect(combo?.alias).toBe(alias as never); + + const slug = alias ?? "combo/mixed"; + const rows = buildCatalogEntries(null, [], [combo!], undefined, false, "default", new Set(alias ? [alias] : [])); + // No `?? rows[0]` fallback: the row must exist at the EXACT expected slug, or the + // alias shape is not actually being exercised. + const row = rows.find(entry => entry.slug === slug); + expect(row).toBeDefined(); + expect(row!.slug).toBe(slug); + expect(row!.supports_search_tool).toBe(false); + // Hosted search stays independent of the discovery policy on every shape. + expect(row!.web_search_tool_type).toBe("text_and_image"); + expect(row!.tool_mode).toBe("code_mode_only"); + } + }); + + it("carries a direct combo policy through an explicit native alias", () => { + const combo = deriveComboCatalogModel( + "mixed", + { targets: [{ provider: "p1", model: "a" }, { provider: "p2", model: "b" }], strategy: "failover", alias: "gpt-5.6-sol", nativeAlias: true } as never, + [ + { id: "a", provider: "p1", contextWindow: 200_000, toolDiscoveryMode: "deferred" }, + { id: "b", provider: "p2", contextWindow: 200_000, toolDiscoveryMode: "direct" }, + ], + ); + expect(combo?.toolDiscoveryMode).toBe("direct"); + expect(combo?.alias).toBe("gpt-5.6-sol"); + expect(combo?.nativeAlias).toBe(true); + + const rows = buildCatalogEntries(null, [], [combo!], undefined, false, "default", new Set(["gpt-5.6-sol"])); + const row = rows.find(entry => entry.slug === "gpt-5.6-sol"); + expect(row).toBeDefined(); + // A combo that takes over a native slug is still a ROUTED row and must carry the + // resolved policy rather than inheriting native catalog defaults. + expect(row!.supports_search_tool).toBe(false); + expect(row!.tool_mode).toBe("code_mode_only"); + }); +}); + +describe("routed tool-discovery backward compatibility", () => { + // 023_backward_compatibility_tests.md: the load-bearing promise of this feature is that an + // unconfigured tree is unchanged. Assert the WHOLE emitted row, not just the two policy + // fields, so an unrelated key silently appearing on routed rows also fails here. + it("emits identical rows whether the deferred default is implicit or explicit", () => { + const withoutPolicy = buildCatalogEntries(null, [], [{ provider: "deepseek", id: "glm-5.2" }]); + const withExplicitAuto = buildCatalogEntries(null, [], [ + { provider: "deepseek", id: "glm-5.2", toolDiscoveryMode: "deferred" }, + ]); + expect(JSON.stringify(withExplicitAuto)).toBe(JSON.stringify(withoutPolicy)); + }); + + // Comparing two rows built by the same path cannot catch a key that leaks onto BOTH, so + // the key set is pinned absolutely and any new catalog field must be added here + // deliberately. Ablation-verified: injecting a stray field into the template-less branch + // fails this, and injecting one into normalizeRoutedCatalogEntry fails the template case. + const EXPECTED_ROUTED_KEYS = [ + "apply_patch_tool_type", + "auto_compact_token_limit", + "base_instructions", + "comp_hash", + "context_window", + "default_reasoning_level", + "default_reasoning_summary", + "default_verbosity", + "description", + "display_name", + "effective_context_window_percent", + "experimental_supported_tools", + "input_modalities", + "max_context_window", + "priority", + "shell_type", + "slug", + "support_verbosity", + "supported_in_api", + "supported_reasoning_levels", + "supports_image_detail_original", + "supports_parallel_tool_calls", + "supports_reasoning_summaries", + "supports_search_tool", + "tool_mode", + "truncation_policy", + "visibility", + "web_search_tool_type", + ]; + + it("pins the exact emitted key set on the template-less path", () => { + const rows = buildCatalogEntries(null, [], [{ provider: "deepseek", id: "glm-5.2" }]); + const routed = rows.find(entry => entry.slug === "deepseek/glm-5.2"); + expect(Object.keys(routed ?? {}).sort()).toEqual(EXPECTED_ROUTED_KEYS); + }); + + it("pins the exact emitted key set on the template path", () => { + // The template path runs normalizeRoutedCatalogEntry, which the fallback never calls, so + // it needs its own guard. This is an ABSOLUTE key set rather than a list of forbidden + // names: an earlier version only rejected the three policy fields plus one sentinel, so + // a stray field under any other name passed straight through it. + const normalized = normalizeRoutedCatalogEntry({ slug: "deepseek/glm-5.2" } as never, false, { + toolDiscoveryMode: "direct", + providerId: "deepseek", + cursorRoute: false, + }) as Record; + expect(Object.keys(normalized).sort()).toEqual([ + "apply_patch_tool_type", + "auto_compact_token_limit", + "comp_hash", + "context_window", + "default_reasoning_summary", + "default_verbosity", + "effective_context_window_percent", + "experimental_supported_tools", + "input_modalities", + "max_context_window", + "slug", + "support_verbosity", + "supports_image_detail_original", + "supports_parallel_tool_calls", + "supports_reasoning_summaries", + "supports_search_tool", + "tool_mode", + "truncation_policy", + "web_search_tool_type", + ]); + }); + + it("keeps the Cursor row shape unchanged with zero configuration", () => { + const rows = buildCatalogEntries(null, [], [{ provider: "cursor", id: "gpt-5.5" }]); + const cursor = rows.find(entry => entry.slug === "cursor/gpt-5.5"); + expect(cursor?.supports_search_tool).toBe(false); + expect(cursor).not.toHaveProperty("web_search_tool_type"); + // The catalog must never carry the opencodex-only internal field. + expect(cursor).not.toHaveProperty("tool_discovery_mode"); + expect(cursor).not.toHaveProperty("toolDiscoveryMode"); + }); + + // These drive the REAL on-disk round trip (loadConfig/saveConfig against a temp + // OPENCODEX_HOME), not just validateConfigCandidate. Validating a candidate object cannot + // prove the file-level promises in 023: that a pre-field config gains nothing on read, and + // that an unrelated save preserves fields a newer binary wrote. + function withTempHome(run: () => T): T { + const previous = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-tool-discovery-compat-")); + process.env.OPENCODEX_HOME = home; + try { return run(); } + finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + rmSync(home, { recursive: true, force: true }); + } + } + + it("warns instead of silently degrading a malformed policy on load", () => { + // 020: the load path is tolerant on purpose, but an operator whose emergency escape + // hatch was dropped by a typo must be told — silence would repeat the #1529 + // observability failure at smaller scope. + withTempHome(() => { + const base = { + port: 10100, + defaultProvider: "test", + providers: { + test: { + adapter: "openai-chat", + baseUrl: "http://127.0.0.1:1/v1", + apiKey: "k", + allowPrivateNetwork: true, + routedToolDiscovery: "eager", + modelRoutedToolDiscovery: { "glm-5.2": "nope" }, + }, + }, + }; + saveConfig(base as never); + writeFileSync(getConfigPath(), `${JSON.stringify(base, null, 2)}\n`); + + const warnings: string[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); }; + let loaded; + try { loaded = loadConfig(); } + finally { console.warn = original; } + + // Degraded, not fatal: the provider and its credential survive. + const provider = loaded.providers.test as unknown as Record; + expect(provider.apiKey).toBe("k"); + expect(provider.routedToolDiscovery).toBeUndefined(); + expect(provider.modelRoutedToolDiscovery).toBeUndefined(); + + const joined = warnings.join("\n"); + expect(joined).toContain("routedToolDiscovery"); + expect(joined).toContain("modelRoutedToolDiscovery"); + // Never echo the offending value; provider config can hold secrets. + expect(joined).not.toContain("eager"); + expect(joined).not.toContain("nope"); + }); + }); + + it("warns when a blank model key silently drops the whole map on load", () => { + // The trimmed-nonblank refinement rejects the key and takes the entire map with it, so + // a value-only warning would let the map vanish without a word. The fixture is + // whitespace-only on purpose: `.min(1)` accepts it, so an empty-string fixture would + // pass under both rules and could not catch the load/write mismatch. + withTempHome(() => { + const base = { + port: 10100, + defaultProvider: "test", + providers: { + test: { + adapter: "openai-chat", + baseUrl: "http://127.0.0.1:1/v1", + apiKey: "k", + allowPrivateNetwork: true, + // Whitespace-only, not just empty: `.min(1)` would accept " " while the write + // boundary rejects it as blank, so the warning would claim a drop that never happened. + modelRoutedToolDiscovery: { " ": "direct" }, + }, + }, + }; + saveConfig(base as never); + writeFileSync(getConfigPath(), `${JSON.stringify(base, null, 2)}\n`); + + const warnings: string[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); }; + let loaded; + try { loaded = loadConfig(); } + finally { console.warn = original; } + + const provider = loaded.providers.test as unknown as Record; + expect(provider.apiKey).toBe("k"); + expect(provider.modelRoutedToolDiscovery).toBeUndefined(); + const joined = warnings.join("\n"); + expect(joined).toContain("modelRoutedToolDiscovery"); + expect(joined).toContain("blank model key"); + }); + }); + + it("does not materialize the new fields when loading a pre-field config file", () => { + withTempHome(() => { + // A config written before these fields existed. A materialized default here would + // rewrite every existing user's file on their next unrelated save. + const legacy = { + port: 10100, + defaultProvider: "test", + providers: { + test: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "k", allowPrivateNetwork: true }, + }, + }; + saveConfig(legacy as never); + writeFileSync(getConfigPath(), `${JSON.stringify(legacy, null, 2)}\n`); + + const loaded = loadConfig(); + const provider = loaded.providers.test as unknown as Record; + expect(Object.hasOwn(provider, "routedToolDiscovery")).toBe(false); + expect(Object.hasOwn(provider, "modelRoutedToolDiscovery")).toBe(false); + + // A no-op read followed by an unrelated save must not introduce them either. + saveConfig({ ...loaded, port: 10101 } as never); + const onDisk = JSON.parse(readFileSync(getConfigPath(), "utf8")) as Record; + const savedProvider = (onDisk.providers as Record>).test; + expect(Object.hasOwn(savedProvider, "routedToolDiscovery")).toBe(false); + expect(Object.hasOwn(savedProvider, "modelRoutedToolDiscovery")).toBe(false); + }); + }); + + it("preserves the fields through an unrelated save, the downgrade contract", () => { + withTempHome(() => { + // Written on disk as UNKNOWN provider keys — the shape an older binary sees. The + // schema is .passthrough(), so an unrelated save must carry them through rather than + // dropping a newer operator's escape hatch. + const configured = { + port: 10100, + defaultProvider: "test", + providers: { + test: { + adapter: "openai-chat", + baseUrl: "http://127.0.0.1:1/v1", + apiKey: "k", + allowPrivateNetwork: true, + routedToolDiscovery: "direct", + modelRoutedToolDiscovery: { "glm-5.2": "deferred" }, + }, + }, + }; + saveConfig(configured as never); + writeFileSync(getConfigPath(), `${JSON.stringify(configured, null, 2)}\n`); + + const loaded = loadConfig(); + saveConfig({ ...loaded, port: 10102 } as never); + + const onDisk = JSON.parse(readFileSync(getConfigPath(), "utf8")) as Record; + const savedProvider = (onDisk.providers as Record>).test; + expect(savedProvider.routedToolDiscovery).toBe("direct"); + expect(savedProvider.modelRoutedToolDiscovery).toEqual({ "glm-5.2": "deferred" }); + }); + }); + + it("never serializes the internal policy field onto a routed row", () => { + const rows = buildCatalogEntries(null, [], [ + { provider: "deepseek", id: "glm-5.2", toolDiscoveryMode: "direct", cursorRoute: false }, + ]); + const routed = rows.find(entry => entry.slug === "deepseek/glm-5.2"); + expect(routed).not.toHaveProperty("toolDiscoveryMode"); + expect(routed).not.toHaveProperty("tool_discovery_mode"); + expect(routed).not.toHaveProperty("cursorRoute"); + }); +}); + +describe("routed tool-discovery gather identity", () => { + // Ablation matrix (run 2026-08-13), so nobody has to guess what these pin: + // remove rtd/mrtd from providerCatalogFingerprint ............ still green + // neutralize discoveryPolicyIdentity ......................... still green + // neutralize providerGraphIdentity ........................... still green + // neutralize ALL THREE ....................................... 2 of 3 FAIL + // So these are not vacuous — they detect a real flight collision — but no single + // mechanism is what they pin, because the three are redundant by design. The + // fingerprint fields are kept as defense in depth: it is an explicit allow-list whose + // omissions leaked flights twice before (credentials, then reasoningEfforts, both + // reproduced against real routes), and its correctness is NOT what these prove. + function liveGatherConfig( + fetchImpl: typeof globalThis.fetch, + routedToolDiscovery?: "auto" | "deferred" | "direct", + modelRoutedToolDiscovery?: Record, + ): never { + return { + defaultProvider: "tdlab", + providers: { + tdlab: { + adapter: "openai-chat", + baseUrl: "http://127.0.0.1:59117/v1", + apiKey: "sk-test", + liveModels: true, + // Caller-owned transport executor: the supported injection point (it is excluded + // from provider-graph identity as non-admitted state). + fetch: fetchImpl, + // Keeps the gather hermetic: the destination policy resolves real DNS and + // fail-closes on an unresolvable host, which would short-circuit before the + // latched fetch is ever entered. + allowPrivateNetwork: true, + ...(routedToolDiscovery ? { routedToolDiscovery } : {}), + ...(modelRoutedToolDiscovery ? { modelRoutedToolDiscovery } : {}), + }, + }, + } as never; + } + + // A sequential pair cannot observe flight REUSE at all: a completed flight is removed from + // gatherInflight in its finally block, so two wholly independent gathers also compare + // equal. Observing the join needs real concurrency plus a way to count upstream discovery. + function latchedFetch(): { fetch: typeof globalThis.fetch; release: () => void; calls: () => number } { + let calls = 0; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const impl = (async () => { + calls += 1; + await gate; + return new Response( + JSON.stringify({ data: [{ id: "glm-5.2" }] }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as unknown as typeof globalThis.fetch; + return { fetch: impl, release, calls: () => calls }; + } + + function mode(models: CatalogModel[]): string | undefined { + return models.find(model => model.id === "glm-5.2")?.toolDiscoveryMode; + } + + it("joins one flight for an identical policy but splits for a different one", async () => { + clearModelCache("tdlab"); + const same = latchedFetch(); + const joined = Promise.all([ + gatherRoutedModels(liveGatherConfig(same.fetch, "direct")), + gatherRoutedModels(liveGatherConfig(same.fetch, "direct")), + ]); + same.release(); + const [first, second] = await joined; + // One upstream discovery for two concurrent identical-policy callers: they joined. + expect(same.calls()).toBe(1); + expect(mode(first)).toBe("direct"); + expect(second).toEqual(first); + + clearModelCache("tdlab"); + const split = latchedFetch(); + const separate = Promise.all([ + gatherRoutedModels(liveGatherConfig(split.fetch, "deferred")), + gatherRoutedModels(liveGatherConfig(split.fetch, "direct")), + ]); + split.release(); + const [asDeferred, asDirect] = await separate; + // Two flights: joining would serve one config the other's catalog. + expect(split.calls()).toBe(2); + expect(mode(asDeferred)).toBe("deferred"); + expect(mode(asDirect)).toBe("direct"); + }); + + it("splits concurrent flights that differ only in the per-model map", async () => { + clearModelCache("tdlab"); + const split = latchedFetch(); + const both = Promise.all([ + gatherRoutedModels(liveGatherConfig(split.fetch, undefined, { "glm-5.2": "direct" })), + gatherRoutedModels(liveGatherConfig(split.fetch, undefined, { "glm-5.2": "deferred" })), + ]); + split.release(); + const [direct, deferred] = await both; + expect(split.calls()).toBe(2); + expect(mode(direct)).toBe("direct"); + expect(mode(deferred)).toBe("deferred"); + }); + + it("re-resolves the policy against a warm model cache", async () => { + clearModelCache("tdlab"); + const warm = latchedFetch(); + const first = gatherRoutedModels(liveGatherConfig(warm.fetch, "deferred")); + warm.release(); + expect(mode(await first)).toBe("deferred"); + + // The model list may now come from the warm cache, but the POLICY must be re-resolved + // from the current config rather than inherited from the cached gather. + expect(mode(await gatherRoutedModels(liveGatherConfig(warm.fetch, "direct")))).toBe("direct"); + }); +}); + +describe("routed tool-discovery config admission", () => { + // defaultProvider must resolve, otherwise every candidate fails on an unrelated rule. + function candidate(providerPatch: Record): Record { + return { + defaultProvider: "deepseek", + providers: { deepseek: { adapter: "openai-responses", baseUrl: "https://example.invalid", ...providerPatch } }, + }; + } + + it("accepts every valid mode", () => { + for (const mode of ["auto", "deferred", "direct"]) { + expect(validateConfigCandidate(candidate({ routedToolDiscovery: mode })).ok).toBe(true); + expect(validateConfigCandidate(candidate({ modelRoutedToolDiscovery: { "glm-5.2": mode } })).ok).toBe(true); + } + }); + + it("rejects an unknown provider-level mode with a path-specific error", () => { + const result = validateConfigCandidate(candidate({ routedToolDiscovery: "eager" })); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected rejection"); + expect(result.error).toBe("schema_invalid: providers.deepseek.routedToolDiscovery: must be auto, deferred, or direct"); + }); + + it("rejects an unknown model-level mode naming the model", () => { + const result = validateConfigCandidate(candidate({ modelRoutedToolDiscovery: { "glm-5.2": "eager" } })); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected rejection"); + expect(result.error).toBe("schema_invalid: providers.deepseek.modelRoutedToolDiscovery.glm-5.2: must be auto, deferred, or direct"); + }); + + it("rejects a non-object model map and a blank model key", () => { + expect(validateConfigCandidate(candidate({ modelRoutedToolDiscovery: "direct" })).ok).toBe(false); + expect(validateConfigCandidate(candidate({ modelRoutedToolDiscovery: ["direct"] })).ok).toBe(false); + expect(validateConfigCandidate(candidate({ modelRoutedToolDiscovery: { " ": "direct" } })).ok).toBe(false); + }); + + it("rejects an accessor-backed model map without invoking the getter", () => { + let getterCalls = 0; + const provider: Record = { adapter: "openai-responses", baseUrl: "https://example.invalid" }; + Object.defineProperty(provider, "modelRoutedToolDiscovery", { + enumerable: true, + configurable: true, + get() { + getterCalls += 1; + return { "glm-5.2": "direct" }; + }, + }); + const result = validateConfigCandidate({ defaultProvider: "deepseek", providers: { deepseek: provider } }); + expect(result.ok).toBe(false); + // The descriptor check must precede the read, so the getter never runs. + expect(getterCalls).toBe(0); + }); + + it("rejects an accessor-backed provider-level mode without invoking the getter", () => { + // An accessor must not be mistaken for an absent field: treating it as absent lets the + // validator pass and Zod invokes the getter moments later, which is the whole bypass. + let getterCalls = 0; + const provider: Record = { adapter: "openai-responses", baseUrl: "https://example.invalid" }; + Object.defineProperty(provider, "routedToolDiscovery", { + enumerable: true, + configurable: true, + get() { + getterCalls += 1; + return "direct"; + }, + }); + const result = validateConfigCandidate({ defaultProvider: "deepseek", providers: { deepseek: provider } }); + expect(result.ok).toBe(false); + expect(getterCalls).toBe(0); + }); + + it("rejects an accessor-backed provider entry and an accessor providers map", () => { + let entryCalls = 0; + const providers: Record = {}; + Object.defineProperty(providers, "deepseek", { + enumerable: true, + configurable: true, + get() { + entryCalls += 1; + return { adapter: "openai-responses", baseUrl: "https://example.invalid", routedToolDiscovery: "direct" }; + }, + }); + expect(validateConfigCandidate({ defaultProvider: "deepseek", providers }).ok).toBe(false); + expect(entryCalls).toBe(0); + + let mapCalls = 0; + const root: Record = { defaultProvider: "deepseek" }; + Object.defineProperty(root, "providers", { + enumerable: true, + configurable: true, + get() { + mapCalls += 1; + return { deepseek: { adapter: "openai-responses", baseUrl: "https://example.invalid" } }; + }, + }); + expect(validateConfigCandidate(root).ok).toBe(false); + expect(mapCalls).toBe(0); + }); + + it("rejects a prototype-inherited value even when it is otherwise valid", () => { + // An INVALID inherited value is a weak test: Zod's .catch(undefined) would discard it and + // the candidate would pass for the wrong reason. A VALID inherited value is the real + // case — without an explicit check it gets copied into the admitted config. + const polluted = Object.create({ routedToolDiscovery: "direct" }) as Record; + polluted.adapter = "openai-responses"; + polluted.baseUrl = "https://example.invalid"; + expect(validateConfigCandidate({ defaultProvider: "deepseek", providers: { deepseek: polluted } }).ok).toBe(false); + }); + + it("rejects a prototype-inherited getter without invoking it", () => { + let getterCalls = 0; + const proto = {}; + Object.defineProperty(proto, "routedToolDiscovery", { + configurable: true, + get() { + getterCalls += 1; + return "direct"; + }, + }); + const polluted = Object.create(proto) as Record; + polluted.adapter = "openai-responses"; + polluted.baseUrl = "https://example.invalid"; + const result = validateConfigCandidate({ defaultProvider: "deepseek", providers: { deepseek: polluted } }); + expect(result.ok).toBe(false); + // `in` walks the prototype chain without reading, so the getter must never run. + expect(getterCalls).toBe(0); + }); + + it("rejects an inherited providers map without invoking its getter", () => { + let getterCalls = 0; + const proto = {}; + Object.defineProperty(proto, "providers", { + configurable: true, + get() { + getterCalls += 1; + return { deepseek: { adapter: "openai-responses", baseUrl: "https://example.invalid" } }; + }, + }); + const root = Object.create(proto) as Record; + root.defaultProvider = "deepseek"; + expect(validateConfigCandidate(root).ok).toBe(false); + expect(getterCalls).toBe(0); + }); +});