diff --git a/docs-site/src/content/docs/fr/guides/sidecars.md b/docs-site/src/content/docs/fr/guides/sidecars.md index 1bd91c107c..c5b7a50b1e 100644 --- a/docs-site/src/content/docs/fr/guides/sidecars.md +++ b/docs-site/src/content/docs/fr/guides/sidecars.md @@ -5,13 +5,13 @@ description: Dotez les modèles routés d’une véritable recherche web et donn Tous les modèles routés ne proposent pas une **recherche web** hébergée ni une **entrée d’image** native. opencodex complète ces capacités au moyen de deux services auxiliaires. Chacun peut s’appuyer sur un fournisseur connecté à ChatGPT (`forward`) ou sur un -fournisseur Anthropic OAuth enregistré. Les erreurs des services auxiliaires sont converties en résultats d’outil limités ou en marqueurs d’image, +fournisseur Anthropic OAuth enregistré ; la recherche web peut aussi utiliser un OAuth Grok enregistré via le moteur `xai` explicite. Les erreurs des services auxiliaires sont converties en résultats d’outil limités ou en marqueurs d’image, au lieu de faire échouer l’intégralité du tour. :::note[Sélection automatique du moteur] -Une valeur `backend` explicite est prioritaire. Lorsqu'elle est omise, opencodex utilise `anthropic` si un fournisseur OAuth Anthropic actif -possède un compte actif qui n'est pas marqué `needsReauth` ; sinon, il utilise `openai`. Une sélection explicite de -`anthropic` sans ces identifiants échoue de manière sûre. `openai` exige à la fois une connexion ChatGPT et un +Une valeur `backend` explicite est prioritaire. Sans valeur, la recherche web utilise toujours `openai` ; Vision utilise +`anthropic` si un compte OAuth Anthropic utilisable existe, sinon `openai`. Une sélection explicite de +`anthropic` ou `xai` sans identifiants utilisables échoue sans repli. `openai` exige à la fois une connexion ChatGPT et un fournisseur `forward` actif. ::: @@ -24,7 +24,8 @@ Lorsque Codex demande un hébergement `web_search` pour un modèle routé sans p 2. Exécute le modèle routé dans une petite **boucle d'agent**. Lorsqu'il appelle `web_search`, opencodex utilise le moteur du service auxiliaire sélectionné : OpenAI exécute l'outil hébergé `web_search` avec `gpt-5.6-luna` par défaut ; Anthropic exécute `web_search_20250305` avec `claude-sonnet-5` par défaut. La réponse en streaming et - les citations deviennent le résultat d’un outil. + les citations deviennent le résultat d’un outil. xAI exécute `web_search` avec `grok-4.6` par défaut et ajoute + `x_search` à la même requête lorsque `xSearch.enabled` vaut true. 3. **Répète la boucle** jusqu'à ce que le modèle réponde ou que le nombre total de recherches réelles atteigne `maxSearchesPerTurn` (par défaut 3), supprime ensuite l'outil de recherche et force une réponse finale. De vrais outils clients tels que `apply_patch` ou le shell mettent fin au tour afin que ces appels parviennent à Codex. 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 fa9400d540..de7256b2b9 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -209,8 +209,10 @@ 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" \| "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. | +| `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | Une valeur explicite est prioritaire ; l'absence de valeur sélectionne toujours `openai`. `anthropic` et `xai` ne s'exécutent que s'ils sont configurés explicitement ; `gemini` et `exa` restent réservés jusqu'à la livraison de leur executor. | +| `model?` | `string` | dépendant du backend | `gpt-5.6-luna` pour OpenAI, `claude-sonnet-5` pour Anthropic ou `grok-4.6` pour xAI. L'héritage explicite `gpt-5.4-mini` migre au démarrage. | +| `exaApiKey?` | `string` | aucun | Clé opérateur pour le backend `exa`. Écriture seule : les lectures de gestion ne renvoient jamais la valeur stockée. | +| `xSearch?` | `object` | omis | Activation facultative de `x_search` hébergé, propre à xAI : `enabled`, tableaux mutuellement exclusifs `allowedXHandles` / `excludedXHandles` (20 au maximum), et dates ISO `fromDate` / `toDate` (`YYYY-MM-DD`). | | `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. | | `routedModelStallTimeoutMs?` | `number` | `200000` | Date limite d'inactivité du corps brut du modèle routé uniquement pour les fichiers de configuration. Entier 1–2147483647 ; chaque morceau non vide le réinitialise. | @@ -220,7 +222,11 @@ Le moteur OpenAI nécessite une connexion à ChatGPT et un fournisseur ChatGPT ` entrantes depuis Claude injectent l'authentification ChatGPT principale dans la requête interne. Le moteur Anthropic utilise les identifiants actifs stockés auprès d'un fournisseur Anthropic OAuth activé. Si le moteur Anthropic est sélectionné explicitement mais qu'aucun compte n'est utilisable, l'opération échoue de manière sûre au lieu de se rabattre sur un autre moteur. L'exécuteur Anthropic utilise son -outil `web_search_20250305` natif. +outil `web_search_20250305` natif. Le backend xAI nécessite un compte OAuth Grok stocké et utilisable, emploie +`web_search` hébergé et ajoute `x_search` hébergé lorsque `xSearch.enabled` vaut true. Une entrée de gestion +`xSearch` mal formée renvoie `400` ; un bloc persistant mal formé échoue de manière sûre pendant la planification. +Les voies `gemini` et `exa` ne s'activent jamais par découverte d'identifiants ni par fallback ; l'opérateur doit +les sélectionner explicitement. `exaApiKey` est accepté en écriture mais omis des réponses de gestion. Quatre horloges régissent la recherche : base `stallTimeoutSec`, `connectTimeoutMs`, inactivité du modèle routé et délai d'expiration de la recherche hébergée. Le chien de garde efficace du pont est le maximum plus 30 secondes. Le décrochage routé est diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index 97dae40734..4cf009abc9 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -4,16 +4,18 @@ description: Give routed models real web search and text-only models image under --- Routed models do not all expose hosted **web search** or native **image input**. opencodex backfills -those capabilities with two sidecars. Each can run through a ChatGPT-login (`forward`) provider or a -stored Anthropic OAuth provider. Sidecar errors become bounded tool results or image markers instead -of failing the whole turn. +those capabilities with two sidecars. Both support a ChatGPT-login (`forward`) provider or stored +Anthropic OAuth provider; web search can additionally use stored Grok OAuth through the explicit +`xai` backend. Sidecar errors become bounded tool results or image markers instead of failing the +whole turn. :::note[Automatic backend selection] Explicit `backend` config wins. The two sidecars default differently when `backend` is unset: **web search** always defaults to `openai` — `anthropic` runs only when explicitly configured. **Vision** defaults to `anthropic` if an enabled Anthropic OAuth provider has an active account not marked `needsReauth`, otherwise `openai`. Explicit `anthropic` without that credential fails -closed. `openai` requires both ChatGPT login auth and an enabled `forward` provider. +closed. Explicit `xai` requires a usable stored Grok OAuth account and does not fall back. `openai` +requires both ChatGPT login auth and an enabled `forward` provider. ::: ## Web-search sidecar @@ -25,7 +27,8 @@ When Codex requests hosted `web_search` for a non-passthrough routed model, open 2. Runs the routed model in a small **agentic loop**. When it calls `web_search`, opencodex uses the selected sidecar backend: OpenAI runs hosted `web_search` with `gpt-5.6-luna` by default; Anthropic runs `web_search_20250305` with `claude-sonnet-5` by default. The streamed answer and - citations become a tool result. + citations become a tool result. xAI runs Grok hosted `web_search` with `grok-4.6` by default and, + when enabled, adds hosted `x_search` to the same request. 3. **Loops** until the model answers or the total real-query budget reaches `maxSearchesPerTurn` (default 3), then removes the search tool and forces a final answer. Real client tools such as `apply_patch` or shell finalize the turn so those calls reach Codex. @@ -70,6 +73,28 @@ relevant images in words and include their source URLs. } ``` +The explicit xAI backend uses the stored credential created by `ocx login xai`. Its optional +`xSearch` block enables X search and may restrict it to one handle list and an ISO date range: + +```json +{ + "webSearchSidecar": { + "backend": "xai", + "model": "grok-4.6", + "xSearch": { + "enabled": true, + "allowedXHandles": ["xai"], + "fromDate": "2026-08-01", + "toDate": "2026-08-21" + } + } +} +``` + +`allowedXHandles` and `excludedXHandles` are mutually exclusive and each accepts at most 20 +strings. Dates use `YYYY-MM-DD`. Malformed management writes return `400`; persisted malformed +blocks fail closed at planning time instead of silently broadening the search. + `minimal` reasoning is not used because the hosted backend rejects tools at that effort. A failed search is returned to the routed model as a bounded error result, allowing it to answer from the context it already has. diff --git a/docs-site/src/content/docs/ja/guides/sidecars.md b/docs-site/src/content/docs/ja/guides/sidecars.md index b30a8ea35e..7a44113a5d 100644 --- a/docs-site/src/content/docs/ja/guides/sidecars.md +++ b/docs-site/src/content/docs/ja/guides/sidecars.md @@ -5,13 +5,13 @@ description: ネイティブ ChatGPT サイドカー経由でルーティング ルーティングモデルごとにホスト型**ウェブ検索**やネイティブ**画像入力**のサポート範囲が異なります。opencodex は ChatGPT ログイン(`forward`)プロバイダーまたは保存された Anthropic OAuth プロバイダーを使う 2 つの -サイドカーで不足機能を補います。サイドカーエラーはターン全体を失敗させず、長さ制限付きのツール +サイドカーで不足機能を補い、ウェブ検索は明示的な `xai` バックエンドで保存済み Grok OAuth も利用できます。サイドカーエラーはターン全体を失敗させず、長さ制限付きのツール 結果や画像案内文に差し替わります。 :::note[バックエンド自動選択] -`backend` を明示するとその値が優先します。省略するとアクティブアカウントが `needsReauth` 状態でない Anthropic -OAuth プロバイダーがあるとき `anthropic`、ないとき `openai` を使います。使える認証情報なしに -`anthropic` を明示すると失敗後停止します。`openai` は ChatGPT ログインと有効化された `forward` +`backend` を明示するとその値が優先します。ウェブ検索は省略時に常に `openai`、Vision は利用可能な +Anthropic OAuth アカウントがあれば `anthropic`、なければ `openai` を使います。利用可能な認証情報なしに +`anthropic` または `xai` を明示するとフォールバックせず失敗します。`openai` は ChatGPT ログインと有効化された `forward` プロバイダーが両方必要です。 ::: @@ -24,8 +24,9 @@ Codex がパススルーでないルーティングモデルにホスト型 `web 公開します。元のホスト型ツールのオプションはサイドカー呼び出しにそのまま使います。 2. ルーティングモデルを小さな**エージェントループ**で実行します。モデルが `web_search` を呼ぶと選んだ バックエンドを使います。OpenAI はデフォルト `gpt-5.6-luna` でホスト型 `web_search` を実行し、 - Anthropic はデフォルト `claude-sonnet-5` で `web_search_20250305` を実行します。ストリーミング回答と - 引用をパースした結果をツール結果として返します。 + Anthropic はデフォルト `claude-sonnet-5` で `web_search_20250305` を実行します。xAI はデフォルト + `grok-4.6` で hosted `web_search` を実行し、`xSearch.enabled` が true の場合は同じリクエストに + `x_search` を追加します。ストリーミング回答と引用をパースした結果をツール結果として返します。 3. モデルが答えるか実際の検索クエリの総数が `maxSearchesPerTurn`(デフォルト 3)に達するまで **反復**します。限度に達すると検索ツールを削除し最終回答を強制します。`apply_patch` や shell のような実際のクライアントツールが出たらターンを終了し該当呼び出しが Codex に渡るようにします。 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 a797a98c73..cacde58f65 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -133,14 +133,16 @@ Codex は、タイトルやコミット メッセージなどのタスクに小 |フィールド |タイプ |デフォルト |意味 | | --- | --- | --- | --- | | `enabled?` | `boolean` |使用可能な場合はオン |マスタースイッチ。 | -| `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` は開始時に移行されます。 | +| `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | 明示設定が優先され、未設定なら常に `openai` です。`anthropic` と `xai` は明示設定時のみ実行され、`gemini` と `exa` は executor が提供されるまで予約値です。 | +| `model?` | `string` |バックエンド依存 | OpenAI は `gpt-5.6-luna`、Anthropic は `claude-sonnet-5`、xAI は `grok-4.6`。従来の明示的な `gpt-5.4-mini` は開始時に移行されます。 | +| `exaApiKey?` | `string` | なし | `exa` バックエンドのオペレーターキー。書き込み専用で、管理 API の読み取りでは保存値を返しません。 | +| `xSearch?` | `object` | 省略 | xAI 専用の hosted `x_search` opt-in。`enabled`、相互排他的な `allowedXHandles` / `excludedXHandles` 配列(最大 20 件)、ISO の `fromDate` / `toDate`(`YYYY-MM-DD`)を指定します。 | | `reasoning?` | `string` | `low` |サイドカーの取り組み。 `minimal` は Web 検索で拒否されます。 | | `maxSearchesPerTurn?` | `number` | `3` |メインモデルのターンごとに許可される実際の検索。 | | `routedModelStallTimeoutMs?` | `number` | `200000` |設定ファイルのみのルーテッド モデルの raw ボディの非アクティブ期限。整数 1 ~ 2147483647。空でないすべてのチャンクがリセットされます。 | | `timeoutMs?` | `number` | `60000` | 1 つのホストされた検索の期限。 | -OpenAI バックエンドには、ChatGPT ログインと有効な ChatGPT `forward` プロバイダーが必要です。クロードインバウンドのルーティングされたリプレイは、メインの ChatGPT 認証を内部リクエストに挿入します。 Anthropic バックエンドは、有効な Anthropic OAuth プロバイダーからのアクティブに保存された資格情報を使用します。使用可能なアカウントがない、明示的に選択された Anthropic バックエンドは、フォールバックせずに失敗して閉じられます。 Anthropic executor は、ネイティブの `web_search_20250305` ツールを使用します。 +OpenAI バックエンドには、ChatGPT ログインと有効な ChatGPT `forward` プロバイダーが必要です。クロードインバウンドのルーティングされたリプレイは、メインの ChatGPT 認証を内部リクエストに挿入します。 Anthropic バックエンドは、有効な Anthropic OAuth プロバイダーからのアクティブに保存された資格情報を使用します。使用可能なアカウントがない、明示的に選択された Anthropic バックエンドは、フォールバックせずに失敗して閉じられます。 Anthropic executor は、ネイティブの `web_search_20250305` ツールを使用します。xAI バックエンドには使用可能な保存済み Grok OAuth アカウントが必要で、hosted `web_search` を使用し、`xSearch.enabled` が true の場合は hosted `x_search` を追加します。不正な `xSearch` 管理入力は `400` を返し、不正な永続化ブロックは計画時に fail closed します。`gemini` と `exa` は資格情報の検出やフォールバックからは決して有効にならず、オペレーターが明示的に選択する必要があります。`exaApiKey` は書き込み時に受け付けますが、管理レスポンスからは省略されます。 検索は 4 つのクロック (ベース `stallTimeoutSec`、`connectTimeoutMs`、ルーテッド モデルの非アクティビティ、ホスト型検索のタイムアウト) によって制御されます。有効なブリッジ ウォッチドッグは、最大プラス 30 秒です。ルート ストールは非アクティブ ガードであり、総生成期限ではありません。 diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index df1fdb841f..e75e0521e5 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -5,13 +5,14 @@ description: 네이티브 ChatGPT 사이드카를 통해 라우팅 모델에 실 라우팅 모델마다 호스팅 **웹 검색**이나 네이티브 **이미지 입력** 지원 범위가 다릅니다. opencodex는 ChatGPT 로그인(`forward`) 프로바이더나 저장된 Anthropic OAuth 프로바이더를 사용하는 두 -사이드카로 부족한 기능을 보완합니다. 사이드카 오류는 턴 전체를 실패시키지 않고 길이가 제한된 도구 +사이드카로 부족한 기능을 보완하며, 웹 검색은 명시적인 `xai` 백엔드로 저장된 Grok OAuth도 사용할 수 +있습니다. 사이드카 오류는 턴 전체를 실패시키지 않고 길이가 제한된 도구 결과나 이미지 안내문으로 바뀝니다. :::note[백엔드 자동 선택] -`backend`를 명시하면 그 값이 우선합니다. 생략하면 활성 계정이 `needsReauth` 상태가 아닌 Anthropic -OAuth 프로바이더가 있을 때 `anthropic`, 없을 때 `openai`를 사용합니다. 쓸 수 있는 자격 증명 없이 -`anthropic`을 명시하면 실패 후 중단합니다. `openai`는 ChatGPT 로그인과 활성화된 `forward` +`backend`를 명시하면 그 값이 우선합니다. 웹 검색은 생략 시 항상 `openai`를 사용하고, 비전은 사용 +가능한 Anthropic OAuth 계정이 있으면 `anthropic`, 없으면 `openai`를 사용합니다. 쓸 수 있는 자격 +증명 없이 `anthropic` 또는 `xai`를 명시하면 폴백 없이 실패합니다. `openai`는 ChatGPT 로그인과 활성화된 `forward` 프로바이더가 모두 필요합니다. ::: @@ -24,8 +25,9 @@ Codex가 패스스루가 아닌 라우팅 모델에 호스팅 `web_search`를 노출합니다. 원래 호스팅 도구의 옵션은 사이드카 호출에 그대로 사용합니다. 2. 라우팅 모델을 작은 **에이전트 루프**에서 실행합니다. 모델이 `web_search`를 호출하면 선택한 백엔드를 사용합니다. OpenAI는 기본 `gpt-5.6-luna`로 호스팅 `web_search`를 실행하고, - Anthropic은 기본 `claude-sonnet-5`로 `web_search_20250305`를 실행합니다. 스트리밍 답변과 - 인용을 파싱한 결과는 도구 결과로 돌려줍니다. + Anthropic은 기본 `claude-sonnet-5`로 `web_search_20250305`를 실행합니다. xAI는 기본 + `grok-4.6`으로 호스팅 `web_search`를 실행하고, `xSearch.enabled`가 true이면 같은 요청에 + `x_search`를 추가합니다. 스트리밍 답변과 인용을 파싱한 결과는 도구 결과로 돌려줍니다. 3. 모델이 답하거나 실제 검색 쿼리의 총합이 `maxSearchesPerTurn`(기본값 3)에 도달할 때까지 **반복**합니다. 한도에 닿으면 검색 도구를 제거하고 최종 답변을 강제합니다. `apply_patch`나 shell 같은 실제 클라이언트 도구가 나오면 턴을 끝내 해당 호출이 Codex에 전달되게 합니다. 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 3e967d4baa..fe3526537f 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -133,14 +133,16 @@ Codex는 제목과 커밋 메시지 같은 작업에 작은 보조 모델을 사 | 필드 | 형식 | 기본값 | 의미 | | --- | --- | --- | --- | | `enabled?` | `boolean` | on when usable | 주 스위치입니다. | -| `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`는 시작 시 마이그레이션됩니다. | +| `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | 명시값이 우선입니다. 생략하면 항상 `openai`입니다. `anthropic`과 `xai`는 명시적으로 설정할 때만 실행되며, `gemini`와 `exa`는 executor가 제공될 때까지 예약 상태입니다. | +| `model?` | `string` | backend-dependent | OpenAI는 `gpt-5.6-luna`, Anthropic은 `claude-sonnet-5`, xAI는 `grok-4.6`입니다. 레거시로 명시된 `gpt-5.4-mini`는 시작 시 마이그레이션됩니다. | +| `exaApiKey?` | `string` | 없음 | `exa` 백엔드용 운영자 키입니다. 쓰기 전용이며 관리 API 조회에서는 저장된 값을 반환하지 않습니다. | +| `xSearch?` | `object` | 생략 | xAI 전용 `x_search` opt-in입니다. `enabled`, 서로 배타적인 `allowedXHandles` / `excludedXHandles` 배열(최대 20개), ISO `fromDate` / `toDate`(`YYYY-MM-DD`)를 지원합니다. | | `reasoning?` | `string` | `low` | 사이드카 노력 수준입니다. `minimal`은 web search와 함께 거부됩니다. | | `maxSearchesPerTurn?` | `number` | `3` | 메인 모델 턴당 허용되는 실제 검색 수입니다. | | `routedModelStallTimeoutMs?` | `number` | `200000` | 설정 파일 전용 routed-model 원시 본문 비활성 기한입니다. 정수 1–2147483647이며, 비어 있지 않은 모든 청크가 이를 다시 시작합니다. | | `timeoutMs?` | `number` | `60000` | 한 번의 hosted search에 대한 기한입니다. | -OpenAI 백엔드는 ChatGPT 로그인과 활성화된 ChatGPT `forward` provider를 요구합니다. Claude-inbound routed replay는 메인 ChatGPT 인증을 내부 요청에 주입합니다. Anthropic 백엔드는 활성화된 Anthropic OAuth provider에서 현재 저장된 자격 증명을 사용합니다. 명시적으로 선택한 Anthropic 백엔드에 사용할 수 있는 계정이 없으면 폴백하지 않고 닫힌 상태로 실패합니다. Anthropic 실행기는 자체 `web_search_20250305` 도구를 사용합니다. +OpenAI 백엔드는 ChatGPT 로그인과 활성화된 ChatGPT `forward` provider를 요구합니다. Claude-inbound routed replay는 메인 ChatGPT 인증을 내부 요청에 주입합니다. Anthropic 백엔드는 활성화된 Anthropic OAuth provider에서 현재 저장된 자격 증명을 사용합니다. 명시적으로 선택한 Anthropic 백엔드에 사용할 수 있는 계정이 없으면 폴백하지 않고 닫힌 상태로 실패합니다. Anthropic 실행기는 자체 `web_search_20250305` 도구를 사용합니다. xAI 백엔드는 사용 가능한 저장된 Grok OAuth 계정을 요구하고 hosted `web_search`를 사용하며, `xSearch.enabled`가 true이면 hosted `x_search`를 추가합니다. 잘못된 `xSearch` 관리 입력은 `400`을 반환하고, 잘못 저장된 블록은 계획 단계에서 닫힌 상태로 실패합니다. `gemini`와 `exa`는 자격 증명 탐색이나 폴백으로 절대 활성화되지 않으며 운영자가 명시적으로 선택해야 합니다. `exaApiKey`는 쓰기에서 허용되지만 관리 응답에서는 생략됩니다. 검색에는 네 가지 시계가 작동합니다: 기본 `stallTimeoutSec`, `connectTimeoutMs`, routed-model 비활성 시간, 그리고 hosted-search 제한 시간입니다. 실제 bridge watchdog은 이들 중 최댓값에 30초를 더한 값입니다. Routed stall은 비활성 가드이지, 전체 생성 기한이 아닙니다. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 1859204856..5ddf5588bc 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -205,9 +205,10 @@ Images API paths and response shape expected by Codex. | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `enabled?` | `boolean` | on when usable | Master switch. | -| `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. | +| `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | Explicit wins; unset always resolves to `openai`. `anthropic` and `xai` run only when explicitly configured; `gemini` and `exa` remain reserved until their executors ship. | +| `model?` | `string` | backend-dependent | `gpt-5.6-luna` for OpenAI, `claude-sonnet-5` for Anthropic, or `grok-4.6` for xAI. 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. | +| `xSearch?` | `object` | omitted | xAI-only opt-in for hosted `x_search`: `enabled`, mutually exclusive `allowedXHandles` / `excludedXHandles` arrays (maximum 20), and ISO `fromDate` / `toDate` (`YYYY-MM-DD`). | | `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. | @@ -217,9 +218,11 @@ 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. 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. +native `web_search_20250305` tool. The xAI backend requires a usable stored Grok OAuth account, uses +hosted `web_search`, and adds hosted `x_search` when `xSearch.enabled` is true. Malformed `xSearch` +management input returns `400`; a malformed persisted block fails closed during planning. The +`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/guides/sidecars.md b/docs-site/src/content/docs/ru/guides/sidecars.md index ae2e2479b2..fed573bd8e 100644 --- a/docs-site/src/content/docs/ru/guides/sidecars.md +++ b/docs-site/src/content/docs/ru/guides/sidecars.md @@ -5,15 +5,16 @@ description: Настоящий веб-поиск для маршрутизир Не все маршрутизируемые модели предоставляют hosted **веб-поиск** или нативный **ввод изображений**. opencodex восполняет эти возможности двумя сайдкарами. Каждый может работать через -провайдера входа ChatGPT (`forward`) или через сохранённого OAuth-провайдера Anthropic. Ошибки +провайдера входа ChatGPT (`forward`) или через сохранённого OAuth-провайдера Anthropic; веб-поиск +также может использовать сохранённый Grok OAuth через явно выбранный бэкенд `xai`. Ошибки сайдкара превращаются в ограниченные по размеру результаты инструментов или маркеры изображений, а не приводят к сбою всего хода. :::note[Автоматический выбор бэкенда] -Явно заданный `backend` имеет приоритет. Если он не задан, opencodex использует `anthropic`, когда -у включённого OAuth-провайдера Anthropic есть активный аккаунт без пометки `needsReauth`; иначе -используется `openai`. Явный `anthropic` без таких учётных данных завершается отказом (fail -closed). Для `openai` нужны одновременно аутентификация через вход ChatGPT и включённый провайдер +Явно заданный `backend` имеет приоритет. Веб-поиск без него всегда использует `openai`; Vision +использует `anthropic` при наличии пригодного OAuth-аккаунта Anthropic, иначе `openai`. Явный +`anthropic` или `xai` без пригодных учётных данных завершается отказом без fallback. Для `openai` +нужны одновременно аутентификация через вход ChatGPT и включённый провайдер `forward`. ::: @@ -28,7 +29,8 @@ opencodex: 2. Запускает маршрутизируемую модель в небольшом **агентном цикле**. Когда она вызывает `web_search`, opencodex использует выбранный бэкенд сайдкара: OpenAI выполняет hosted `web_search` по умолчанию с `gpt-5.6-luna`; Anthropic выполняет `web_search_20250305` по - умолчанию с `claude-sonnet-5`. Потоковый ответ и цитаты становятся результатом инструмента. + умолчанию с `claude-sonnet-5`. xAI выполняет hosted `web_search` по умолчанию с `grok-4.6` и + добавляет `x_search` в тот же запрос, когда `xSearch.enabled` равно true. Потоковый ответ и цитаты становятся результатом инструмента. 3. **Повторяет цикл**, пока модель не ответит или суммарный бюджет реальных запросов не достигнет `maxSearchesPerTurn` (по умолчанию 3), после чего убирает инструмент поиска и принуждает к финальному ответу. Настоящие клиентские инструменты вроде `apply_patch` или shell завершают 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 1c1e649567..64d7ee8e3d 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -162,8 +162,10 @@ Codex использует маленькие helper-model'и для задач | Поле | Тип | По умолчанию | Значение | | --- | --- | --- | --- | | `enabled?` | `boolean` | on when usable | Главный переключатель. | -| `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` мигрирует при старте. | +| `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | Явный выбор выигрывает; отсутствие значения всегда означает `openai`. `anthropic` и `xai` запускаются только при явной настройке; `gemini` и `exa` зарезервированы до появления executor. | +| `model?` | `string` | backend-dependent | `gpt-5.6-luna` для OpenAI, `claude-sonnet-5` для Anthropic или `grok-4.6` для xAI. Старый явный `gpt-5.4-mini` мигрирует при старте. | +| `exaApiKey?` | `string` | отсутствует | Ключ оператора для backend `exa`. Только для записи: management-read никогда не возвращает сохранённое значение. | +| `xSearch?` | `object` | отсутствует | Опциональный hosted `x_search` только для xAI: `enabled`, взаимоисключающие массивы `allowedXHandles` / `excludedXHandles` (не более 20) и ISO-даты `fromDate` / `toDate` (`YYYY-MM-DD`). | | `reasoning?` | `string` | `low` | Effort sidecar'а. Значение `minimal` с web search отклоняется. | | `maxSearchesPerTurn?` | `number` | `3` | Число реальных поисков, разрешённых за один turn основной модели. | | `routedModelStallTimeoutMs?` | `number` | `200000` | Config-file-only дедлайн бездействия raw-body у routed-model. Целое 1–2147483647; каждый непустой chunk сбрасывает таймер. | @@ -173,7 +175,12 @@ Backend OpenAI требует логина в ChatGPT и включённого с входом от Claude внедряет auth основного ChatGPT во внутренний запрос. Anthropic-backend использует активный stored credential из включённого Anthropic OAuth-провайдера. Явно выбранный Anthropic-backend без рабочего аккаунта закрывается с ошибкой и не откатывается на другой backend. -Исполнитель Anthropic использует нативный tool `web_search_20250305`. +Исполнитель Anthropic использует нативный tool `web_search_20250305`. Backend xAI требует рабочего +сохранённого аккаунта Grok OAuth, использует hosted `web_search` и добавляет hosted `x_search`, когда +`xSearch.enabled` равно true. Некорректный management-input `xSearch` возвращает `400`, а некорректный +сохранённый блок закрывается с ошибкой при планировании. Линии `gemini` и `exa` никогда не активируются +через обнаружение credentials или fallback; оператор должен выбрать их явно. `exaApiKey` принимается +при записи, но не включается в management-response. Поиск ограничивают четыре clock'а: базовый `stallTimeoutSec`, `connectTimeoutMs`, inactivity для routed-model и hosted-search timeout. Эффективный watchdog моста равен максимуму этих значений плюс diff --git a/docs-site/src/content/docs/tr/guides/sidecars.md b/docs-site/src/content/docs/tr/guides/sidecars.md index 4c8fdfe733..1057ab70a7 100644 --- a/docs-site/src/content/docs/tr/guides/sidecars.md +++ b/docs-site/src/content/docs/tr/guides/sidecars.md @@ -6,14 +6,13 @@ description: Yerel ChatGPT sidecar'ları aracılığıyla yönlendirilen modelle Yönlendirilen modellerin tümü barındırılan **web araması** veya yerel **görsel girişi** sunmaz. opencodex bu yetenekleri iki sidecar ile doldurur. Her biri bir ChatGPT girişi (`forward`) sağlayıcısı veya saklanan bir Anthropic OAuth -sağlayıcısı aracılığıyla çalışabilir. Sidecar hataları tüm turu başarısız kılmak +sağlayıcısı aracılığıyla çalışabilir; web araması açık `xai` arka ucuyla saklanan Grok OAuth'ı da kullanabilir. Sidecar hataları tüm turu başarısız kılmak yerine sınırlı araç sonuçları veya görsel işaretçileri haline gelir. :::note[Otomatik arka uç seçimi] -Açık `backend` yapılandırması kazanır. Ayarlanmadığında opencodex, -etkinleştirilmiş bir Anthropic OAuth sağlayıcısının `needsReauth` olarak -işaretlenmemiş etkin bir hesabı varsa `anthropic`'i kullanır; aksi takdirde -`openai`'yi kullanır. Bu kimlik bilgisi olmadan açık `anthropic` kapalı olarak +Açık `backend` yapılandırması kazanır. Web araması ayarlanmadığında her zaman `openai` kullanır; +Vision kullanılabilir bir Anthropic OAuth hesabı varsa `anthropic`, yoksa `openai` kullanır. +Kullanılabilir kimlik bilgisi olmadan açık `anthropic` veya `xai` geri dönüş yapmadan başarısız olur. `openai`, hem ChatGPT girişi kimlik doğrulamasını hem de etkinleştirilmiş bir `forward` sağlayıcısını gerektirir. ::: @@ -30,8 +29,9 @@ Codex, doğrudan geçiş olmayan bir yönlendirilmiş model için barındırıla `web_search`'i çağırdığında opencodex seçilen sidecar arka ucunu kullanır: OpenAI varsayılan olarak `gpt-5.6-luna` ile barındırılan `web_search`'i çalıştırır; Anthropic varsayılan olarak `claude-sonnet-5` ile - `web_search_20250305`'i çalıştırır. Akışlı yanıt ve alıntılar bir araç sonucu - haline gelir. + `web_search_20250305`'i çalıştırır. xAI varsayılan olarak `grok-4.6` ile hosted + `web_search` çalıştırır ve `xSearch.enabled` true olduğunda aynı isteğe `x_search` ekler. + Akışlı yanıt ve alıntılar bir araç sonucu haline gelir. 3. Model yanıt verene veya toplam gerçek sorgu bütçesi `maxSearchesPerTurn`'e (varsayılan 3) ulaşana kadar **döngüye girer**, ardından arama aracını kaldırır ve nihai bir yanıta zorlar. `apply_patch` veya kabuk gibi gerçek @@ -199,4 +199,3 @@ hedeflenen hesap ve iş yükü ile kapsamlı bir şekilde test edilmelidir. Her alan için [Yapılandırma referansı](/tr/reference/configuration/#sidecars) bölümüne bakın. - 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 8181cc896c..25da923a29 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -232,8 +232,10 @@ 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" \| "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. | +| `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | Açık değer kazanır; ayarlanmadığında her zaman `openai` seçilir. `anthropic` ve `xai` yalnızca açıkça yapılandırıldığında çalışır; `gemini` ve `exa` executor'ları sunulana kadar ayrılmıştır. | +| `model?` | `string` | arka uca bağlı | OpenAI için `gpt-5.6-luna`, Anthropic için `claude-sonnet-5` veya xAI için `grok-4.6`. Eski açık `gpt-5.4-mini` başlangıçta geçirilir. | +| `exaApiKey?` | `string` | yok | `exa` arka ucu için operatör anahtarı. Yalnızca yazılır; yönetim okumaları saklanan değeri asla döndürmez. | +| `xSearch?` | `object` | atlanmış | Yalnızca xAI için hosted `x_search` opt-in: `enabled`, birbirini dışlayan `allowedXHandles` / `excludedXHandles` dizileri (en fazla 20) ve ISO `fromDate` / `toDate` (`YYYY-MM-DD`). | | `reasoning?` | `string` | `low` | Sidecar çabası. `minimal` web araması ile reddedilir. | | `maxSearchesPerTurn?` | `number` | `3` | Ana model turu başına izin verilen gerçek aramalar. | | `routedModelStallTimeoutMs?` | `number` | `200000` | Yalnızca yapılandırma dosyasındaki yönlendirilen model ham gövde hareketsizlik süresi sınırı. Tamsayı 1–2147483647; boş olmayan her parça onu sıfırlar. | @@ -246,6 +248,11 @@ etkinleştirilmiş bir Anthropic OAuth sağlayıcısından gelen aktif saklanan bilgisini kullanır. Kullanılabilir hesabı olmayan açıkça seçilmiş bir Anthropic arka ucu geri dönmek yerine kapalı olarak başarısız olur. Anthropic yürütücüsü yerel `web_search_20250305` aracını kullanır. +xAI arka ucu kullanılabilir, saklanmış bir Grok OAuth hesabı gerektirir, hosted `web_search` kullanır +ve `xSearch.enabled` true olduğunda hosted `x_search` ekler. Hatalı `xSearch` yönetim girdisi `400` +döndürür; hatalı kalıcı blok planlama sırasında kapalı olarak başarısız olur. `gemini` ve `exa` +hatları kimlik bilgisi keşfi veya fallback ile hiçbir zaman etkinleşmez; operatör bunları açıkça +seçmelidir. `exaApiKey` yazmalarda kabul edilir ancak yönetim yanıtlarından çıkarılır. Aramayı dört saat yönetir: temel `stallTimeoutSec`, `connectTimeoutMs`, yönlendirilen model hareketsizliği ve barındırılan arama zaman aşımı. Geçerli diff --git a/docs-site/src/content/docs/zh-cn/guides/sidecars.md b/docs-site/src/content/docs/zh-cn/guides/sidecars.md index b0da7443b7..522dfdb41c 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-cn/guides/sidecars.md @@ -5,13 +5,13 @@ description: 通过原生 ChatGPT sidecar,让路由模型获得真实 web sear 不同路由模型对托管 **Web Search** 和原生**图像输入**的支持并不相同。opencodex 通过两个 sidecar 补齐这些能力;它们可以使用 ChatGPT 登录(`forward`)provider,也可以使用已存储的 -Anthropic OAuth provider。Sidecar 错误会转换成长度受限的工具结果或图像提示,不会让整个 turn +Anthropic OAuth provider;web search 还可通过显式 `xai` 后端使用已存储的 Grok OAuth。Sidecar 错误会转换成长度受限的工具结果或图像提示,不会让整个 turn 失败。 :::note[自动选择后端] -显式 `backend` 配置优先。省略时,如果已启用 Anthropic OAuth provider 的活动账户未标记 -`needsReauth`,则使用 `anthropic`;否则使用 `openai`。显式选择 `anthropic` 但没有可用凭据时 -会关闭失败。`openai` 同时需要 ChatGPT 登录和已启用的 `forward` provider。 +显式 `backend` 配置优先。Web search 省略时始终使用 `openai`;Vision 在存在可用 Anthropic +OAuth 账户时使用 `anthropic`,否则使用 `openai`。显式选择 `anthropic` 或 `xai` 但没有可用凭据时 +会关闭失败且不会回退。`openai` 同时需要 ChatGPT 登录和已启用的 `forward` provider。 ::: ## Web-search sidecar @@ -22,7 +22,8 @@ Anthropic OAuth provider。Sidecar 错误会转换成长度受限的工具结果 `web_search(query)` function 工具。原托管工具的选项会保留并用于 sidecar 调用。 2. 让路由模型在一个小型 **agentic 循环**中运行。模型调用 `web_search` 时,opencodex 使用所选 后端:OpenAI 默认以 `gpt-5.6-luna` 运行托管 `web_search`;Anthropic 默认以 - `claude-sonnet-5` 运行 `web_search_20250305`。Streaming 答案及引用会解析为工具结果。 + `claude-sonnet-5` 运行 `web_search_20250305`。xAI 默认以 `grok-4.6` 运行托管 `web_search`, + 并在 `xSearch.enabled` 为 true 时把 `x_search` 加入同一请求。Streaming 答案及引用会解析为工具结果。 3. **循环**直到模型回答,或真实查询总数达到 `maxSearchesPerTurn`(默认 3)。达到上限后会移除 search 工具并强制生成最终答案。如果模型调用 `apply_patch` 或 shell 等真实客户端工具,当前 turn 会结束,以便这些调用到达 Codex。 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 4d3f41b15f..36f467f437 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,15 +145,17 @@ Codex 会为标题、提交信息等任务使用较小的辅助模型。启用 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `enabled?` | `boolean` | 在可用时启用 | 总开关。 | -| `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` 会在启动时迁移。 | +| `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | 显式配置优先;省略时始终使用 `openai`。`anthropic` 和 `xai` 仅在显式配置时运行;`gemini` 和 `exa` 在 executor 发布前仍为保留值。 | +| `model?` | `string` | 依后端而定 | OpenAI 使用 `gpt-5.6-luna`,Anthropic 使用 `claude-sonnet-5`,xAI 使用 `grok-4.6`。旧的显式 `gpt-5.4-mini` 会在启动时迁移。 | +| `exaApiKey?` | `string` | 无 | `exa` 后端的操作员密钥。仅可写入:管理读取绝不会返回已存储的值。 | +| `xSearch?` | `object` | 省略 | xAI 专用的托管 `x_search` opt-in:`enabled`、互斥的 `allowedXHandles` / `excludedXHandles` 数组(最多 20 项),以及 ISO `fromDate` / `toDate`(`YYYY-MM-DD`)。 | | `reasoning?` | `string` | `low` | 侧车努力级别。`minimal` 与 web search 不兼容,会被拒绝。 | | `maxSearchesPerTurn?` | `number` | `3` | 每个主模型轮次允许的实际搜索次数。 | | `routedModelStallTimeoutMs?` | `number` | `200000` | 仅限配置文件的 routed-model 原始正文不活动截止时间。整数范围 1–2147483647;每个非空数据块都会重置它。 | | `timeoutMs?` | `number` | `60000` | 单次托管搜索的截止时间。 | OpenAI 后端要求已登录 ChatGPT,并启用了 ChatGPT `forward` 提供方。来自 Claude 的入站 -routed 重放会把主 ChatGPT 认证注入内部请求。Anthropic 后端使用的是来自已启用 Anthropic OAuth 提供方的当前保存凭据。如果显式选择了 Anthropic 后端但没有可用账户,则会失败并关闭,而不会回退。Anthropic 执行器使用其原生的 `web_search_20250305` 工具。 +routed 重放会把主 ChatGPT 认证注入内部请求。Anthropic 后端使用的是来自已启用 Anthropic OAuth 提供方的当前保存凭据。如果显式选择了 Anthropic 后端但没有可用账户,则会失败并关闭,而不会回退。Anthropic 执行器使用其原生的 `web_search_20250305` 工具。xAI 后端要求有可用的已存储 Grok OAuth 账户,使用托管 `web_search`,并在 `xSearch.enabled` 为 true 时添加托管 `x_search`。格式错误的 `xSearch` 管理输入会返回 `400`;格式错误的持久化块会在规划期间失败并关闭。`gemini` 和 `exa` 通道绝不会因凭据发现或回退而激活;操作员必须显式选择它们。`exaApiKey` 可在写入时接受,但会从管理响应中省略。 搜索由四个时钟共同约束:基础 `stallTimeoutSec`、`connectTimeoutMs`、routed-model 不活动超时,以及 托管搜索超时。有效的桥接看门狗是最大值再加 30 秒。routed stall 是不活动保护,而不是总生成截止时间。 diff --git a/docs-site/src/content/docs/zh-tw/guides/sidecars.md b/docs-site/src/content/docs/zh-tw/guides/sidecars.md index 3db6485291..1ffe807961 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-tw/guides/sidecars.md @@ -5,13 +5,13 @@ description: 透過原生 ChatGPT sidecar,讓路由模型獲得真實 web sear 不同路由模型對託管 **Web Search** 和原生**圖像輸入**的支援並不相同。opencodex 透過兩個 sidecar 補齊這些能力;它們可以使用 ChatGPT 登入(`forward`)provider,也可以使用已儲存的 -Anthropic OAuth provider。Sidecar 錯誤會轉換成長度受限的工具結果或圖像提示,不會讓整個 turn +Anthropic OAuth provider;web search 還可透過明確的 `xai` backend 使用已儲存的 Grok OAuth。Sidecar 錯誤會轉換成長度受限的工具結果或圖像提示,不會讓整個 turn 失敗。 :::note[自動選擇後端] -顯式 `backend` 設定優先。省略時,如果已啟用 Anthropic OAuth provider 的活動帳號未標記 -`needsReauth`,則使用 `anthropic`;否則使用 `openai`。顯式選擇 `anthropic` 但沒有可用憑證時 -會關閉失敗。`openai` 同時需要 ChatGPT 登入和已啟用的 `forward` provider。 +明確的 `backend` 設定優先。Web search 省略時一律使用 `openai`;Vision 有可用 Anthropic +OAuth 帳號時使用 `anthropic`,否則使用 `openai`。明確選擇 `anthropic` 或 `xai` 但沒有可用憑證時 +會關閉失敗且不回退。`openai` 同時需要 ChatGPT 登入和已啟用的 `forward` provider。 ::: ## Web-search sidecar @@ -22,7 +22,8 @@ Anthropic OAuth provider。Sidecar 錯誤會轉換成長度受限的工具結果 `web_search(query)` function 工具。原託管工具的選項會保留並用於 sidecar 呼叫。 2. 讓路由模型在一個小型 **agentic 迴圈**中執行。模型呼叫 `web_search` 時,opencodex 使用所選 後端:OpenAI 預設以 `gpt-5.6-luna` 執行託管 `web_search`;Anthropic 預設以 - `claude-sonnet-5` 執行 `web_search_20250305`。Streaming 答案及引用會解析為工具結果。 + `claude-sonnet-5` 執行 `web_search_20250305`。xAI 預設以 `grok-4.6` 執行託管 `web_search`, + 並在 `xSearch.enabled` 為 true 時將 `x_search` 加入同一請求。Streaming 答案及引用會解析為工具結果。 3. **迴圈**直到模型回答,或真實查詢總數達到 `maxSearchesPerTurn`(預設 3)。達到上限後會移除 search 工具並強制生成最終答案。如果模型呼叫 `apply_patch` 或 shell 等真實用戶端工具,目前 turn 會結束,以便這些呼叫到達 Codex。 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 291e3ef160..6df1d0f7be 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,14 +167,16 @@ Codex 使用小型 helper 模型處理如標題與 commit 訊息等任務。啟 | 欄位 | 型別 | 預設值 | 意義 | | --- | --- | --- | --- | | `enabled?` | `boolean` | 可用時開啟 | 主開關。 | -| `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` 在啟動時遷移。 | +| `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | 明確設定優先;省略時一律使用 `openai`。`anthropic` 與 `xai` 僅在明確設定時執行;`gemini` 與 `exa` 在 executor 推出前仍為保留值。 | +| `model?` | `string` | 視 backend 而定 | OpenAI 為 `gpt-5.6-luna`、Anthropic 為 `claude-sonnet-5`、xAI 為 `grok-4.6`。舊版明確 `gpt-5.4-mini` 在啟動時遷移。 | +| `exaApiKey?` | `string` | 無 | `exa` backend 的操作員金鑰。僅可寫入:管理讀取永遠不會傳回已儲存的值。 | +| `xSearch?` | `object` | 省略 | xAI 專用的託管 `x_search` opt-in:`enabled`、互斥的 `allowedXHandles` / `excludedXHandles` 陣列(最多 20 項),以及 ISO `fromDate` / `toDate`(`YYYY-MM-DD`)。 | | `reasoning?` | `string` | `low` | Sidecar effort。`minimal` 在網頁搜尋時被拒絕。 | | `maxSearchesPerTurn?` | `number` | `3` | 每個主模型回合允許的實際搜尋。 | | `routedModelStallTimeoutMs?` | `number` | `200000` | 僅設定檔的路由模型原始 body 不活動截止時間。整數 1–2147483647;每個非空 chunk 重置它。 | | `timeoutMs?` | `number` | `60000` | 一個代管搜尋的截止時間。 | -OpenAI backend 需要 ChatGPT 登入與啟用的 ChatGPT `forward` 供應商。Claude-inbound 路由重播將主 ChatGPT 認證注入內部請求。Anthropic backend 使用來自已啟用 Anthropic OAuth 供應商的現用已儲存憑證。明確選擇的 Anthropic backend 在無可用帳號時 fail closed 而非後退。Anthropic 執行器使用其原生 `web_search_20250305` 工具。 +OpenAI backend 需要 ChatGPT 登入與啟用的 ChatGPT `forward` 供應商。Claude-inbound 路由重播將主 ChatGPT 認證注入內部請求。Anthropic backend 使用來自已啟用 Anthropic OAuth 供應商的現用已儲存憑證。明確選擇的 Anthropic backend 在無可用帳號時 fail closed 而非後退。Anthropic 執行器使用其原生 `web_search_20250305` 工具。xAI backend 需要可用的已儲存 Grok OAuth 帳號,使用託管 `web_search`,並在 `xSearch.enabled` 為 true 時加入託管 `x_search`。格式錯誤的 `xSearch` 管理輸入會傳回 `400`;格式錯誤的持久化區塊會在規劃期間 fail closed。`gemini` 與 `exa` 通道絕不會因憑證探索或 fallback 而啟用;操作員必須明確選擇它們。`exaApiKey` 可在寫入時接受,但會從管理回應中省略。 四個時鐘治理搜尋:基礎 `stallTimeoutSec`、`connectTimeoutMs`、路由模型不活動與代管搜尋逾時。有效的橋接看門狗為最大值加 30 秒。路由停滯是不活動防護,而非總生成截止時間。 diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index a15c482682..06a677d46a 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -76,6 +76,7 @@ import { webSearchModelOptionsFrom, webSearchModelRejection, } from "./web-search-sidecar-options"; +import { validateXaiSearchOptions } from "../../web-search/xai-executor"; import { getDebugLogEntries } from "../../lib/debug-log-buffer"; import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; import { @@ -523,6 +524,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise; + const allowedXSearchKeys = new Set([ + "enabled", + "allowedXHandles", + "excludedXHandles", + "fromDate", + "toDate", + ]); + const unknownKey = Object.keys(x).find(key => !allowedXSearchKeys.has(key)); + if (unknownKey !== undefined) { + return jsonResponse({ error: `webSearch.xSearch.${unknownKey} is not a supported field` }, 400); + } + if (x.enabled !== undefined && typeof x.enabled !== "boolean") { + return jsonResponse({ error: "webSearch.xSearch.enabled must be a boolean" }, 400); + } + for (const field of ["allowedXHandles", "excludedXHandles"] as const) { + const value = x[field]; + if (value !== undefined && (!Array.isArray(value) || !value.every(handle => typeof handle === "string"))) { + return jsonResponse({ error: `webSearch.xSearch.${field} must be an array of strings` }, 400); + } + } + for (const field of ["fromDate", "toDate"] as const) { + if (x[field] !== undefined && typeof x[field] !== "string") { + return jsonResponse({ error: `webSearch.xSearch.${field} must be an ISO-8601 date (YYYY-MM-DD)` }, 400); + } + } + const candidate = { + ...(x.enabled === true ? { enabled: true } : {}), + ...(x.allowedXHandles !== undefined ? { allowedXHandles: x.allowedXHandles as string[] } : {}), + ...(x.excludedXHandles !== undefined ? { excludedXHandles: x.excludedXHandles as string[] } : {}), + ...(x.fromDate !== undefined ? { fromDate: x.fromDate as string } : {}), + ...(x.toDate !== undefined ? { toDate: x.toDate as string } : {}), + }; + const invalid = validateXaiSearchOptions({ + xSearch: candidate.enabled, + allowedXHandles: candidate.allowedXHandles, + excludedXHandles: candidate.excludedXHandles, + fromDate: candidate.fromDate, + toDate: candidate.toDate, + }); + if (invalid) return jsonResponse({ error: `webSearch.xSearch invalid: ${invalid}` }, 400); + webSearchCandidate.xSearch = candidate; } if (typeof body.webSearch.streamRoutedModelOutput === "boolean") { // `false` is the default — drop the key so config files stay minimal. - if (body.webSearch.streamRoutedModelOutput) config.webSearchSidecar.streamRoutedModelOutput = true; - else delete config.webSearchSidecar.streamRoutedModelOutput; + if (body.webSearch.streamRoutedModelOutput) webSearchCandidate.streamRoutedModelOutput = true; + else delete webSearchCandidate.streamRoutedModelOutput; } + config.webSearchSidecar = webSearchCandidate; } if (body.vision) { config.visionSidecar = { ...config.visionSidecar }; @@ -687,6 +739,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise { - const descriptor = WEB_SEARCH_BACKENDS.find(entry => - entry.isActive(auth) && entry.eligibleModel(candidate, auth)); + const descriptor = WEB_SEARCH_BACKENDS.find(entry => + entry.isActive(auth, config) && entry.eligibleModel(candidate, auth)); return descriptor ? [{ ...candidate, backend: descriptor.backend }] : []; }); } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3c7773e4e4..7c92dd3f38 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -3586,6 +3586,8 @@ async function handleResponsesInner( backend: wsPlan.backend, forwardProvider: wsPlan.forwardSidecar?.provider, anthropicSidecar: wsPlan.anthropicSidecar, + xaiSidecar: wsPlan.xaiSidecar, + xaiSearchOptions: wsPlan.xaiSearchOptions, hostedTool: wsPlan.hostedTool, selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders, settings: wsPlan.settings, diff --git a/src/types/config.ts b/src/types/config.ts index f7ad9857f5..de84457743 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -790,10 +790,10 @@ export interface OcxWebSearchSidecarConfig { /** * Which backend actually runs the server-side search. "openai" replays the hosted web_search via * the ChatGPT forward provider (gpt-mini sidecar); "anthropic" runs web_search_20250305 on a Claude - * model authenticated by the STORED anthropic OAuth credential. "xai" (Grok hosted web_search / - * x_search via stored Grok OAuth), "gemini" (google_search grounding via the Antigravity CCA - * transport), and "exa" (non-LLM search JSON via an operator key) are explicit-only and stay - * inactive until their executor ships. Unset ALWAYS resolves to "openai"; no backend is ever + * model authenticated by the STORED anthropic OAuth credential. "xai" runs Grok hosted web_search + * and optional x_search through stored Grok OAuth. "gemini" (google_search grounding via the + * Antigravity CCA transport) and "exa" (non-LLM search JSON via an operator key) are explicit-only + * and stay inactive until their executors ship. Unset ALWAYS resolves to "openai"; no backend is ever * auto-selected from credential availability (that once sent incompatible models to the * Anthropic API — see resolveSidecarBackend). */ @@ -805,6 +805,18 @@ export interface OcxWebSearchSidecarConfig { * and src/lib/redact.ts strips it from any logged structure or error string. */ exaApiKey?: string; + /** + * Opt-in X (Twitter) search for the xai backend: adds the hosted x_search tool next to + * web_search. Limits are doc-validated at the management layer AND in the executor: + * handles <=20 per list, allow XOR exclude, ISO-8601 dates. + */ + xSearch?: { + enabled?: boolean; + allowedXHandles?: string[]; + excludedXHandles?: string[]; + fromDate?: string; + toDate?: string; + }; /** Reasoning effort for the sidecar — "minimal" (non-thinking) keeps it fast/cheap. */ reasoning?: string; /** Max searches executed per main-model turn (loop guard). */ diff --git a/src/web-search/backends.ts b/src/web-search/backends.ts index f3daad939a..e40720a234 100644 --- a/src/web-search/backends.ts +++ b/src/web-search/backends.ts @@ -19,11 +19,13 @@ import type { OcxConfig } from "../types"; import { AUTH_SLOT_MODELS, type SidecarAuthState } from "../sidecar/auth"; import type { SidecarCandidate } from "../sidecar/candidates"; +import { getAccountSet } from "../oauth/store"; +import type { WebSearchBackendId } from "./index"; export interface WebSearchBackendDescriptor { - backend: "openai" | "anthropic"; + backend: WebSearchBackendId; /** Liveness signal for this backend (auth presence for the shipped two). */ - isActive(auth: SidecarAuthState): boolean; + isActive(auth: SidecarAuthState, config: OcxConfig): boolean; /** Which candidate rows this backend's executor can actually run. */ eligibleModel(candidate: SidecarCandidate, auth: SidecarAuthState): boolean; } @@ -50,6 +52,20 @@ export const WEB_SEARCH_BACKENDS: readonly WebSearchBackendDescriptor[] = [ // rows are unreachable, mirroring visionBackendForCandidate's stance. eligibleModel: (candidate, auth) => candidate.provider === auth.anthropicProviderName, }, + { + backend: "xai", + // Probe = stored Grok OAuth usable: enabled oauth-mode "xai" provider whose + // active account is not marked for reauth — the same predicate + // findXaiSidecarProvider applies at plan time (L7). + isActive: (_auth, config) => { + const provider = config.providers["xai"]; + if (!provider || provider.disabled === true || provider.authMode !== "oauth") return false; + const set = getAccountSet("xai"); + const active = set?.accounts.find(account => account.id === set.activeAccountId); + return !!active && active.needsReauth !== true; + }, + eligibleModel: candidate => candidate.provider === "xai", + }, ]; /** @@ -58,11 +74,11 @@ export const WEB_SEARCH_BACKENDS: readonly WebSearchBackendDescriptor[] = [ * side keeps Luna/Haiku even when the picker hides them. */ export function webSearchSidecarCandidates( - _config: OcxConfig, + config: OcxConfig, auth: SidecarAuthState, all: readonly SidecarCandidate[], ): SidecarCandidate[] { - const active = WEB_SEARCH_BACKENDS.filter(descriptor => descriptor.isActive(auth)); + const active = WEB_SEARCH_BACKENDS.filter(descriptor => descriptor.isActive(auth, config)); return all.filter(candidate => active.some(descriptor => descriptor.eligibleModel(candidate, auth))); } diff --git a/src/web-search/index.ts b/src/web-search/index.ts index 90e15c3e8e..137852ab84 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -4,16 +4,22 @@ import { isModelTextOnly } from "../vision"; import type { SidecarSettings } from "./executor"; import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar"; import { resolveSidecarAuth } from "../sidecar/auth"; +import { getAccountSet } from "../oauth/store"; +import { validateXaiSearchOptions, type XaiSearchOptions } from "./xai-executor"; +import type { OcxWebSearchSidecarConfig } from "../types"; import { DEFAULT_STALL_TIMEOUT_SEC } from "../stall-timeout"; import { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool"; export { runWithWebSearch } from "./loop"; export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME }; export { runAnthropicWebSearch, parseAnthropicSidecarSSE } from "./anthropic-executor"; +export { runXaiWebSearch, parseXaiResponsesSSE, validateXaiSearchOptions, type XaiSearchOptions } from "./xai-executor"; const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna"; // Default Claude model for the anthropic-backed sidecar (used when cfg.model is unset). const DEFAULT_ANTHROPIC_SIDECAR_MODEL = "claude-sonnet-5"; +// Default Grok model for the xai-backed sidecar (probe-verified with hosted tools, devlog 003). +const DEFAULT_XAI_SIDECAR_MODEL = "grok-4.6"; // "low" is the lightest effort the ChatGPT backend allows with web_search ("minimal" is rejected: // "tools cannot be used with reasoning.effort 'minimal'") — keeps the sidecar fast/cheap. const DEFAULT_SIDECAR_REASONING = "low"; @@ -92,6 +98,35 @@ export function findAnthropicSidecarProvider(config: OcxConfig): AnthropicSideca return { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider }; } +/** + * First enabled provider whose stored Grok OAuth account is active and not marked for + * reauth — the only credential the xai web-search executor may spend. Same account-set + * predicate the shared sidecar auth module applies to Anthropic. + */ +export function findXaiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined { + // The stored Grok credential lives under the provider named "xai" (registry id); + // OAuth account sets are keyed by provider name, so the name IS the credential key. + const provider = config.providers["xai"]; + if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined; + const set = getAccountSet("xai"); + const active = set?.accounts.find(account => account.id === set.activeAccountId); + if (active && active.needsReauth !== true) return { providerName: "xai", provider }; + return undefined; +} + +/** Lift the persisted xSearch config block into executor options (absent block = web_search only). */ +export function xaiSearchOptionsFromConfig(cfg: Pick): XaiSearchOptions { + const x = cfg.xSearch; + if (!x || x.enabled !== true) return {}; + return { + xSearch: true, + ...(x.allowedXHandles ? { allowedXHandles: x.allowedXHandles } : {}), + ...(x.excludedXHandles ? { excludedXHandles: x.excludedXHandles } : {}), + ...(x.fromDate ? { fromDate: x.fromDate } : {}), + ...(x.toDate ? { toDate: x.toDate } : {}), + }; +} + /** 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"; @@ -117,6 +152,10 @@ export interface SidecarPlan { forwardSidecar?: ResolvedOpenAiForwardSidecar; /** Present for the anthropic backend (stored-OAuth /v1/messages path); undefined for openai. */ anthropicSidecar?: AnthropicSidecarProvider; + /** Present for the xai backend (stored Grok OAuth /v1/responses path). */ + xaiSidecar?: { providerName: string; provider: OcxProviderConfig }; + /** Opt-in x_search options for the xai backend (validated at the management layer and again in the executor). */ + xaiSearchOptions?: XaiSearchOptions; hostedTool: Record; settings: SidecarSettings; maxSearches: number; @@ -167,10 +206,9 @@ 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; + // Inert arms (roadmap 060): gemini/exa stay fail-closed until their executor + // layers land. The xai arm went live in L7 below. + if (backend === "gemini" || backend === "exa") return undefined; const maxSearches = cfg.maxSearchesPerTurn ?? DEFAULT_MAX_SEARCHES; const stallTimeoutSec = webSearchStallTimeoutSec( config.stallTimeoutSec, @@ -201,6 +239,27 @@ export function planWebSearch( }; } + // xAI backend (L7): explicit-only, authenticated by the STORED Grok OAuth credential. + // Same fail-closed stance as anthropic — an explicit choice with no usable credential + // produces no plan instead of borrowing another login. + if (backend === "xai") { + const xaiSidecar = findXaiSidecarProvider(config); + if (!xaiSidecar) return undefined; + const xaiOptions = xaiSearchOptionsFromConfig(cfg); + if (validateXaiSearchOptions(xaiOptions)) return undefined; + return { + backend: "xai", + xaiSidecar, + xaiSearchOptions: xaiOptions, + hostedTool: parsed._webSearch, + settings: { model: cfg.model ?? DEFAULT_XAI_SIDECAR_MODEL, reasoning, timeoutMs, describeImages }, + maxSearches, + routedModelStallTimeoutMs, + stallTimeoutSec, + streamRoutedModelOutput, + }; + } + // OpenAI backend: needs a ChatGPT login (main) and a forward provider to reach server-side web_search. if (!openAiSidecar) return undefined; return { diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 50f2e40544..7df638b67a 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 { runXaiWebSearch, type XaiSearchOptions } from "./xai-executor"; import type { WebSearchBackendId } from "./index"; import { clearableDeadline } from "../lib/abort"; import { redactSecretString } from "../lib/redact"; @@ -261,6 +262,10 @@ export interface WebSearchLoopDeps { forwardProvider?: OcxProviderConfig; /** Required for the anthropic backend: the stored-OAuth provider that runs web_search_20250305. */ anthropicSidecar?: { providerName: string; provider: OcxProviderConfig }; + /** Required for the xai backend: the stored Grok OAuth provider (L7). */ + xaiSidecar?: { providerName: string; provider: OcxProviderConfig }; + /** Opt-in x_search options for the xai backend. */ + xaiSearchOptions?: XaiSearchOptions; hostedTool: Record; selectedForwardHeaders: Headers; settings: SidecarSettings; @@ -661,9 +666,20 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise 0 && excluded.length > 0) return "allowedXHandles and excludedXHandles are mutually exclusive"; + if (allowed.length > 20) return "allowedXHandles admits at most 20 handles"; + if (excluded.length > 20) return "excludedXHandles admits at most 20 handles"; + for (const [field, value] of [["fromDate", options.fromDate], ["toDate", options.toDate]] as const) { + if (value !== undefined && !ISO_DATE.test(value)) return `${field} must be an ISO-8601 date (YYYY-MM-DD)`; + } + return undefined; +} + +function buildXSearchTool(options: XaiSearchOptions): Record { + return { + type: "x_search", + ...(options.allowedXHandles?.length ? { allowed_x_handles: options.allowedXHandles } : {}), + ...(options.excludedXHandles?.length ? { excluded_x_handles: options.excludedXHandles } : {}), + ...(options.fromDate ? { from_date: options.fromDate } : {}), + ...(options.toDate ? { to_date: options.toDate } : {}), + }; +} + +function isRec(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); +} + +export async function runXaiWebSearch( + query: string, + providerName: string, + provider: OcxProviderConfig, + settings: SidecarSettings, + options: XaiSearchOptions = {}, + abortSignal?: AbortSignal, +): Promise { + const invalid = validateXaiSearchOptions(options); + if (invalid) return { text: "", sources: [], error: `xai sidecar options invalid: ${invalid}` }; + let token: string; + try { + token = await getValidAccessToken(providerName); + } catch (e) { + return { text: "", sources: [], error: `xai sidecar auth failed: ${publicOAuthAuthenticationErrorMessage(e)}` }; + } + // Credential pinning: only the EXACT api.x.ai origin may carry the OAuth bearer. + // A prefix check would admit https://api.x.ai.evil/ (review Critical); parse and + // compare origins, falling back to the canonical endpoint on any mismatch. + let base = `${XAI_RESPONSES_ORIGIN}/v1`; + if (provider.baseUrl) { + try { + const parsed = new URL(provider.baseUrl); + if (parsed.origin === XAI_RESPONSES_ORIGIN) base = provider.baseUrl.replace(/\/+$/, ""); + } catch { /* malformed baseUrl: keep the canonical endpoint */ } + } + const url = `${base}/responses`; + const instruction = settings.describeImages ? BASE_INSTRUCTION + IMAGE_INSTRUCTION : BASE_INSTRUCTION; + const body = { + model: settings.model, + instructions: instruction, + input: [{ role: "user", content: query }], + tools: [{ type: "web_search" }, ...(options.xSearch ? [buildXSearchTool(options)] : [])], + include: ["web_search_call.action.sources"], + reasoning: { effort: settings.reasoning }, + stream: true, + }; + const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal); + const sidecarExit = sidecarEnter("web-search"); + const t0 = Date.now(); + try { + const res = await fetchWithResetRetry( + () => fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, + body: JSON.stringify(body), + signal: linkedSignal.signal, + // Credential-bearing: never follow a redirect off the pinned origin. + redirect: "manual", + }), + { abortSignal: linkedSignal.signal, label: "xai-web-search-sidecar" }, + ); + const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); + if (!res.ok) { + const t = await res.text().catch(() => ""); + detachBodyGuard(); + const entitlement = res.status === 401 || res.status === 403 ? " (Grok OAuth entitlement — re-run ocx login xai?)" : ""; + return { text: "", sources: [], error: `xai sidecar HTTP ${res.status}${entitlement}: ${redactSecretString(t.slice(0, 200))}` }; + } + try { + return await parseXaiResponsesSSE(res); + } finally { + detachBodyGuard(); + } + } catch (e) { + const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; + console.warn(`[web-search] xai sidecar ${kind} (${Date.now() - t0}ms)`); + return { text: "", sources: [], error: e instanceof Error ? redactSecretString(e.message) : String(e) }; + } finally { + sidecarExit(); + linkedSignal.cleanup(); + } +} + +/** + * Reduce a Grok Responses SSE stream to a SidecarOutcome (probe-shapes in devlog 003): + * text from output_text deltas on message items; sources from url_citation annotations + * unioned with web_search_call action.sources (deduped by url). Tolerates custom_tool_call + * and x_search_call items, absent `action`, and unknown event names. Bounds raw bytes. + */ +export async function parseXaiResponsesSSE(response: Response): Promise { + if (!response.body) return { text: "", sources: [] }; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let responseBytes = 0; + let text = ""; + let doneText = ""; + const sourceUrls = new Map(); + let error: string | null = null; + const addSource = (url: unknown, title?: unknown) => { + if (typeof url !== "string" || url.length === 0) return; + if (!sourceUrls.has(url)) sourceUrls.set(url, { url, ...(typeof title === "string" && title.length > 0 && title !== url ? { title } : {}) }); + }; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + responseBytes += value.byteLength; + if (responseBytes > MAX_SIDECAR_RESPONSE_BYTES * 8) { + error = "xai sidecar stream exceeded byte bound"; + await reader.cancel(error).catch(() => {}); + break; + } + buffer += decoder.decode(value, { stream: true }); + let idx; + while ((idx = buffer.indexOf("\n\n")) >= 0) { + const frame = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const dataLine = frame.match(/^data: (.+)$/m)?.[1]; + if (!dataLine || dataLine === "[DONE]") continue; + let payload: unknown; + try { payload = JSON.parse(dataLine); } catch { continue; } + if (!isRec(payload) || typeof payload.type !== "string") continue; + switch (payload.type) { + case "response.output_text.delta": { + if (typeof payload.delta === "string") text += payload.delta; + break; + } + case "response.output_text.done": { + if (typeof payload.text === "string") doneText = payload.text; + break; + } + case "response.output_text.annotation.added": { + const annotation = payload.annotation; + if (isRec(annotation) && annotation.type === "url_citation") addSource(annotation.url, annotation.title); + break; + } + case "response.output_item.done": { + const item = payload.item; + if (isRec(item) && item.type === "web_search_call" && isRec(item.action) && Array.isArray(item.action.sources)) { + for (const s of item.action.sources) if (isRec(s)) addSource(s.url); + } + break; + } + case "response.failed": + case "error": { + const message = isRec(payload.response) && isRec(payload.response.error) && typeof payload.response.error.message === "string" + ? payload.response.error.message + : typeof payload.message === "string" ? payload.message : "upstream reported failure"; + error = redactSecretString(String(message).slice(0, 200)); + break; + } + default: break; + } + } + } + } catch (e) { + error = e instanceof Error ? redactSecretString(e.message) : String(e); + } finally { + reader.releaseLock(); + } + const finalText = doneText.length >= text.length ? doneText : text; + const outcome: SidecarOutcome = { text: finalText, sources: [...sourceUrls.values()] }; + if (error && finalText.length === 0) outcome.error = error; + return outcome; +} diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 7cd6514fea..64cf8e8383 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -896,12 +896,12 @@ surface is listed here so a maintainer can find the owner without grepping: Web search and vision sidecars run only when the main request needs that capability and a usable 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: +admits `xai`, `gemini`, and `exa`. xAI is a live explicit-only backend through stored Grok OAuth; +Gemini and Exa remain inert until their executors ship. Selection differs per sidecar: | Sidecar | Backend selection | Default model | Activation | | --- | --- | --- | --- | -| `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. | +| `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 requires usable stored Grok OAuth and may add hosted `x_search`; explicit Gemini/Exa remain fail-closed until their executors land. | `gpt-5.6-luna` (OpenAI), `claude-sonnet-5` (Anthropic), `grok-4.6` (xAI) | 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 079057352d..8d26d0dbdf 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 (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. | +| 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; xAI is live through stored Grok OAuth, while Gemini/Exa remain inert until their executors ship) plus validated `webSearch.xSearch`, 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/sidecar-settings-web-search-gate.test.ts b/tests/sidecar-settings-web-search-gate.test.ts index 1b58996239..57629ae9d4 100644 --- a/tests/sidecar-settings-web-search-gate.test.ts +++ b/tests/sidecar-settings-web-search-gate.test.ts @@ -29,6 +29,7 @@ import { import { handleManagementAPI } from "../src/server/management-api"; import { ManagementRequest as Request } from "./helpers/management-auth"; import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/account-id"; +import { xaiSearchOptionsFromConfig } from "../src/web-search"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; const forward: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }; @@ -192,3 +193,95 @@ describe("HTTP contract on /api/sidecar-settings", () => { expect(rejection.allowedModels).toContain("claude-haiku-4-5"); }); }); + +describe("xSearch config round-trip (review High)", () => { + test("PUT validates doc limits (400) and persists+echoes a valid block; GET carries it; null clears", async () => { + usableCodexAccounts.add(MAIN_CODEX_ACCOUNT_ID); + const cfg = config(); + const bad = await sidecarSettings(cfg, { method: "PUT", body: { webSearch: { xSearch: { enabled: true, allowedXHandles: ["a"], excludedXHandles: ["b"] } } } }); + expect(bad.status).toBe(400); + expect(((await bad.json()) as { error: string }).error).toContain("mutually exclusive"); + const ok = await sidecarSettings(cfg, { method: "PUT", body: { webSearch: { xSearch: { enabled: true, allowedXHandles: ["xai"], fromDate: "2026-08-01" } } } }); + expect(ok.status).toBe(200); + const putBody = await ok.json() as { webSearch: { xSearch?: unknown } }; + expect(putBody.webSearch.xSearch).toEqual({ enabled: true, allowedXHandles: ["xai"], fromDate: "2026-08-01" }); + const get = await sidecarSettings(cfg); + expect(((await get.json()) as { webSearch: { xSearch?: unknown } }).webSearch.xSearch).toEqual({ enabled: true, allowedXHandles: ["xai"], fromDate: "2026-08-01" }); + const clear = await sidecarSettings(cfg, { method: "PUT", body: { webSearch: { xSearch: null } } }); + expect(clear.status).toBe(200); + expect(cfg.webSearchSidecar?.xSearch).toBeUndefined(); + }); + + test("invalid xSearch does not partially mutate other web-search settings", async () => { + const original = { + backend: "openai" as const, + model: "gpt-5.6-luna", + reasoning: "low", + streamRoutedModelOutput: true, + }; + const cfg = config({ webSearchSidecar: { ...original } }); + + const response = await sidecarSettings(cfg, { + method: "PUT", + body: { + webSearch: { + backend: "xai", + reasoning: "high", + streamRoutedModelOutput: false, + xSearch: { enabled: true, allowedXHandles: ["a"], excludedXHandles: ["b"] }, + }, + }, + }); + + expect(response.status).toBe(400); + expect(cfg.webSearchSidecar).toEqual(original); + }); + + test("rejects allowedXHandle typo without broadening or partially mutating x_search", async () => { + const original = { + backend: "xai" as const, + reasoning: "low", + xSearch: { enabled: true as const, allowedXHandles: ["trusted"] }, + }; + const cfg = config({ webSearchSidecar: structuredClone(original) }); + + const response = await sidecarSettings(cfg, { + method: "PUT", + body: { + webSearch: { + reasoning: "high", + xSearch: { enabled: true, allowedXHandle: ["xai"] }, + }, + }, + }); + + expect(response.status).toBe(400); + expect(((await response.json()) as { error: string }).error).toContain("webSearch.xSearch.allowedXHandle"); + expect(cfg.webSearchSidecar).toEqual(original); + expect(xaiSearchOptionsFromConfig(cfg.webSearchSidecar!)).toEqual({ + xSearch: true, + allowedXHandles: ["trusted"], + }); + }); + + test.each([ + ["non-object block", "invalid", "webSearch.xSearch must be an object or null"], + ["non-boolean enabled", { enabled: "true" }, "enabled must be a boolean"], + ["non-array handles", { enabled: true, allowedXHandles: "xai" }, "allowedXHandles must be an array of strings"], + ["mixed handle array", { enabled: true, excludedXHandles: ["xai", 7] }, "excludedXHandles must be an array of strings"], + ["non-string date", { enabled: true, fromDate: 20260801 }, "fromDate must be an ISO-8601 date"], + ["malformed date", { enabled: true, toDate: "08/01/2026" }, "toDate must be an ISO-8601 date"], + ["unknown field", { enabled: true, scope: "following" }, "webSearch.xSearch.scope"], + ])("rejects malformed xSearch input: %s", async (_name, xSearch, message) => { + const cfg = config({ webSearchSidecar: { backend: "openai" } }); + + const response = await sidecarSettings(cfg, { + method: "PUT", + body: { webSearch: { xSearch } }, + }); + + expect(response.status).toBe(400); + expect(((await response.json()) as { error: string }).error).toContain(message); + expect(cfg.webSearchSidecar).toEqual({ backend: "openai" }); + }); +}); diff --git a/tests/web-search-backend-union.test.ts b/tests/web-search-backend-union.test.ts index f65c083e35..7f7d29a462 100644 --- a/tests/web-search-backend-union.test.ts +++ b/tests/web-search-backend-union.test.ts @@ -17,7 +17,7 @@ function parsedWithWebSearch() { return parseRequest({ model: "routed/model", input: "search", stream: true, tools: [{ type: "web_search" }] }); } -describe("widened backend union stays inert (roadmap 060)", () => { +describe("widened backend union remains fail-closed without backend authority", () => { test("resolveSidecarBackend: unset pin survives; new ids resolve to themselves", () => { expect(resolveSidecarBackend(undefined)).toBe("openai"); expect(resolveSidecarBackend("xai")).toBe("xai"); @@ -25,7 +25,12 @@ describe("widened backend union stays inert (roadmap 060)", () => { expect(resolveSidecarBackend("exa")).toBe("exa"); }); - test.each(["xai", "gemini", "exa"] as const)("planWebSearch %s -> no plan (fail-closed inert)", backend => { + test("planWebSearch xai -> no plan without a configured Grok OAuth provider", () => { + const cfg = config({ webSearchSidecar: { backend: "xai" } }); + expect(planWebSearch(cfg, parsedWithWebSearch(), false, routed, "model", undefined)).toBeUndefined(); + }); + + test.each(["gemini", "exa"] as const)("planWebSearch %s -> no plan while its executor is inert", backend => { const cfg = config({ webSearchSidecar: { backend } }); expect(planWebSearch(cfg, parsedWithWebSearch(), false, routed, "model", undefined)).toBeUndefined(); }); diff --git a/tests/xai-web-search.test.ts b/tests/xai-web-search.test.ts new file mode 100644 index 0000000000..41e4a42c90 --- /dev/null +++ b/tests/xai-web-search.test.ts @@ -0,0 +1,221 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import * as storeModule from "../src/oauth/store"; + +let accountSets: Record; activeAccountId?: string }> = {}; +mock.module("../src/oauth/store", () => ({ + ...storeModule, + getAccountSet: (provider: string) => accountSets[provider] ?? null, +})); +import * as oauthModule from "../src/oauth"; +mock.module("../src/oauth", () => ({ + ...oauthModule, + getValidAccessToken: async () => "test-token-xyz", +})); + +import { parseXaiResponsesSSE, validateXaiSearchOptions } from "../src/web-search/xai-executor"; +import { findXaiSidecarProvider, planWebSearch, xaiSearchOptionsFromConfig } from "../src/web-search"; +import { MAX_SIDECAR_RESPONSE_BYTES } from "../src/web-search/parse"; +import { parseRequest } from "../src/responses/parser"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const routed: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://routed.test/v1", apiKey: "k" }; +const xaiProvider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "oauth" }; + +function config(overrides: Partial = {}): OcxConfig { + return { port: 10100, defaultProvider: "routed", providers: { routed, xai: xaiProvider }, ...overrides }; +} +function parsedWithWebSearch() { + return parseRequest({ model: "routed/model", input: "search", stream: true, tools: [{ type: "web_search" }] }); +} +afterEach(() => { accountSets = {}; }); + +describe("validateXaiSearchOptions (doc limits)", () => { + test("21 handles rejected; 20 accepted", () => { + const twenty = Array.from({ length: 20 }, (_, i) => "h" + i); + expect(validateXaiSearchOptions({ allowedXHandles: twenty })).toBeUndefined(); + expect(validateXaiSearchOptions({ allowedXHandles: [...twenty, "extra"] })).toContain("at most 20"); + }); + test("allow XOR exclude; ISO dates", () => { + expect(validateXaiSearchOptions({ allowedXHandles: ["a"], excludedXHandles: ["b"] })).toContain("mutually exclusive"); + expect(validateXaiSearchOptions({ fromDate: "08/21/2026" })).toContain("ISO-8601"); + expect(validateXaiSearchOptions({ fromDate: "2026-08-21", toDate: "2026-08-22" })).toBeUndefined(); + }); +}); + +function sse(frames: Array>): Response { + const body = frames.map(f => `event: ${f.type}\ndata: ${JSON.stringify(f)}\n\n`).join("") + "data: [DONE]\n\n"; + return new Response(new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode(body)); c.close(); } })); +} + +describe("parseXaiResponsesSSE (live capture shapes, devlog 003)", () => { + test("text assembly + sources from annotations ∪ action.sources, deduped", async () => { + const out = await parseXaiResponsesSSE(sse([ + { type: "response.output_text.delta", delta: "xAI shipped " }, + { type: "response.output_text.delta", delta: "a thing." }, + { type: "response.output_text.annotation.added", annotation: { type: "url_citation", url: "https://x.ai/news", title: "1" } }, + { type: "response.output_item.done", item: { type: "web_search_call", id: "ws_1", status: "completed", action: { type: "search", query: "q", sources: [{ type: "url", url: "https://x.ai/news" }, { type: "url", url: "https://x.ai/company" }] } } }, + { type: "response.output_text.done", text: "xAI shipped a thing." }, + { type: "response.completed" }, + ])); + expect(out.text).toBe("xAI shipped a thing."); + expect(out.sources.map(s => s.url).sort()).toEqual(["https://x.ai/company", "https://x.ai/news"]); + expect(out.error).toBeUndefined(); + }); + + test("custom_tool_call items tolerated; absent action tolerated", async () => { + const out = await parseXaiResponsesSSE(sse([ + { type: "response.output_item.added", item: { type: "web_search_call", id: "ws_2", status: "in_progress" } }, + { type: "response.output_item.done", item: { type: "custom_tool_call", id: "ctc_1", call_id: "xs_call-1", name: "x_user_search", input: "{}", status: "completed" } }, + { type: "response.output_text.delta", delta: "posts found" }, + { type: "response.completed" }, + ])); + expect(out.text).toBe("posts found"); + expect(out.error).toBeUndefined(); + }); + + test("upstream failure with no text -> error outcome, never throws", async () => { + const out = await parseXaiResponsesSSE(sse([ + { type: "response.failed", response: { error: { message: "entitlement denied" } } }, + ])); + expect(out.text).toBe(""); + expect(out.error).toContain("entitlement"); + }); + + test("oversized stream cancels the upstream body", async () => { + let canceled = false; + const chunk = new Uint8Array(MAX_SIDECAR_RESPONSE_BYTES); + const response = new Response(new ReadableStream({ + pull(controller) { + controller.enqueue(chunk); + }, + cancel() { + canceled = true; + }, + })); + + const out = await parseXaiResponsesSSE(response); + + expect(out.error).toContain("byte bound"); + expect(canceled).toBe(true); + }); +}); + +describe("planWebSearch xai arm (L7)", () => { + test("explicit xai + usable Grok OAuth -> plan with xaiSidecar and grok-4.6 default", () => { + accountSets = { xai: { accounts: [{ id: "a1" }], activeAccountId: "a1" } }; + const cfg = config({ webSearchSidecar: { backend: "xai" } }); + const plan = planWebSearch(cfg, parsedWithWebSearch(), false, routed, "model", undefined); + expect(plan?.backend).toBe("xai"); + expect(plan?.xaiSidecar?.providerName).toBe("xai"); + expect(plan?.settings.model).toBe("grok-4.6"); + }); + + test("explicit xai without credential fails closed (no plan)", () => { + const cfg = config({ webSearchSidecar: { backend: "xai" } }); + expect(planWebSearch(cfg, parsedWithWebSearch(), false, routed, "model", undefined)).toBeUndefined(); + }); + + test("invalid persisted xSearch options fail closed at plan time", () => { + accountSets = { xai: { accounts: [{ id: "a1" }], activeAccountId: "a1" } }; + const cfg = config({ webSearchSidecar: { backend: "xai", xSearch: { enabled: true, allowedXHandles: ["a"], excludedXHandles: ["b"] } } }); + expect(planWebSearch(cfg, parsedWithWebSearch(), false, routed, "model", undefined)).toBeUndefined(); + }); + + test("xaiSearchOptionsFromConfig lifts only enabled blocks", () => { + expect(xaiSearchOptionsFromConfig({})).toEqual({}); + expect(xaiSearchOptionsFromConfig({ xSearch: { enabled: false, allowedXHandles: ["a"] } })).toEqual({}); + expect(xaiSearchOptionsFromConfig({ xSearch: { enabled: true, allowedXHandles: ["a"], fromDate: "2026-08-01" } })) + .toEqual({ xSearch: true, allowedXHandles: ["a"], fromDate: "2026-08-01" }); + }); + + test("findXaiSidecarProvider: disabled/key-auth/reauth all fail", () => { + accountSets = { xai: { accounts: [{ id: "a1", needsReauth: true }], activeAccountId: "a1" } }; + expect(findXaiSidecarProvider(config())).toBeUndefined(); + accountSets = { xai: { accounts: [{ id: "a1" }], activeAccountId: "a1" } }; + expect(findXaiSidecarProvider(config({ providers: { routed, xai: { ...xaiProvider, disabled: true } } }))).toBeUndefined(); + expect(findXaiSidecarProvider(config({ providers: { routed, xai: { ...xaiProvider, authMode: "key", apiKey: "k" } } }))).toBeUndefined(); + expect(findXaiSidecarProvider(config())?.providerName).toBe("xai"); + }); +}); + +describe("credential pinning + loop fail-closed (review blockers)", () => { + test("lookalike origin https://api.x.ai.evil never receives the bearer (falls back to canonical)", async () => { + const captured: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + captured.push(String(input instanceof Request ? input.url : input)); + return new Response("{}", { status: 401 }); + }) as typeof fetch; + try { + const evil: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://api.x.ai.evil/v1", authMode: "oauth" }; + const { runXaiWebSearch } = await import("../src/web-search/xai-executor"); + const out = await runXaiWebSearch("q", "xai", evil, { model: "grok-4.6", reasoning: "low", timeoutMs: 5000, describeImages: false }, {}); + expect(captured.length).toBeGreaterThanOrEqual(1); + for (const url of captured) expect(new URL(url).origin).toBe("https://api.x.ai"); + expect(out.error).toContain("401"); + } finally { + globalThis.fetch = realFetch; + } + }); +}); + +import { runWithWebSearch, type WebSearchLoopDeps } from "../src/web-search/loop"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; +import type { AdapterEvent, ProviderAdapter } from "../src/adapters/base"; + +describe("loop dispatch: xai arm fails closed without a sidecar (review blocker)", () => { + test("missing xaiSidecar yields the invariant error tool-result; forward executor and pool recorder untouched", async () => { + const firstPass: AdapterEvent[] = [ + { type: "tool_call_start", id: "ws1", name: "web_search" }, + { type: "tool_call_delta", arguments: "{\"query\":\"docs\"}" }, + { type: "tool_call_end" }, + { type: "done" }, + ]; + let pass = 0; + let sawToolResult = ""; + const adapter: ProviderAdapter = { + name: "two-pass", + buildRequest: (parsed) => { + if (pass > 0) sawToolResult = JSON.stringify(parsed.context.messages ?? parsed); + return { url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }; + }, + fetchResponse: async () => new Response("wire", { status: 200 }), + async *parseStream() { + const events = pass++ === 0 ? firstPass : [{ type: "text_delta", text: "answer" } as AdapterEvent, { type: "done" } as AdapterEvent]; + for (const event of events) yield event; + }, + async parseResponse() { throw new Error("unreachable"); }, + }; + const capturedForwardFetches: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + capturedForwardFetches.push(String(input instanceof Request ? input.url : input)); + return new Response("{}", { status: 500 }); + }) as typeof fetch; + let poolRecorded = 0; + try { + const response = await runWithWebSearch({ + parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }] }), + adapter, + backend: "xai", + // xaiSidecar deliberately ABSENT — the invariant violation under test. + hostedTool: { type: "web_search" }, + selectedForwardHeaders: new Headers({ authorization: "Bearer forward-secret" }), + settings: { model: "grok-4.6", reasoning: "low", timeoutMs: 5000, describeImages: false }, + maxSearches: 1, + recordSidecarOutcome: () => { poolRecorded += 1; }, + incomingMeta: { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, + } satisfies WebSearchLoopDeps); + // Drain the SSE so the loop completes. + await new Response(response.body).text(); + // No network fetch may have carried the forward headers (executors were never invoked). + expect(capturedForwardFetches).toEqual([]); + // The pool recorder belongs to the OpenAI executor path only. + expect(poolRecorded).toBe(0); + // The second pass received the graceful error tool result. + expect(sawToolResult).toContain("without a resolved Grok OAuth provider"); + } finally { + globalThis.fetch = realFetch; + } + }); +});