diff --git a/docs-site/src/content/docs/fr/reference/configuration/server.md b/docs-site/src/content/docs/fr/reference/configuration/server.md index b12d07996f..fa9400d540 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -209,7 +209,7 @@ l'API Images d'OpenAI et la forme de réponse attendue par Codex. | Champ | Type | Par défaut | Signification | | --- | --- | --- | --- | | `enabled?` | `boolean` | activé lorsqu'il est utilisable | Interrupteur principal. | -| `backend?` | `"openai" \| "anthropic"` | automatique | Une valeur explicite est prioritaire ; sinon, la présence d'identifiants OAuth Anthropic stockés et utilisables sélectionne `anthropic`, puis `openai`. | +| `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | Une valeur explicite est prioritaire ; sans valeur, `openai` est toujours utilisé. `xai`, `gemini` et `exa` ne s'activent que s'ils sont configurés explicitement. | | `model?` | `string` | dépendant du backend | `gpt-5.6-luna` pour OpenAI ou `claude-sonnet-5` pour Anthropic. L'héritage explicite `gpt-5.4-mini` migre au démarrage. | | `reasoning?` | `string` | `low` | Effort secondaire. `minimal` est rejeté lors de la recherche sur le Web. | | `maxSearchesPerTurn?` | `number` | `3` | Recherches réelles autorisées par tour de modèle principal. | @@ -231,7 +231,7 @@ une garde d'inactivité, pas un délai de génération total. | Champ | Type | Par défaut | Signification | | --- | --- | --- | --- | | `enabled?` | `boolean` | activé lorsqu'il est utilisable | Commutateur principal de description d'images. | -| `backend?` | `"openai" \| "anthropic"` | automatique | Même sélection, prioritaire lorsqu'elle est explicite et tenant compte des identifiants Anthropic, que pour la recherche Web. | +| `backend?` | `"openai" \| "anthropic"` | automatique | La valeur explicite prévaut ; si elle est omise, un identifiant OAuth Anthropic stocké et utilisable est privilégié, sinon `openai`. | | `model?` | `string` | dépendant du backend | `gpt-5.4-mini` pour OpenAI ou `claude-sonnet-5` pour Anthropic. | | `maxDescriptionsPerTurn?` | `number` | `8` | Nouvelles descriptions des ratés du cache admises par tour principal. `0` désactive les appels ; les valeurs non valides utilisent la valeur par défaut. | | `timeoutMs?` | `number` | `45000` | Délai d'expiration de la récupération par le service auxiliaire. Entier 1–2147483647. | diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index 673a2c75c7..a797a98c73 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -133,7 +133,7 @@ Codex は、タイトルやコミット メッセージなどのタスクに小 |フィールド |タイプ |デフォルト |意味 | | --- | --- | --- | --- | | `enabled?` | `boolean` |使用可能な場合はオン |マスタースイッチ。 | -| `backend?` | `"openai" \| "anthropic"` |自動 |明示的な勝利。それ以外の場合は使用可能な保存された Anthropic OAuth は `anthropic` を選択し、次に `openai` を選択します。 | +| `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` |明示指定が優先され、未指定時は常に `openai` です。`xai`、`gemini`、`exa` は明示的に設定した場合のみ有効になります。 | | `model?` | `string` |バックエンド依存 | OpenAI の場合は `gpt-5.6-luna`、Anthropic の場合は `claude-sonnet-5`。従来の明示的な `gpt-5.4-mini` は開始時に移行されます。 | | `reasoning?` | `string` | `low` |サイドカーの取り組み。 `minimal` は Web 検索で拒否されます。 | | `maxSearchesPerTurn?` | `number` | `3` |メインモデルのターンごとに許可される実際の検索。 | @@ -149,7 +149,7 @@ OpenAI バックエンドには、ChatGPT ログインと有効な ChatGPT `forw |フィールド |タイプ |デフォルト |意味 | | --- | --- | --- | --- | | `enabled?` | `boolean` |使用可能な場合はオン |マスターイメージと説明のスイッチ。 | -| `backend?` | `"openai" \| "anthropic"` |自動 | Web 検索と同じ、明示的優先、人間認証情報を意識した選択。 | +| `backend?` | `"openai" \| "anthropic"` |自動 | 明示的な値が優先されます。未設定の場合、使用可能な保存済み Anthropic OAuth 認証情報が優先され、それ以外は `openai` になります。 | | `model?` | `string` |バックエンド依存 | OpenAI の場合は `gpt-5.4-mini`、Anthropic の場合は `claude-sonnet-5`。 | | `reasoning?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"low"` | OpenAI Responses の推論負荷。Anthropic は無視します。 | | `maxDescriptionsPerTurn?` | `number` | `8` |新しい説明のキャッシュミスはメインターンごとに許可されます。 `0` は通話を無効にします。無効な値にはデフォルトが使用されます。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index ab23a6bcdc..3e967d4baa 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -133,7 +133,7 @@ Codex는 제목과 커밋 메시지 같은 작업에 작은 보조 모델을 사 | 필드 | 형식 | 기본값 | 의미 | | --- | --- | --- | --- | | `enabled?` | `boolean` | on when usable | 주 스위치입니다. | -| `backend?` | `"openai" \| "anthropic"` | auto | 명시값이 우선입니다. 그 외에는 사용 가능한 저장된 Anthropic OAuth가 있으면 `anthropic`을, 아니면 `openai`를 선택합니다. | +| `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | 명시값이 우선이며, 미설정 시 항상 `openai`입니다. `xai`, `gemini`, `exa`는 명시적으로 설정한 경우에만 활성화됩니다. | | `model?` | `string` | backend-dependent | OpenAI는 `gpt-5.6-luna`, Anthropic은 `claude-sonnet-5`입니다. 레거시로 명시된 `gpt-5.4-mini`는 시작 시 마이그레이션됩니다. | | `reasoning?` | `string` | `low` | 사이드카 노력 수준입니다. `minimal`은 web search와 함께 거부됩니다. | | `maxSearchesPerTurn?` | `number` | `3` | 메인 모델 턴당 허용되는 실제 검색 수입니다. | @@ -149,7 +149,7 @@ OpenAI 백엔드는 ChatGPT 로그인과 활성화된 ChatGPT `forward` provider | 필드 | 형식 | 기본값 | 의미 | | --- | --- | --- | --- | | `enabled?` | `boolean` | on when usable | 주 이미지 설명 스위치입니다. | -| `backend?` | `"openai" \| "anthropic"` | auto | web search와 같은, 명시값 우선 및 Anthropic 자격 증명 인식 선택 방식입니다. | +| `backend?` | `"openai" \| "anthropic"` | auto | 명시값이 우선하며, 미설정 시 사용 가능한 저장된 Anthropic OAuth 자격 증명을 우선하고 없으면 `openai`를 사용합니다. | | `model?` | `string` | backend-dependent | OpenAI는 `gpt-5.4-mini`, Anthropic은 `claude-sonnet-5`입니다. | | `reasoning?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"low"` | OpenAI Responses 추론 강도입니다. Anthropic은 무시합니다. | | `maxDescriptionsPerTurn?` | `number` | `8` | 메인 턴당 허용되는 새 설명 캐시 미스 수입니다. `0`이면 호출이 비활성화되며, 잘못된 값은 기본값을 사용합니다. | diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 6726bcfaf2..1859204856 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -205,8 +205,9 @@ Images API paths and response shape expected by Codex. | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `enabled?` | `boolean` | on when usable | Master switch. | -| `backend?` | `"openai" \| "anthropic"` | `openai` | Explicit wins; unset always resolves to `openai`. `anthropic` runs only when explicitly configured. | -| `model?` | `string` | backend-dependent | `gpt-5.6-luna` for OpenAI or `claude-sonnet-5` for Anthropic. Legacy explicit `gpt-5.4-mini` migrates on start. | +| `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | Explicit wins; unset always resolves to `openai`. `xai`, `gemini`, and `exa` are explicit-only and remain inert unless configured. | +| `model?` | `string` | backend-dependent | Sidecar model for model-backed search. Legacy explicit `gpt-5.4-mini` migrates on start. | +| `exaApiKey?` | `string` | none | Operator key for the `exa` backend. Write-only: management reads never return the stored value. | | `reasoning?` | `string` | `low` | Sidecar effort. `minimal` is rejected with web search. | | `maxSearchesPerTurn?` | `number` | `3` | Real searches allowed per main-model turn. | | `routedModelStallTimeoutMs?` | `number` | `200000` | Config-file-only routed-model raw-body inactivity deadline. Integer 1–2147483647; every non-empty chunk resets it. | @@ -216,7 +217,9 @@ The OpenAI backend requires a ChatGPT login and enabled ChatGPT `forward` provid routed replays inject main ChatGPT auth into the internal request. The Anthropic backend uses the active stored credential from an enabled Anthropic OAuth provider. An explicitly selected Anthropic backend with no usable account fails closed instead of falling back. The Anthropic executor uses its -native `web_search_20250305` tool. +native `web_search_20250305` tool. The `xai`, `gemini`, and `exa` lanes never activate from credential +discovery or fallback; the operator must select them explicitly. `exaApiKey` is accepted on writes +but omitted from management responses. Four clocks govern search: base `stallTimeoutSec`, `connectTimeoutMs`, routed-model inactivity, and hosted-search timeout. The effective bridge watchdog is the maximum plus 30 seconds. Routed stall is diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index b6816f5e84..1c1e649567 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -162,7 +162,7 @@ Codex использует маленькие helper-model'и для задач | Поле | Тип | По умолчанию | Значение | | --- | --- | --- | --- | | `enabled?` | `boolean` | on when usable | Главный переключатель. | -| `backend?` | `"openai" \| "anthropic"` | auto | Явный выбор выигрывает; иначе usable stored Anthropic OAuth выбирает `anthropic`, затем `openai`. | +| `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | Явный выбор имеет приоритет; без него всегда используется `openai`. `xai`, `gemini` и `exa` активируются только при явной настройке. | | `model?` | `string` | backend-dependent | `gpt-5.6-luna` для OpenAI или `claude-sonnet-5` для Anthropic. Старый явный `gpt-5.4-mini` мигрирует при старте. | | `reasoning?` | `string` | `low` | Effort sidecar'а. Значение `minimal` с web search отклоняется. | | `maxSearchesPerTurn?` | `number` | `3` | Число реальных поисков, разрешённых за один turn основной модели. | @@ -184,7 +184,7 @@ routed-model и hosted-search timeout. Эффективный watchdog мост | Поле | Тип | По умолчанию | Значение | | --- | --- | --- | --- | | `enabled?` | `boolean` | on when usable | Главный переключатель описания изображений. | -| `backend?` | `"openai" \| "anthropic"` | auto | Та же логика выбора explicit-first/Anthropic-credential-aware, что и у web search. | +| `backend?` | `"openai" \| "anthropic"` | auto | Явное значение имеет приоритет; если оно не задано, предпочтение отдаётся пригодным сохранённым учётным данным Anthropic OAuth, иначе используется `openai`. | | `model?` | `string` | backend-dependent | `gpt-5.4-mini` для OpenAI или `claude-sonnet-5` для Anthropic. | | `reasoning?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"low"` | Уровень рассуждений OpenAI Responses. Anthropic его игнорирует. | | `maxDescriptionsPerTurn?` | `number` | `8` | Максимум новых промахов description-cache за один main turn. `0` отключает вызовы; некорректные значения возвращают дефолт. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md index 3de2b40fa2..8181cc896c 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -232,7 +232,7 @@ Images API yollarını ve yanıt şeklini uygulamalıdır. | Alan | Tip | Varsayılan | Anlamı | | --- | --- | --- | --- | | `enabled?` | `boolean` | kullanılabilir olduğunda açık | Ana anahtar. | -| `backend?` | `"openai" \| "anthropic"` | auto | Açık olan kazanır; aksi takdirde kullanılabilir saklanan Anthropic OAuth `anthropic`'i, ardından `openai`'yi seçer. | +| `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | Açık seçim kazanır; ayarlanmadığında her zaman `openai` kullanılır. `xai`, `gemini` ve `exa` yalnızca açıkça yapılandırıldığında etkinleşir. | | `model?` | `string` | arka uca bağlı | OpenAI için `gpt-5.6-luna` veya Anthropic için `claude-sonnet-5`. Eski açık `gpt-5.4-mini` başlangıçta geçirilir. | | `reasoning?` | `string` | `low` | Sidecar çabası. `minimal` web araması ile reddedilir. | | `maxSearchesPerTurn?` | `number` | `3` | Ana model turu başına izin verilen gerçek aramalar. | @@ -257,7 +257,7 @@ hareketsizlik korumasıdır, toplam bir üretim süresi sınırı değildir. | Alan | Tip | Varsayılan | Anlamı | | --- | --- | --- | --- | | `enabled?` | `boolean` | kullanılabilir olduğunda açık | Ana görsel açıklama anahtarı. | -| `backend?` | `"openai" \| "anthropic"` | auto | Web araması ile aynı açık öncelikli, Anthropic kimlik bilgisine duyarlı seçim. | +| `backend?` | `"openai" \| "anthropic"` | auto | Açık değer önceliklidir; ayarlanmadığında kullanılabilir kayıtlı bir Anthropic OAuth kimlik bilgisi tercih edilir, aksi halde `openai` kullanılır. | | `model?` | `string` | arka uca bağlı | OpenAI için `gpt-5.4-mini` veya Anthropic için `claude-sonnet-5`. | | `maxDescriptionsPerTurn?` | `number` | `8` | Ana tur başına kabul edilen yeni açıklama önbellek ıskalamaları. `0` çağrıları devre dışı bırakır; geçersiz değerler varsayılanı kullanır. | | `timeoutMs?` | `number` | `45000` | Sidecar getirme zaman aşımı. Tamsayı 1–2147483647. | @@ -273,4 +273,3 @@ sınırı tüketmez. Uzak `https:` görselleri ve başarısız veya boş açıkl Anthropic OAuth sidecar'ları opencodex'in mevcut Claude Code OAuth parmak izini yeniden kullanır. Hedeflenen hesap ve iş yükünü kapsamlı bir şekilde test edin. - diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index 2d1d0773bb..4d3f41b15f 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -145,7 +145,7 @@ Codex 会为标题、提交信息等任务使用较小的辅助模型。启用 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `enabled?` | `boolean` | 在可用时启用 | 总开关。 | -| `backend?` | `"openai" \| "anthropic"` | auto | 显式优先;否则若可用的 Anthropic OAuth 存储凭据存在则选择 `anthropic`,否则选择 `openai`。 | +| `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | 显式选择优先;未设置时始终使用 `openai`。`xai`、`gemini` 和 `exa` 仅在显式配置后启用。 | | `model?` | `string` | 依后端而定 | OpenAI 使用 `gpt-5.6-luna`,Anthropic 使用 `claude-sonnet-5`。旧的显式 `gpt-5.4-mini` 会在启动时迁移。 | | `reasoning?` | `string` | `low` | 侧车努力级别。`minimal` 与 web search 不兼容,会被拒绝。 | | `maxSearchesPerTurn?` | `number` | `3` | 每个主模型轮次允许的实际搜索次数。 | @@ -163,7 +163,7 @@ routed 重放会把主 ChatGPT 认证注入内部请求。Anthropic 后端使用 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `enabled?` | `boolean` | 在可用时启用 | 图像描述总开关。 | -| `backend?` | `"openai" \| "anthropic"` | auto | 与 web search 相同的显式优先、感知 Anthropic 凭据的选择方式。 | +| `backend?` | `"openai" \| "anthropic"` | auto | 显式值优先;未设置时优先使用可用的已保存 Anthropic OAuth 凭据,否则使用 `openai`。 | | `model?` | `string` | 依后端而定 | OpenAI 使用 `gpt-5.4-mini`,Anthropic 使用 `claude-sonnet-5`。 | | `reasoning?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"low"` | OpenAI Responses 推理强度;Anthropic 会忽略该项。 | | `maxDescriptionsPerTurn?` | `number` | `8` | 每个主轮次允许的新增描述缓存未命中次数。`0` 会禁用调用;无效值会使用默认值。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index 8e4f6fd7d9..291e3ef160 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -167,7 +167,7 @@ Codex 使用小型 helper 模型處理如標題與 commit 訊息等任務。啟 | 欄位 | 型別 | 預設值 | 意義 | | --- | --- | --- | --- | | `enabled?` | `boolean` | 可用時開啟 | 主開關。 | -| `backend?` | `"openai" \| "anthropic"` | 自動 | 明確勝出;否則可用的已儲存 Anthropic OAuth 選擇 `anthropic`,然後 `openai`。 | +| `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | 明確選擇優先;未設定時一律使用 `openai`。`xai`、`gemini` 與 `exa` 只會在明確設定後啟用。 | | `model?` | `string` | 視 backend 而定 | OpenAI 為 `gpt-5.6-luna` 或 Anthropic 為 `claude-sonnet-5`。舊版明確 `gpt-5.4-mini` 在啟動時遷移。 | | `reasoning?` | `string` | `low` | Sidecar effort。`minimal` 在網頁搜尋時被拒絕。 | | `maxSearchesPerTurn?` | `number` | `3` | 每個主模型回合允許的實際搜尋。 | @@ -183,7 +183,7 @@ OpenAI backend 需要 ChatGPT 登入與啟用的 ChatGPT `forward` 供應商。C | 欄位 | 型別 | 預設值 | 意義 | | --- | --- | --- | --- | | `enabled?` | `boolean` | 可用時開啟 | 主圖片描述開關。 | -| `backend?` | `"openai" \| "anthropic"` | 自動 | 與網頁搜尋相同的明確優先、Anthropic 憑證感知選擇。 | +| `backend?` | `"openai" \| "anthropic"` | 自動 | 明確值優先;未設定時優先使用可用的已儲存 Anthropic OAuth 憑證,否則使用 `openai`。 | | `model?` | `string` | 視 backend 而定 | OpenAI 為 `gpt-5.4-mini` 或 Anthropic 為 `claude-sonnet-5`。 | | `maxDescriptionsPerTurn?` | `number` | `8` | 每個主回合允許的新描述快取未命中。`0` 停用呼叫;無效值使用預設。 | | `timeoutMs?` | `number` | `45000` | Sidecar 擷取逾時。整數 1–2147483647。 | diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index b85252b142..8156080c7f 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1376,6 +1376,30 @@ function stripUnsupportedHostedTools(body: unknown): unknown { return tools.length === body.tools.length ? body : { ...body, tools }; } +/** + * OpenAI hosted web_search config fields that a capability-classified Responses + * upstream may reject wholesale. xAI's /v1/responses 400s the entire request on + * `external_web_access` and `search_context_size` ("Argument not supported"), + * which killed every routed Grok turn whose client (Codex) attaches its + * default web_search tool config (probe 2026-08-21: both fields 400 + * individually; `user_location` and `filters` are accepted and kept). + * The caller decides whether to apply this compatibility transform from explicit + * provider capability metadata; an unclassified upstream keeps the fields. + */ +const OPENAI_ONLY_WEB_SEARCH_FIELDS = ["external_web_access", "search_context_size"] as const; +export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { + if (!isPlainObject(body) || !Array.isArray(body.tools)) return body; + let changed = false; + const tools = body.tools.map(t => { + if (!isPlainObject(t) || (t.type !== "web_search" && t.type !== "web_search_preview")) return t; + if (!OPENAI_ONLY_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(t, field))) return t; + const { external_web_access: _access, search_context_size: _size, ...rest } = t; + changed = true; + return rest; + }); + return changed ? { ...body, tools } : body; +} + /** Replace every `input_image` part under a routed-compaction body with a short marker. */ function stripInputImagesDeep(value: unknown): unknown { if (Array.isArray(value)) return value.map(stripInputImagesDeep); @@ -1571,6 +1595,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const rewritten = rewriteRoutedToolSearchForUpstream(outBody); outBody = rewritten.body; convertedRoutedToolSearchNames = rewritten.names; + // xAI rejects these OpenAI web_search extensions with HTTP 400. Keep them + // for OpenAI API-key traffic and unclassified gateways; only an explicit + // provider capability denial activates the compatibility transform. + if (provider.supportsOpenAiWebSearchToolFields === false) { + outBody = stripOpenAiOnlyWebSearchFields(outBody); + } } const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true }))))))); const finalBody = stripDisabledReasoningSummaries( diff --git a/src/cli/agent.ts b/src/cli/agent.ts index 507f49aa20..9cdef3df63 100644 --- a/src/cli/agent.ts +++ b/src/cli/agent.ts @@ -27,7 +27,7 @@ const USAGE = `Usage: ocx agent effort [--main ] [--subagent ] [--json] ocx agent subagents [model,model...] [--json] ocx agent fallback [model,model...] [--poll-ms <5000-600000>] [--json] - ocx agent sidecar [--list] [--model ] [--backend ] + ocx agent sidecar [--list] [--model ] [--backend ] [--reasoning ] [--max-descriptions ] [--json]`; function clearable(value: string | undefined): string | null | undefined { diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index aa6673444d..a654bf8cac 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -18,7 +18,7 @@ const CLAUDE_USAGE = `Usage: [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--inject-agents ] [--small-fast-model ] [--model-map ] - [--blocked-skills ] [--web-model ] [--web-backend ] + [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]`; const GROK_USAGE = `Usage: diff --git a/src/lib/redact.ts b/src/lib/redact.ts index 5561d13c0c..9f9bb4af44 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -1,6 +1,6 @@ export const REDACTED_SECRET = "[REDACTED]"; -const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn)$/i; +const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i; /** * Colon-labelled credential headers echoed back inside an error body @@ -33,7 +33,7 @@ const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set- // Every letter position also accepts \u0001, the placeholder the fold emits for // an unresolved HTML named reference: `authorⅈzation` is the label with one // character we cannot name, and that is still the label. -const CREDENTIAL_HEADER_LABEL_RAW = "x-api-key|x-goog-api-key|x-amz-security-token|api[_-]?key|apiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token"; +const CREDENTIAL_HEADER_LABEL_RAW = "x-api-key|x-goog-api-key|x-amz-security-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token"; const CREDENTIAL_HEADER_LABEL = CREDENTIAL_HEADER_LABEL_RAW .replace(/(?; /** @@ -1006,6 +1008,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ featured: true, oauthId: "xai", jawcodeBundle: "xai", + supportsOpenAiWebSearchToolFields: false, note: "Log in with your Grok account", // Parallel tool calls: officially supported and default-on per docs.x.ai function-calling // (verified 260709, devlog/_plan/260709_parallel_tool_calls). Streamed calls arrive whole diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 5896e8742f..53a35e8756 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1070,9 +1070,16 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const section = body[field]; if (section === undefined || section === null) continue; if (!isPlainObject(section)) return jsonResponse({ error: `${field} must be an object or null` }, 400); + // The widened union applies to the WEB-SEARCH override only (roadmap 060). + // Vision keeps its two-backend contract — accepting a wider id there would + // persist a backend the vision resolver reads as unset, silently activating + // a backend the operator never chose (review F1). + const allowedBackends = field === "webSearchSidecar" + ? ["openai", "anthropic", "xai", "gemini", "exa"] + : ["openai", "anthropic"]; if (section.backend !== undefined && section.backend !== null - && section.backend !== "openai" && section.backend !== "anthropic") { - return jsonResponse({ error: `${field}.backend must be openai, anthropic, or null` }, 400); + && !allowedBackends.includes(section.backend as string)) { + return jsonResponse({ error: `${field}.backend must be ${allowedBackends.join(", ")}, or null` }, 400); } if (section.model !== undefined && typeof section.model !== "string") { return jsonResponse({ error: `${field}.model must be a string` }, 400); @@ -1128,13 +1135,17 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise delete next[field]; continue; } - const requested = section as { backend?: "openai" | "anthropic" | null; model?: string }; - const override: NonNullable = { ...next[field] }; + // The per-field validation above guarantees vision only ever carries the two-member + // union; the cast is the loop's shared-shape compromise, not a wider write path. + const requested = section as { backend?: "openai" | "anthropic" | "xai" | "gemini" | "exa" | null; model?: string }; + const override = { ...next[field] } as NonNullable; if (requested.backend === null) delete override.backend; - else if (requested.backend !== undefined) override.backend = requested.backend; + else if (requested.backend !== undefined) override.backend = requested.backend as never; if (requested.model === "") delete override.model; else if (requested.model !== undefined) override.model = requested.model; - if (Object.keys(override).length > 0) next[field] = override; + // Indexed write across the field union collapses to an intersection; runtime + // validation above already guarantees the per-field shape. + if (Object.keys(override).length > 0) next[field] = override as never; else delete next[field]; } if (body.enabled !== undefined) { diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 81c5e5fd27..a15c482682 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -541,7 +541,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise; + /** + * Whether the Responses upstream accepts OpenAI's extended hosted web_search fields. + * Set false only for a provider whose native contract rejects them; absence preserves + * passthrough compatibility for OpenAI and unclassified gateways. + */ + supportsOpenAiWebSearchToolFields?: boolean; /** * Provider-local repair for Responses gateways whose lifecycle snapshots omit canonical * fields or closing events (#893). Disabled by default and applied only to client-facing diff --git a/src/web-search/index.ts b/src/web-search/index.ts index cfa1440b31..90e15c3e8e 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -92,21 +92,27 @@ export function findAnthropicSidecarProvider(config: OcxConfig): AnthropicSideca return { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider }; } +/** Every backend id the config union admits. New ids are explicit-only and inert until their executor ships. */ +export type WebSearchBackendId = "openai" | "anthropic" | "xai" | "gemini" | "exa"; + /** * Precedence: explicit config wins; unset defaults to "openai" (ChatGPT forward path). The * anthropic backend (web_search_20250305) is only used when explicitly configured — auto-selecting * it from credential availability caused the sidecar to send incompatible models (e.g. gpt-5.6-luna) * to the Anthropic API. + * The 2188 follow-up ids (xai/gemini/exa) resolve to themselves the same explicit-only way; their + * planWebSearch arms stay fail-closed until each executor layer lands. */ export function resolveSidecarBackend( - explicit: "openai" | "anthropic" | undefined, -): "openai" | "anthropic" { - return explicit === "anthropic" ? "anthropic" : "openai"; + explicit: WebSearchBackendId | undefined, +): WebSearchBackendId { + if (explicit === "anthropic" || explicit === "xai" || explicit === "gemini" || explicit === "exa") return explicit; + return "openai"; } export interface SidecarPlan { /** Which executor runs the search. Anthropic does not require a forward provider. */ - backend: "openai" | "anthropic"; + backend: WebSearchBackendId; /** Present for the openai backend (ChatGPT forward path); undefined for anthropic. */ forwardSidecar?: ResolvedOpenAiForwardSidecar; /** Present for the anthropic backend (stored-OAuth /v1/messages path); undefined for openai. */ @@ -161,6 +167,10 @@ export function planWebSearch( ? { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider } : undefined; const backend = resolveSidecarBackend(cfg.backend); + // Inert arms (roadmap 060): the union admits these ids so config can carry them, + // but each stays fail-closed — no plan, the request takes the normal routed path — + // until its executor layer replaces the arm with a credential-gated plan. + if (backend === "xai" || backend === "gemini" || backend === "exa") return undefined; const maxSearches = cfg.maxSearchesPerTurn ?? DEFAULT_MAX_SEARCHES; const stallTimeoutSec = webSearchStallTimeoutSec( config.stallTimeoutSec, diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 4c4ad772ac..50f2e40544 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -6,6 +6,7 @@ import type { AttemptRecoveryKind } from "../usage/log"; import { bridgeToResponsesSSE } from "../bridge"; import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor"; import { runAnthropicWebSearch } from "./anthropic-executor"; +import type { WebSearchBackendId } from "./index"; import { clearableDeadline } from "../lib/abort"; import { redactSecretString } from "../lib/redact"; import { readBoundedResponseBody } from "../lib/bounded-body"; @@ -250,8 +251,12 @@ export interface WebSearchLoopDeps { parsed: OcxParsedRequest; adapter: ProviderAdapter; incomingMeta: IncomingMeta; - /** Which executor runs searches. Defaults to "openai" so existing callers keep the ChatGPT path (audit F4). */ - backend?: "openai" | "anthropic"; + /** + * Which executor runs searches. Defaults to "openai" so existing callers keep the ChatGPT path + * (audit F4). The widened ids (xai/gemini/exa) cannot reach the loop yet: planWebSearch returns + * no plan for them (inert 060 arms), and the dispatch below only branches on "anthropic". + */ + backend?: WebSearchBackendId; /** Required for the openai backend; unused (and typically undefined) for the anthropic backend. */ forwardProvider?: OcxProviderConfig; /** Required for the anthropic backend: the stored-OAuth provider that runs web_search_20250305. */ diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index efd1c87dbb..7cd6514fea 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -895,11 +895,13 @@ surface is listed here so a maintainer can find the owner without grepping: ## Sidecars Web search and vision sidecars run only when the main request needs that capability and a usable -sidecar authority exists. Both have two possible backends, but they select differently: +sidecar authority exists. Vision has two possible backends; web search's config union additionally +admits `xai`, `gemini`, and `exa` (explicit-only, inert until each executor ships — an inert id +produces no plan and the request takes the normal routed path). Selection differs per sidecar: | Sidecar | Backend selection | Default model | Activation | | --- | --- | --- | --- | -| `web-search/` | Explicit configuration only: unset always resolves to the OpenAI forward path. Anthropic is never auto-selected from credential availability — doing so once sent OpenAI model ids to the Anthropic API. | `gpt-5.6-luna` (OpenAI), `claude-sonnet-5` (Anthropic) | Hosted `web_search` requested by a non-passthrough routed model. | +| `web-search/` | Explicit configuration only: unset always resolves to the OpenAI forward path. No backend — Anthropic or otherwise — is auto-selected from credential availability (doing so once sent OpenAI model ids to the Anthropic API). Explicit `xai`/`gemini`/`exa` fail closed until their executors land. | `gpt-5.6-luna` (OpenAI), `claude-sonnet-5` (Anthropic) | Hosted `web_search` requested by a non-passthrough routed model. | | `vision/` | Explicit configuration wins for both backends. Only an unset backend auto-selects: Anthropic when a usable Anthropic OAuth provider exists, otherwise the OpenAI forward authority. An explicitly selected backend whose authority is unavailable produces no plan rather than falling back. | `claude-sonnet-5` (Anthropic), `gpt-5.4-mini` (OpenAI) | Input contains images for a model listed in `noVisionModels`. | The asymmetry is in the unset case only: vision may describe an image with whichever model can see diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 688f112020..079057352d 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -118,7 +118,7 @@ this document owns is which module holds which area and what invariant that area | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | -| Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`. PUT accepts model and backend plus optional `webSearch.reasoning`, `vision.reasoning`, `vision.enabled`, `vision.maxDescriptionsPerTurn`, and `vision.timeoutMs`; the read and PUT-response payload reports model, backend, reasoning, enabled, the vision per-turn limit, and timeout. `timeoutMs` is validated against the runtime integer bounds in `src/vision/timeout-bounds.ts`. Credentials live in the provider and OAuth stores instead. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-5.4-mini` + `gpt-5.6-luna`), so no client hard-codes a helper slug that a Codex release can invalidate. | +| Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`. PUT accepts model and backend (web-search union: openai/anthropic/xai/gemini/exa; the last three are explicit-only and inert until their executors ship) plus optional `webSearch.exaApiKey` (write/clear only — never echoed by GET or the PUT response; redact.ts strips it from logs), `webSearch.reasoning`, `vision.reasoning`, `vision.enabled`, `vision.maxDescriptionsPerTurn`, and `vision.timeoutMs`; the read and PUT-response payload reports model, backend, reasoning, enabled, the vision per-turn limit, and timeout. `timeoutMs` is validated against the runtime integer bounds in `src/vision/timeout-bounds.ts`. Provider/OAuth credentials live in their stores; `exaApiKey` is the one sidecar-owned secret and follows the write-only contract above. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-5.4-mini` + `gpt-5.6-luna`), so no client hard-codes a helper slug that a Codex release can invalidate. | | Storage | `src/server/management/logs-usage-routes.ts` — `GET /api/storage`, `POST /api/storage/cleanup/preview` and `/api/storage/cleanup`, `GET /api/storage/trash`, `POST /api/storage/trash/restore`, and `GET/PUT /api/storage/cleanup-policy` plus `POST /api/storage/cleanup-policy/run`. `GET /api/storage/cleanup-policy/test-stream` and `GET /api/storage/trash/restore/test-stream` exist for progress-stream testing. Cleanup takes an explicit `mode`: `quarantine` moves to trash and is restorable, `permanent` is not. The caller must name the mode — there is no default that silently deletes. | | Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. | | Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`. Visibility writes trigger catalog sync through the owning server path. | diff --git a/tests/redact.test.ts b/tests/redact.test.ts index e3facd7d2d..a1d75a3219 100644 --- a/tests/redact.test.ts +++ b/tests/redact.test.ts @@ -276,6 +276,18 @@ describe("redactSecretString", () => { expect(multipart).not.toContain("dXNlcjpwYXNz"); }); + test("exaApiKey is masked in JSON, colon, and query framings", () => { + for (const input of [ + '{"exaApiKey":"exa-canary-1234567890"}', + "exaApiKey: exa-canary-1234567890", + "exaApiKey=exa-canary-1234567890&model=x", + ]) { + const redacted = redactSecretString(input); + expect(redacted).toContain(REDACTED_SECRET); + expect(redacted).not.toContain("exa-canary-1234567890"); + } + }); + test("XML credentials are covered by tag name, identifying attribute, and attribute value", () => { // A qualifying tag keeps only its NAME and masks to end of line. Using the // closing tag as the stopping point was the same early-termination mistake diff --git a/tests/responses-routed-web-search-fields.test.ts b/tests/responses-routed-web-search-fields.test.ts new file mode 100644 index 0000000000..7df6e24a63 --- /dev/null +++ b/tests/responses-routed-web-search-fields.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction, stripOpenAiOnlyWebSearchFields } from "../src/adapters/openai-responses"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import type { OcxProviderConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const createResponsesPassthroughAdapter = (...args: Parameters) => + withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +function buildWebSearchBody(provider: OcxProviderConfig): Record { + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "test-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "test-model", + input: "ping", + tools: [{ + type: "web_search", + external_web_access: true, + search_context_size: "medium", + user_location: { type: "approximate" }, + }], + }, + }, { headers: new Headers() }); + return JSON.parse(request.body) as Record; +} + +// #2188 follow-up: routed Responses upstreams (xAI api.x.ai) 400 the WHOLE request on +// OpenAI-only web_search config fields (probe 2026-08-21: external_web_access and +// search_context_size each 400 individually; user_location and filters are accepted). +describe("stripOpenAiOnlyWebSearchFields", () => { + test("removes the two fatal fields, keeps user_location/filters and other tools", () => { + const body = { model: "grok-4.6", tools: [ + { type: "web_search", external_web_access: true, search_context_size: "medium", user_location: { type: "approximate" }, filters: { allowed_domains: ["x.ai"] } }, + { type: "function", name: "f" }, + ] }; + const out = stripOpenAiOnlyWebSearchFields(body) as { tools: Array> }; + expect(out.tools[0]).toEqual({ type: "web_search", user_location: { type: "approximate" }, filters: { allowed_domains: ["x.ai"] } }); + expect(out.tools[1]).toEqual({ type: "function", name: "f" }); + }); + + test("web_search_preview covered; clean body returns the same reference", () => { + const preview = { model: "m", tools: [{ type: "web_search_preview", external_web_access: false }] }; + const out = stripOpenAiOnlyWebSearchFields(preview) as { tools: Array> }; + expect(out.tools[0]).toEqual({ type: "web_search_preview" }); + const clean = { model: "m", tools: [{ type: "web_search" }] }; + expect(stripOpenAiOnlyWebSearchFields(clean)).toBe(clean); + }); +}); + +describe("Responses buildRequest web_search capability", () => { + test("official OpenAI API-key traffic retains OpenAI web_search fields", () => { + const body = buildWebSearchBody({ + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authMode: "key", + apiKey: "test-openai-key", + }); + + expect(body.tools).toEqual([{ + type: "web_search", + external_web_access: true, + search_context_size: "medium", + user_location: { type: "approximate" }, + }]); + }); + + test("registry xAI traffic strips fields its Responses API rejects", () => { + const entry = getProviderRegistryEntry("xai"); + if (!entry) throw new Error("xAI registry entry missing"); + const provider = { ...providerConfigSeed(entry), adapter: "openai-responses" }; + enrichProviderFromRegistry("xai", provider); + + const body = buildWebSearchBody(provider); + expect(body.tools).toEqual([{ + type: "web_search", + user_location: { type: "approximate" }, + }]); + }); +}); diff --git a/tests/web-search-backend-union.test.ts b/tests/web-search-backend-union.test.ts new file mode 100644 index 0000000000..f65c083e35 --- /dev/null +++ b/tests/web-search-backend-union.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { parseRequest } from "../src/responses/parser"; +import { planWebSearch, resolveSidecarBackend, shouldResolveOpenAiWebSearchSidecar } from "../src/web-search"; +import { handleManagementAPI } from "../src/server/management-api"; +import { ManagementRequest as Request } from "./helpers/management-auth"; +import { redactSecrets } from "../src/lib/redact"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const routed: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://routed.test/v1", apiKey: "routed-key" }; +const forward: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://chatgpt.test/v1", authMode: "forward" }; + +function config(overrides: Partial = {}): OcxConfig { + return { port: 10100, defaultProvider: "routed", providers: { routed, openai: forward }, ...overrides }; +} + +function parsedWithWebSearch() { + return parseRequest({ model: "routed/model", input: "search", stream: true, tools: [{ type: "web_search" }] }); +} + +describe("widened backend union stays inert (roadmap 060)", () => { + test("resolveSidecarBackend: unset pin survives; new ids resolve to themselves", () => { + expect(resolveSidecarBackend(undefined)).toBe("openai"); + expect(resolveSidecarBackend("xai")).toBe("xai"); + expect(resolveSidecarBackend("gemini")).toBe("gemini"); + expect(resolveSidecarBackend("exa")).toBe("exa"); + }); + + test.each(["xai", "gemini", "exa"] as const)("planWebSearch %s -> no plan (fail-closed inert)", backend => { + const cfg = config({ webSearchSidecar: { backend } }); + expect(planWebSearch(cfg, parsedWithWebSearch(), false, routed, "model", undefined)).toBeUndefined(); + }); + + test.each(["xai", "gemini", "exa"] as const)("shouldResolveOpenAiWebSearchSidecar false for %s", backend => { + const cfg = config({ webSearchSidecar: { backend } }); + expect(shouldResolveOpenAiWebSearchSidecar(cfg, parsedWithWebSearch(), false)).toBe(false); + }); +}); + +async function putSidecar(cfg: OcxConfig, webSearch: Record): Promise { + const url = new URL("http://localhost/api/sidecar-settings"); + const response = await handleManagementAPI( + new Request(url, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ webSearch }) }), + url, cfg, + ); + if (!response) throw new Error("route did not handle PUT"); + return response; +} + +describe("management routes admit the union, reject strangers, and guard the exa key", () => { + test("backend xai accepted; zen rejected 400", async () => { + const cfg = config(); + expect((await putSidecar(cfg, { backend: "xai" })).status).toBe(200); + expect(cfg.webSearchSidecar?.backend).toBe("xai"); + const bad = await putSidecar(cfg, { backend: "zen" }); + expect(bad.status).toBe(400); + }); + + test("exaApiKey sets, clears with empty string, and never appears in GET or PUT bodies", async () => { + const cfg = config(); + const put = await putSidecar(cfg, { exaApiKey: "exa-canary-1234567890" }); + expect(put.status).toBe(200); + expect(cfg.webSearchSidecar?.exaApiKey).toBe("exa-canary-1234567890"); + expect(JSON.stringify(await put.json())).not.toContain("exa-canary"); + const url = new URL("http://localhost/api/sidecar-settings"); + const get = await handleManagementAPI(new Request(url), url, cfg); + expect(JSON.stringify(await get!.json())).not.toContain("exa-canary"); + const clear = await putSidecar(cfg, { exaApiKey: "" }); + expect(clear.status).toBe(200); + expect(cfg.webSearchSidecar?.exaApiKey).toBeUndefined(); + }); + + test("redactSecrets strips exaApiKey from structures", () => { + const redacted = redactSecrets({ webSearchSidecar: { exaApiKey: "exa-canary-1234567890", model: "m" } }) as Record; + expect(JSON.stringify(redacted)).not.toContain("exa-canary"); + expect(JSON.stringify(redacted)).toContain('"model":"m"'); + }); +}); + +async function putClaudeCode(cfg: OcxConfig, body: Record): Promise { + const url = new URL("http://localhost/api/claude-code"); + const response = await handleManagementAPI( + new Request(url, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }), + url, cfg, + ); + if (!response) throw new Error("route did not handle PUT"); + return response; +} + +describe("claude-code overrides: union widened for web-search ONLY (review F1)", () => { + test("webSearchSidecar.backend xai accepted", async () => { + const cfg = config(); + const response = await putClaudeCode(cfg, { webSearchSidecar: { backend: "xai" } }); + expect(response.status).toBe(200); + expect(cfg.claudeCode?.webSearchSidecar?.backend).toBe("xai"); + }); + + test("visionSidecar.backend xai rejected 400 (vision keeps the two-member contract)", async () => { + const cfg = config(); + const response = await putClaudeCode(cfg, { visionSidecar: { backend: "xai" } }); + expect(response.status).toBe(400); + expect(cfg.claudeCode?.visionSidecar).toBeUndefined(); + }); +});