diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 85650d22e13..6b7439f0b0c 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -199,9 +199,9 @@ configuré sous [`claudeCode.authMode`](/fr/reference/configuration/server/#clau ### Ce qu'une sauvegarde de fournisseur conserve -`POST /api/providers` avec le nom d'un fournisseur existant remplace la ligne enregistrée par une ligne construite à partir de la requête. Le formulaire d'ajout/modification du tableau de bord ne peut pas envoyer tous les champs ; la sauvegarde conserve donc certains champs enregistrés que la requête omet. Dix d'entre eux décrivent le comportement d'un amont précis : `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. +`POST /api/providers` avec le nom d'un fournisseur existant remplace la ligne enregistrée par une ligne construite à partir de la requête. Le formulaire d'ajout/modification du tableau de bord ne peut pas envoyer tous les champs ; la sauvegarde conserve donc certains champs enregistrés que la requête omet. Onze d'entre eux décrivent le comportement d'un amont précis : `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. -| Sauvegarde | Les dix réglages | `apiKeyPool` enregistré | +| Sauvegarde | Les onze réglages | `apiKeyPool` enregistré | | --- | --- | --- | | Même destination, champ omis | Valeur enregistrée conservée, y compris un `[]` ou un `false` explicite | Conservé | | Nouvelle destination, champ omis | Non conservée ; les valeurs par défaut du registre pour la nouvelle destination peuvent s'appliquer | Non conservé | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 8705a8b7c64..7ecffe3764a 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -189,9 +189,9 @@ API キープロバイダーは、リテラルキーまたは環境参照を保 ### プロバイダーの保存で保持されるもの -既存のプロバイダー名で `POST /api/providers` を送ると、保存済みの行はリクエストから組み立てた行で置き換えられます。ダッシュボードの追加・編集フォームはすべてのフィールドを送れないため、リクエストが省略した保存済みフィールドの一部は保存時に引き継がれます。そのうち次の 10 つは、特定のアップストリームの挙動を記録するものです:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 +既存のプロバイダー名で `POST /api/providers` を送ると、保存済みの行はリクエストから組み立てた行で置き換えられます。ダッシュボードの追加・編集フォームはすべてのフィールドを送れないため、リクエストが省略した保存済みフィールドの一部は保存時に引き継がれます。そのうち次の 11 つは、特定のアップストリームの挙動を記録するものです:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 -| 保存 | 10 つの設定 | 保存済みの `apiKeyPool` | +| 保存 | 11 つの設定 | 保存済みの `apiKeyPool` | | --- | --- | --- | | 同じ宛先、フィールド省略 | 保存済みの値を保持(明示的な `[]` や `false` を含む) | 保持 | | 新しい宛先、フィールド省略 | 保持しない。新しい宛先のレジストリ既定値が適用されることがあります | 保持しない | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 0bb995ed1ef..6102a5442e6 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -189,9 +189,9 @@ API 키 공급자는 리터럴 키나 환경 참조를 둘 수 있습니다. OAu ### 프로바이더 저장이 유지하는 것 -기존 프로바이더 이름으로 `POST /api/providers`를 보내면 저장된 행이 요청으로 만든 행으로 바뀝니다. 대시보드의 추가/편집 폼은 모든 필드를 보낼 수 없으므로, 요청이 빠뜨린 저장 필드 일부는 저장할 때 이어서 유지됩니다. 그중 열 가지는 특정 업스트림의 동작을 기록한 설정입니다: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. +기존 프로바이더 이름으로 `POST /api/providers`를 보내면 저장된 행이 요청으로 만든 행으로 바뀝니다. 대시보드의 추가/편집 폼은 모든 필드를 보낼 수 없으므로, 요청이 빠뜨린 저장 필드 일부는 저장할 때 이어서 유지됩니다. 그중 열한 가지는 특정 업스트림의 동작을 기록한 설정입니다: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. -| 저장 | 열 가지 설정 | 저장된 `apiKeyPool` | +| 저장 | 열한 가지 설정 | 저장된 `apiKeyPool` | | --- | --- | --- | | 같은 목적지, 필드 생략 | 저장된 값 유지(명시적인 `[]`나 `false` 포함) | 유지 | | 새 목적지, 필드 생략 | 유지하지 않음. 새 목적지의 레지스트리 기본값이 적용될 수 있음 | 유지하지 않음 | diff --git a/docs-site/src/content/docs/reference/configuration/keychain.md b/docs-site/src/content/docs/reference/configuration/keychain.md new file mode 100644 index 00000000000..c8a4e40d384 --- /dev/null +++ b/docs-site/src/content/docs/reference/configuration/keychain.md @@ -0,0 +1,55 @@ +--- +title: Storing keys in the OS keychain +description: Storing provider API keys and Vertex credentials in macOS Keychain, Windows Credential Manager, or Linux Secret Service. +--- + +For Vertex, the dedicated API-key pool input (`POST /api/providers/keys`) accepts complete GCP +`service_account` or `authorized_user` credential JSON after the provider has been created. +The provider create/edit form and `ocx provider add --api-key` refuse credential JSON before +saving: create the provider without a key, then add the JSON through its key pool. Paste the +JSON contents, not a Windows, Unix, or relative file path. Multiple +JSON objects separated by commas or whitespace are stored together as separate pool entries; +the first becomes active. Configuration contains only `gcp-sa:` references, never the +credential JSON. Mixed JSON/literal-key bulk pastes are rejected as a whole; add literal keys +separately. Storage failures leave the configuration unchanged and report a fixed error without +echoing credential material. Empty marker accounts are invalid and do not fall back to host credentials. +Paste one credential object or a sequence of objects, without an enclosing array or quoted string. +Malformed credential text is rejected. If a selected stored credential is removed or becomes unreadable, +Vertex refuses the request even when it previously cached an access token for that credential. +Removing a key or provider, or replacing a provider with `ocx provider add --force`, deletes its unreferenced GCP credentials after the configuration is +saved; a credential referenced by another provider is retained. If OS-store cleanup fails, a +warning requests manual inspection without undoing the committed configuration removal. +Ordinary keychain store/restore actions leave GCP markers and JSON in the OS store. Restoring +credential JSON through a `keychain:` alias is refused, and cleanup preserves accounts still +referenced through either marker spelling. +Using a `keychain:` alias containing GCP JSON as an API key also refuses the request; select its +`gcp-sa:` reference for Vertex authentication instead. +Literal API keys must be plain token text: JSON objects, arrays and quoted JSON strings are refused +in literal-key resolution and restore, including wrappers containing escaped credential text. +Credential writes and marker persistence share the cleanup transaction lock, so concurrent +removal cannot delete the stored JSON between read-back and the marker commit. + +By default a provider's `apiKey` and `apiKeyPool` sit in `config.json` (mode 0600, atomic writes). +If you would rather keep the key material out of the file, move it into the OS credential store: + +```bash +ocx provider keychain deepseek status # store: file | env | keychain, and whether the keychain answers +ocx provider keychain deepseek store # move active key + pool into the OS keychain +ocx provider keychain deepseek restore # bring the plaintext back and delete the keychain items +``` + +The same operations are `GET`/`POST /api/providers/keychain`. After `store`, `config.json` holds +`"apiKey": "keychain:deepseek"` (pool entries `keychain:deepseek/`) and the secret lives under the +`opencodex.provider-api-key.v1` service in macOS Keychain, Windows Credential Manager, or the Linux +Secret Service. Backups of `config.json` therefore carry references only. Key rotation and failover +keep working: pool entries compare by reference, so a rotation never writes plaintext back. + +Before touching the config, `store` writes and reads back every entry; if the keychain is unavailable +or the read-back does not match, it refuses with 503 and leaves the file as it was. At request time +a reference that cannot be read yields no credential and one warning per key — there is no plaintext +fallback, by design. + +When not to opt in: a proxy running as a headless service (systemd, launchd, Task Scheduler) or in a +container usually has no unlocked keychain session, so requests would fail closed. Use an +`${ENV_VAR}` reference in the service environment there instead. Env references are left untouched +by `store`. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index ae998f0f119..7ed86852041 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -326,7 +326,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `preserveResponsesMessageMetadata?` | `boolean` | Default off. Preserve private `internal_chat_message_metadata_passthrough` on input items for explicitly trusted launcher relays, including custom native v1 compact requests. This forwards private turn/session metadata to that destination; do not enable for ordinary gateways. | | `dropResponsesReasoningItems?` | `boolean` | Remove replayed Responses `reasoning` items from continuation input entirely, including custom native v1 compact requests, instead of forwarding them with blanked content. This is lossy: summaries, item ids, and `encrypted_content` go with them, so no reasoning item from the previous turn remains in the forwarded input. It does not remove `previous_response_id`, so an upstream holding its own turn state may still reach it. Enable only for an upstream that rejects reasoning replay outright — the built-in Volcengine Ark Coding Plan preset sets it because validated tool continuations answered `400 InvalidParameter` otherwise. Set it to `false` on that provider to forward reasoning items again. | | `disabled?` | `boolean` | Keep the provider on disk but exclude it from routing and model/catalog listings. | -| `apiKey?` | `string` | API key, an `${ENV_VAR}` / `$ENV_VAR` reference, or a `keychain:` reference written by `ocx provider keychain store`. References resolve at request time. See [Storing keys in the OS keychain](#storing-keys-in-the-os-keychain). | +| `apiKey?` | `string` | API key, an `${ENV_VAR}` / `$ENV_VAR` reference, or a `keychain:` reference written by `ocx provider keychain store`. References resolve at request time. See [Storing keys in the OS keychain](/reference/configuration/keychain/). | | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic key header style. Defaults to native `x-api-key`; valid only for key-auth `anthropic` providers. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Multi-key pool. `apiKey` mirrors the active entry; each item has `id`, `key`, optional `label`, and optional numeric `addedAt`. | | `apiKeyPoolStrategy?` | `"round-robin" \| "fill-first" \| "quota"` | How a warm key is chosen **before** the first attempt when the committed key is already cooling. Omitted keeps rotation reactive-only: the pool moves after a 429 or 401 and not before. `round-robin` takes the next key in the pool, `fill-first` keeps the first eligible one, and `quota` prefers the key with the most remaining headroom, falling back to `fill-first` order for a provider whose per-key quota is unknown. A healthy committed key is never overridden, so a manual key selection stands. | @@ -370,6 +370,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `xaiResponsesXSearch?` | `boolean` | Disabled by default. On an xAI Responses destination, append the provider-hosted `x_search` declaration only when a live `web_search` tool survives final request normalization. Existing declarations are not duplicated, caller `tool_choice`/`allowed_tools` selectors are never widened, and this is separate from the web-search sidecar's `search.xSearch` options. | | `modelPreferHostedTools?` | `Record` | Exact-model opt-in for non-forward Responses gateways that reserve a hosted-tool namespace. Currently accepts only `["image_generation"]`; a matching model must use the `openai-responses` wire and support that hosted tool. It removes colliding client `image_gen` declarations and rewrites their selectors to preserve caller tool choice. For OpenAI API virtual `-pro` models, the selected public ID is matched first and the resolved base wire-model ID is a fallback. `modelAdapters` resolves the public ID first, then the base ID; the second resolution determines the final wire. Other models retain normal alias behavior. | | `annotateEmptyToolOutputs?` | `boolean` | Replace a present-but-empty tool result with a short marker before it reaches the model, so a blank result is not read as a missing one. Applies to blank strings and text-only part arrays; image, file, and encrypted parts are never touched. Defaults to `true` for DeepSeek from the built-in registry and is otherwise unset. Set `false` to opt a provider out — an explicit `false` is preserved across later edits that omit the field. `PATCH /api/providers?name=` accepts `true`, `false`, or `null` to clear the override and return to registry-default behavior. | +| `promoteAdditionalTools?` | `boolean` | Whether to promote Codex Desktop's `additional_tools` input items into top-level `tools` and strip the wrapper from `input`. Enabled automatically for OpenCode Go and xAI (`api.x.ai` and `cli-chat-proxy.grok.com`) unless `authMode` is `"forward"`, where promotion needs an explicit `true`; other OpenAI-strict third-party gateways that return HTTP 422 for unknown item types in `input` can opt in here. `PATCH /api/providers?name=` accepts a boolean or `null` to clear it. | | `unsupportedHostedTools?` | `string[]` | Hosted tool declarations this Responses destination rejects, so they are stripped from `tools`, from client-loaded `additional_tools`, and from `tool_choice` instead of being forwarded and rejected upstream. Use it for an OpenAI-compatible gateway with a narrower capability set — one that accepts plain Responses requests and `function` tools but returns HTTP 400 for hosted `web_search` — so a text-only prompt is not failed by a capability it never needed. Accepts only hosted tool type names (`web_search`, `web_search_preview`, `file_search`, `computer_use_preview`, `computer_use`, `code_interpreter`, `image_generation`, `image_gen`, `mcp`, `tool_search`, `local_shell`, `x_search`); an unrecognized name is rejected rather than silently ignored. Spelling variants of one capability are aliased, so `["web_search"]` also denies `web_search_preview`. A provider cannot both deny a hosted tool here and prefer it in `modelPreferHostedTools`. Xiaomi MiMo Responses destinations (`xiaomimimo.com` and its subdomains, including `api.xiaomimimo.com` and `token-plan-cn.xiaomimimo.com`) automatically strip `web_search` and `web_search_preview` while preserving function tools; no declaration is needed for these hosts. This is independent of `supportsResponsesCustomTools`; set both for a gateway that also rejects native custom tools. `PATCH /api/providers?name=` accepts an array or `null` to clear it. | | `reasoningEffortMap?` | `Record` | Provider-wide wire aliases for reasoning labels. Map a label to `"__omit__"` to drop the reasoning field from the upstream request entirely: `reasoning_effort` on an OpenAI-compatible wire, and Ollama's native `think` field on the Ollama native adapter (#2356). | | `modelReasoningEffortMap?` | `Record>` | Per-model wire aliases for reasoning labels. Map a label to `"__omit__"` to drop the reasoning field from the upstream request entirely. | @@ -487,9 +488,9 @@ and `modelContextWindows` for that exact id when needed. Unknown ids are not gue ### What a provider save keeps -`POST /api/providers` with the name of an existing provider replaces the stored row with one built from the request. The dashboard's add/edit form cannot send every field, so the save keeps some stored fields the request omits. Ten of them record how one upstream behaves: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx` and `retryOnReset`. +`POST /api/providers` with the name of an existing provider replaces the stored row with one built from the request. The dashboard's add/edit form cannot send every field, so the save keeps some stored fields the request omits. Eleven of them record how one upstream behaves: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx` and `retryOnReset`. -| Save | The ten settings | Stored `apiKeyPool` | +| Save | The eleven settings | Stored `apiKeyPool` | | --- | --- | --- | | Same destination, field omitted | Stored value kept, including an explicit `[]` or `false` | Kept | | New destination, field omitted | Not kept; registry defaults for the new destination may apply | Not kept | @@ -1589,60 +1590,11 @@ same mapping applies to a native `vercel/` selector: use the encoded `vercel-ai-gateway/vercel-` selector in OpenCodex and keep `vercel/` as the model key. -## Static model allowlists - ## Storing keys in the OS keychain -For Vertex, the dedicated API-key pool input (`POST /api/providers/keys`) accepts complete GCP -`service_account` or `authorized_user` credential JSON after the provider has been created. -The provider create/edit form and `ocx provider add --api-key` refuse credential JSON before -saving: create the provider without a key, then add the JSON through its key pool. Paste the -JSON contents, not a Windows, Unix, or relative file path. Multiple -JSON objects separated by commas or whitespace are stored together as separate pool entries; -the first becomes active. Configuration contains only `gcp-sa:` references, never the -credential JSON. Mixed JSON/literal-key bulk pastes are rejected as a whole; add literal keys -separately. Storage failures leave the configuration unchanged and report a fixed error without -echoing credential material. Empty marker accounts are invalid and do not fall back to host credentials. -Paste one credential object or a sequence of objects, without an enclosing array or quoted string. -Malformed credential text is rejected. If a selected stored credential is removed or becomes unreadable, -Vertex refuses the request even when it previously cached an access token for that credential. -Removing a key or provider, or replacing a provider with `ocx provider add --force`, deletes its unreferenced GCP credentials after the configuration is -saved; a credential referenced by another provider is retained. If OS-store cleanup fails, a -warning requests manual inspection without undoing the committed configuration removal. -Ordinary keychain store/restore actions leave GCP markers and JSON in the OS store. Restoring -credential JSON through a `keychain:` alias is refused, and cleanup preserves accounts still -referenced through either marker spelling. -Using a `keychain:` alias containing GCP JSON as an API key also refuses the request; select its -`gcp-sa:` reference for Vertex authentication instead. -Literal API keys must be plain token text: JSON objects, arrays and quoted JSON strings are refused -in literal-key resolution and restore, including wrappers containing escaped credential text. -Credential writes and marker persistence share the cleanup transaction lock, so concurrent -removal cannot delete the stored JSON between read-back and the marker commit. - -By default a provider's `apiKey` and `apiKeyPool` sit in `config.json` (mode 0600, atomic writes). -If you would rather keep the key material out of the file, move it into the OS credential store: - -```bash -ocx provider keychain deepseek status # store: file | env | keychain, and whether the keychain answers -ocx provider keychain deepseek store # move active key + pool into the OS keychain -ocx provider keychain deepseek restore # bring the plaintext back and delete the keychain items -``` +API keys and credential references can be stored in the OS credential store (macOS Keychain, Windows Credential Manager, Linux Secret Service) instead of `config.json`. See [Storing keys in the OS keychain](/reference/configuration/keychain/) for details on migration commands, storage rules, and headless-service caveats. -The same operations are `GET`/`POST /api/providers/keychain`. After `store`, `config.json` holds -`"apiKey": "keychain:deepseek"` (pool entries `keychain:deepseek/`) and the secret lives under the -`opencodex.provider-api-key.v1` service in macOS Keychain, Windows Credential Manager, or the Linux -Secret Service. Backups of `config.json` therefore carry references only. Key rotation and failover -keep working: pool entries compare by reference, so a rotation never writes plaintext back. - -Before touching the config, `store` writes and reads back every entry; if the keychain is unavailable -or the read-back does not match, it refuses with 503 and leaves the file as it was. At request time -a reference that cannot be read yields no credential and one warning per key — there is no plaintext -fallback, by design. - -When not to opt in: a proxy running as a headless service (systemd, launchd, Task Scheduler) or in a -container usually has no unlocked keychain session, so requests would fail closed. Use an -`${ENV_VAR}` reference in the service environment there instead. Env references are left untouched -by `store`. +## Static model allowlists The `zhipu-bigmodel-responses` preset seeds `glm-5.3`, `glm-5.3-flash` and `glm-5-turbo` with `liveModels: false` for `https://open.bigmodel.cn/api/v1`. Its static roster and diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index af23015f1de..527f8f4761b 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -205,9 +205,9 @@ Claude Code настраивается через ### Что сохраняется при сохранении провайдера -`POST /api/providers` с именем существующего провайдера заменяет сохранённую строку строкой, собранной из запроса. Форма добавления и редактирования в панели не может отправить все поля, поэтому сохранение переносит часть сохранённых полей, которые запрос опустил. Десять из них описывают поведение конкретного апстрима: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. +`POST /api/providers` с именем существующего провайдера заменяет сохранённую строку строкой, собранной из запроса. Форма добавления и редактирования в панели не может отправить все поля, поэтому сохранение переносит часть сохранённых полей, которые запрос опустил. Одиннадцать из них описывают поведение конкретного апстрима: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. -| Сохранение | Десять настроек | Сохранённый `apiKeyPool` | +| Сохранение | Одиннадцать настроек | Сохранённый `apiKeyPool` | | --- | --- | --- | | Та же точка назначения, поле опущено | Сохранённое значение остаётся, включая явный `[]` или `false` | Остаётся | | Новая точка назначения, поле опущено | Не переносится; могут примениться значения реестра по умолчанию для новой точки | Не переносится | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index c13c0c45116..12f2004ed8b 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -205,9 +205,9 @@ yapılandırılır. ### Bir sağlayıcı kaydı neleri korur -Var olan bir sağlayıcının adıyla gönderilen `POST /api/providers`, saklanan satırı istekten oluşturulan bir satırla değiştirir. Panodaki ekleme/düzenleme formu her alanı gönderemez; bu yüzden kayıt, isteğin atladığı bazı saklanan alanları korur. Bunlardan onu belirli bir üst kaynağın nasıl davrandığını kaydeder: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. +Var olan bir sağlayıcının adıyla gönderilen `POST /api/providers`, saklanan satırı istekten oluşturulan bir satırla değiştirir. Panodaki ekleme/düzenleme formu her alanı gönderemez; bu yüzden kayıt, isteğin atladığı bazı saklanan alanları korur. Bunlardan on biri belirli bir üst kaynağın nasıl davrandığını kaydeder: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. -| Kayıt | On ayar | Saklanan `apiKeyPool` | +| Kayıt | On bir ayar | Saklanan `apiKeyPool` | | --- | --- | --- | | Aynı hedef, alan atlanmış | Saklanan değer korunur, açık bir `[]` veya `false` dahil | Korunur | | Yeni hedef, alan atlanmış | Korunmaz; yeni hedef için kayıt defteri varsayılanları uygulanabilir | Korunmaz | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index e7b199e536d..bc201ce0b73 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -191,9 +191,9 @@ API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者 ### 保存提供方时会保留什么 -用已有提供方的名称调用 `POST /api/providers`,会用根据请求构建的行替换已存储的行。仪表板的添加/编辑表单无法发送所有字段,因此保存时会保留请求省略的部分已存储字段。其中十个记录的是某个上游的行为:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 +用已有提供方的名称调用 `POST /api/providers`,会用根据请求构建的行替换已存储的行。仪表板的添加/编辑表单无法发送所有字段,因此保存时会保留请求省略的部分已存储字段。其中十一个记录的是某个上游的行为:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 -| 保存 | 十项设置 | 已存储的 `apiKeyPool` | +| 保存 | 十一项设置 | 已存储的 `apiKeyPool` | | --- | --- | --- | | 目的地相同,字段省略 | 保留已存储的值,包括显式的 `[]` 或 `false` | 保留 | | 新目的地,字段省略 | 不保留;可能套用新目的地的注册表默认值 | 不保留 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index cc8b93b3735..5a430f66c07 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -162,9 +162,9 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 ### 儲存供應商時會保留什麼 -以既有供應商的名稱呼叫 `POST /api/providers`,會以根據請求建立的列取代已儲存的列。儀表板的新增/編輯表單無法傳送所有欄位,因此儲存時會保留請求省略的部分已儲存欄位。其中十個記錄的是某個上游的行為:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 +以既有供應商的名稱呼叫 `POST /api/providers`,會以根據請求建立的列取代已儲存的列。儀表板的新增/編輯表單無法傳送所有欄位,因此儲存時會保留請求省略的部分已儲存欄位。其中十一個記錄的是某個上游的行為:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 -| 儲存 | 十項設定 | 已儲存的 `apiKeyPool` | +| 儲存 | 十一項設定 | 已儲存的 `apiKeyPool` | | --- | --- | --- | | 目的地相同,欄位省略 | 保留已儲存的值,包括明確的 `[]` 或 `false` | 保留 | | 新目的地,欄位省略 | 不保留;可能套用新目的地的登錄檔預設值 | 不保留 | diff --git a/scripts/test-layout/layout-providers.json b/scripts/test-layout/layout-providers.json index e79f4b53fc7..cadb953254c 100644 --- a/scripts/test-layout/layout-providers.json +++ b/scripts/test-layout/layout-providers.json @@ -1,4 +1,7 @@ { + "xai-additional-tools.test.ts": "providers/xai", + "xai-replay-provenance.test.ts": "providers/xai", + "xai-loaded-tool-provenance.test.ts": "providers/xai", "free-directory-runcrate.test.ts": "providers", "cc-switch-import.test.ts": "providers", "kiro-completion-with-real-tools.test.ts": "providers/kiro" diff --git a/src/adapters/openai-responses/muse-tool-catalog.ts b/src/adapters/openai-responses/muse-tool-catalog.ts index bf2811e00eb..e0c92d70205 100644 --- a/src/adapters/openai-responses/muse-tool-catalog.ts +++ b/src/adapters/openai-responses/muse-tool-catalog.ts @@ -1,6 +1,7 @@ import { isPlainObject } from "./internal"; import { promoteClientLoadedTools } from "./tool-schema"; import { rewriteRoutedCustomToolsForUpstream } from "../../responses/custom-tool-compat"; +import { hasItemProvenance, isHistoricalItem } from "../../responses/item-provenance"; export class MuseToolCatalogCompatibilityError extends Error { constructor() { @@ -54,8 +55,13 @@ export function normalizeMuseToolCatalog(body: unknown, replayPrefixLength = 0, if (!Array.isArray(body.input)) return topLevelTools === undefined ? body : { ...body, tools: flattenBuiltinNamespace(topLevelTools) }; const start = Number.isFinite(replayPrefixLength) ? Math.min(body.input.length, Math.max(0, Math.trunc(replayPrefixLength))) : 0; + // Item provenance owns membership once present: an earlier rewrite (Zed lifting a replayed + // wrapper) may already have shortened the input below the numeric prefix. + const originalInput: unknown[] = body.input; + const tagged = originalInput.some(hasItemProvenance); + const isCurrent = (item: unknown, index: number) => tagged ? !isHistoricalItem(item) : index >= start; const normalizedInput = body.input.map((item, index) => { - if (index < start) return item; + if (!isCurrent(item, index)) return item; if (isPlainObject(item) && (item.type === "additional_tools" || (item.type === "tool_search_output" && item.tools !== undefined))) { const tools = normalizeCatalog(item.tools); @@ -63,7 +69,7 @@ export function normalizeMuseToolCatalog(body: unknown, replayPrefixLength = 0, } return item; }); - const currentInput = normalizedInput.slice(start); + const currentInput = normalizedInput.filter((_item, index) => isCurrent(originalInput[index], index)); const declared = [...(topLevelTools ?? [])]; for (const item of currentInput) { if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) declared.push(...item.tools); diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index b76549f571e..6f6ab2c07e2 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -3,7 +3,7 @@ import { normalizeRoutedAgentMessages } from "../routed-agent-messages"; import { frameCompactionHistory } from "../../responses/compaction-history-framing"; import { nameRoutedIdentity, repairIdentityInResponsesBody, stripRoutedIdentity } from "../identity"; import { stripBracketedModelSuffix } from "../openai-chat"; -import { normalizeOpenCodeGoAdditionalTools } from "../opencode-go-additional-tools"; +import { isOpenCodeGoResponsesUrl, isXaiResponsesUrl, promoteResponsesAdditionalTools } from "../opencode-go-additional-tools"; import { isXaiResponsesDestination } from "../../providers/xai-transport"; import { applyGithubCopilotContextTier } from "../../providers/github-copilot-context"; import { Buffer } from "node:buffer"; @@ -48,6 +48,7 @@ import { usageFromResponsesPayload } from "./usage"; import { normalizeToolSchemas, promoteClientLoadedTools, stripUnsupportedHostedTools } from "./tool-schema"; import { annotateEmptyResponsesToolOutputs, backfillWebSearchQueries, normalizeResponsesToolResultAdjacency, repairOrphanedInputItems, repairOversizedReplayCallIds, repairUnidentifiedToolOutputItems, restoreBridgedWebSearchCalls } from "./tool-output-recovery"; import { bridgeSearchReplayScope } from "../../responses/bridge-search-replay-cache"; +import { tagHistoricalInputItems } from "../../responses/item-provenance"; import { applyTierDecisionToResponsesBody, normalizeCanonicalForwardContinuationEnvelope, normalizeCanonicalForwardPromptEnvelope, stripCanonicalForwardSamplingParams, stripPreviousResponseId, stripStatefulResponsesParams, stripUnsupportedForwardParams } from "./canonical-forward"; import { normalizeImageGenClientTools, preferConfiguredHostedTools } from "./image-gen"; import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchFields } from "./web-search"; @@ -175,8 +176,22 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): let plaintextV2AgentMessageAliasedToolNames: ReadonlySet | undefined; let convertedMuseToolNameAliases: Map | undefined; const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; - let outBody = stripPreviousResponseId( - parsed._rawBody, + const replayPrefixLen = parsed._replayPrefixLen; + let outBody = parsed._rawBody; + // Clone and tag before any input rewrite can remove, replace, or reorder replay items. + if ( + typeof replayPrefixLen === "number" + && replayPrefixLen > 0 + && isPlainObject(outBody) + && Array.isArray(outBody.input) + ) { + outBody = { + ...outBody, + input: tagHistoricalInputItems(outBody.input, replayPrefixLen), + }; + } + outBody = stripPreviousResponseId( + outBody, forward || parsed._previousResponseInputExpanded === true, ); if (isMetaAiResponsesDestination(url)) { @@ -368,8 +383,15 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } - if (!forward) { - outBody = normalizeOpenCodeGoAdditionalTools(outBody, url, parsed._replayPrefixLen); + if ( + provider.promoteAdditionalTools === true + || (!forward && ( + isOpenCodeGoResponsesUrl(url) + || isXaiResponsesDestination(provider) + || isXaiResponsesUrl(url) + )) + ) { + outBody = promoteResponsesAdditionalTools(outBody); } // Same native-v2/portable predicate as the routedCompaction gate in handleResponses(): // neither forward auth nor native v1 endpoint support implies trigger support. The diff --git a/src/adapters/openai-responses/prompt-cache.ts b/src/adapters/openai-responses/prompt-cache.ts index dff927dd17f..a9b4b2a6e63 100644 --- a/src/adapters/openai-responses/prompt-cache.ts +++ b/src/adapters/openai-responses/prompt-cache.ts @@ -71,7 +71,9 @@ export function stripPromptCacheBreakpoints( if (!isPlainObject(value)) return { value, changed: false, complete: true }; let changed = Object.hasOwn(value, "prompt_cache_breakpoint"); - const next: Record = {}; + // Keep symbol provenance when rebuilding an input item or one of its nested fields. + const next: Record = { ...value }; + delete next.prompt_cache_breakpoint; for (const [key, entry] of Object.entries(value)) { if (key === "prompt_cache_breakpoint") continue; const rewritten = stripPromptCacheBreakpoints(entry, state, depth + 1); diff --git a/src/adapters/openai-responses/request-strips.ts b/src/adapters/openai-responses/request-strips.ts index 366d1ec34bc..9d64f6ed49e 100644 --- a/src/adapters/openai-responses/request-strips.ts +++ b/src/adapters/openai-responses/request-strips.ts @@ -287,12 +287,15 @@ const CANONICAL_ONLY_INPUT_ITEM_TYPES: ReadonlySet = new Set([ * {@link stripCanonicalOnlyTopLevelFields}; the canonical and OpenAI-operated destinations keep * the items verbatim and in order. Returns the input unchanged when none is present. */ +export function isCanonicalOnlyInputItem(item: unknown): boolean { + return isPlainObject(item) + && typeof item.type === "string" && CANONICAL_ONLY_INPUT_ITEM_TYPES.has(item.type); +} + export function stripCanonicalOnlyInputItems(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - const isPrivate = (item: unknown): boolean => isPlainObject(item) - && typeof item.type === "string" && CANONICAL_ONLY_INPUT_ITEM_TYPES.has(item.type); - if (!body.input.some(isPrivate)) return body; - return { ...body, input: body.input.filter(item => !isPrivate(item)) }; + if (!body.input.some(isCanonicalOnlyInputItem)) return body; + return { ...body, input: body.input.filter(item => !isCanonicalOnlyInputItem(item)) }; } /** diff --git a/src/adapters/openai-responses/tool-output-recovery.ts b/src/adapters/openai-responses/tool-output-recovery.ts index f2218ecb181..2605e1fdab7 100644 --- a/src/adapters/openai-responses/tool-output-recovery.ts +++ b/src/adapters/openai-responses/tool-output-recovery.ts @@ -3,6 +3,7 @@ import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../ import { isPlainObject } from "./internal"; import { peekBridgeSearchReplay } from "../../responses/bridge-search-replay-cache"; import { externalTaskInputResponsesContent } from "../../responses/task-input"; +import { copyItemProvenance } from "../../responses/item-provenance"; const MAX_RESPONSES_CALL_ID_LENGTH = 64; @@ -336,7 +337,7 @@ export function restoreBridgedWebSearchCalls(body: unknown, destinationScope: st if (memo && !occupiedCallIds.has(memo.callId)) { changed = true; occupiedCallIds.add(memo.callId); - restored.push({ + const fnCall: Record = { type: "function_call", ...(memo.sourceItemId ? { id: memo.sourceItemId } : {}), call_id: memo.callId, @@ -344,8 +345,16 @@ export function restoreBridgedWebSearchCalls(body: unknown, destinationScope: st // The bridge records the complete arguments text from the call's own done frame; the // empty-object fallback matches what a continuation leg would have sent. arguments: memo.argumentsText.length > 0 ? memo.argumentsText : "{}", - }); - restored.push({ type: "function_call_output", call_id: memo.callId, output: memo.output }); + }; + const fnOutput: Record = { + type: "function_call_output", + call_id: memo.callId, + output: memo.output, + }; + copyItemProvenance(item, fnCall); + copyItemProvenance(item, fnOutput); + restored.push(fnCall); + restored.push(fnOutput); continue; } } @@ -397,7 +406,7 @@ export function repairOrphanedInputItems( const taskInput = isFnOutput && !hasStringCallId ? externalTaskInputResponsesContent(item) : undefined; if (taskInput) { changed = true; - repaired.push({ type: "message", role: "user", content: taskInput }); + repaired.push(copyItemProvenance(item, { type: "message", role: "user", content: taskInput })); continue; } const callId = typeof item.call_id === "string" ? item.call_id : ""; @@ -409,11 +418,13 @@ export function repairOrphanedInputItems( const knownNullOutput = callId.length > 0 && item.output == null; if (repairOrphanOutputs && !paired && (knownNullOutput || usableOutput)) { changed = true; - repaired.push({ + const msg: Record = { type: "message", role: "user", content: orphanedToolOutputContent(item.output, callId), - }); + }; + copyItemProvenance(item, msg); + repaired.push(msg); continue; } } @@ -429,9 +440,11 @@ export function repairOrphanedInputItems( const name = typeof item.name === "string" && item.name.length > 0 ? item.name : callId; const text = `[ocx] no tool result was recorded for "${name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`; syntheticKeys.add(`${isFnCall ? "function" : "custom"}:${callId}`); - pendingSyntheticOutputs.push(isFnCall + const synthetic: Record = isFnCall ? { type: "function_call_output", call_id: callId, output: text } - : { type: "custom_tool_call_output", call_id: callId, output: text }); + : { type: "custom_tool_call_output", call_id: callId, output: text }; + copyItemProvenance(item, synthetic); + pendingSyntheticOutputs.push(synthetic); } } continue; diff --git a/src/adapters/openai-responses/tool-schema.ts b/src/adapters/openai-responses/tool-schema.ts index 12d11093cdd..eb986999fb8 100644 --- a/src/adapters/openai-responses/tool-schema.ts +++ b/src/adapters/openai-responses/tool-schema.ts @@ -8,6 +8,7 @@ import { XaiToolSchemaCompatibilityError, } from "../xai-tool-schema"; import { isPlainObject } from "./internal"; +import { hasItemProvenance, isHistoricalItem } from "../../responses/item-provenance"; function normalizeFunctionToolSchema(tool: unknown, xaiTarget: boolean): unknown | undefined { if (!isPlainObject(tool) || tool.type !== "function") return tool; @@ -195,11 +196,19 @@ function mergeLoadedTools(declaredTools: unknown[], loadedTools: unknown[]): unk * serializing the raw request, so activate those returned definitions for upstreams that do not * implement the native deferred-loading handshake themselves. */ -export function promoteClientLoadedTools(body: unknown): unknown { +export function promoteClientLoadedTools(body: unknown, replayPrefixLength?: number): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - const loadedTools = body.input.flatMap(item => - isPlainObject(item) && item.type === "tool_search_output" && Array.isArray(item.tools) + const currentTurnStart = Number.isFinite(replayPrefixLength) + ? Math.max(0, Math.trunc(replayPrefixLength!)) + : 0; + const hasProvenance = body.input.some(hasItemProvenance); + const isCurrentItem = (item: unknown, index: number): boolean => + hasProvenance ? !isHistoricalItem(item) : index >= currentTurnStart; + + // Filter the source before declarations lose their item's provenance in a live catalog. + const loadedTools = body.input.flatMap((item, index) => + isCurrentItem(item, index) && isPlainObject(item) && item.type === "tool_search_output" && Array.isArray(item.tools) ? item.tools : [] ); @@ -210,17 +219,20 @@ export function promoteClientLoadedTools(body: unknown): unknown { return tools === body.tools ? body : { ...body, tools }; } - const additionalToolsIndex = body.input.findIndex(item => - isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools) + const currentAdditionalToolsIndex = body.input.findIndex((item, index) => + isCurrentItem(item, index) && isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools) ); - if (additionalToolsIndex < 0) return { ...body, tools: mergeLoadedTools([], loadedTools) }; - const additionalTools = body.input[additionalToolsIndex]; + if (currentAdditionalToolsIndex < 0) { + return { ...body, tools: mergeLoadedTools([], loadedTools) }; + } + + const additionalTools = body.input[currentAdditionalToolsIndex]; if (!isPlainObject(additionalTools) || !Array.isArray(additionalTools.tools)) return body; const tools = mergeLoadedTools(additionalTools.tools, loadedTools); if (tools === additionalTools.tools) return body; const input = [...body.input]; - input[additionalToolsIndex] = { ...additionalTools, tools }; + input[currentAdditionalToolsIndex] = { ...additionalTools, tools }; return { ...body, input }; } diff --git a/src/adapters/opencode-go-additional-tools.ts b/src/adapters/opencode-go-additional-tools.ts index ea41ec1aa17..c76b0ad8a00 100644 --- a/src/adapters/opencode-go-additional-tools.ts +++ b/src/adapters/opencode-go-additional-tools.ts @@ -1,3 +1,5 @@ +import { hasItemProvenance, isHistoricalItem } from "../responses/item-provenance"; + function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -20,13 +22,32 @@ export function isOpenCodeGoResponsesUrl(responseUrl: string): boolean { && !destination.href.includes("?") && !destination.href.includes("#"); } -/** Console Go accepts public tools but rejects the private additional_tools input wrapper. */ -export function normalizeOpenCodeGoAdditionalTools( +const XAI_RESPONSES_HOSTS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]); + +/** + * True when the Responses target is an official xAI endpoint (public API or Grok CLI proxy). + */ +export function isXaiResponsesUrl(responseUrl: string): boolean { + let destination: URL; + try { + destination = new URL(responseUrl); + } catch { + return false; + } + return destination.protocol === "https:" + && XAI_RESPONSES_HOSTS.has(destination.hostname.toLowerCase()) + && (destination.port === "" || destination.port === "443"); +} + +/** + * Promotes client-loaded `additional_tools` input items into top-level `tools` and strips + * the wrapper from `input`. Required for gateways whose Rust/OpenAI-strict deserializer + * rejects unknown items in the `input` array. + */ +export function promoteResponsesAdditionalTools( body: unknown, - responseUrl: string, replayPrefixLength = 0, ): unknown { - if (!isOpenCodeGoResponsesUrl(responseUrl)) return body; if (!isRecord(body) || !Array.isArray(body.input)) return body; // Do not replace a malformed top-level catalog with a partial promoted one. if (body.tools !== undefined && !Array.isArray(body.tools)) return body; @@ -36,13 +57,15 @@ export function normalizeOpenCodeGoAdditionalTools( const currentTurnStart = Number.isFinite(replayPrefixLength) ? Math.min(body.input.length, Math.max(0, Math.trunc(replayPrefixLength))) : 0; + const hasProvenance = body.input.some(hasItemProvenance); let changed = false; for (const [index, item] of body.input.entries()) { if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { changed = true; // Replayed wrappers are conversation history, not authority for the current request. - // Go cannot accept the wrapper itself, so remove it without promoting its catalog. - if (index < currentTurnStart) continue; + // Gateways cannot accept the wrapper itself, so remove it without promoting its catalog. + const isHistorical = hasProvenance ? isHistoricalItem(item) : (index < currentTurnStart); + if (isHistorical) continue; // Custom/search/namespace lowering already owns identity and deduplication. This pass // only moves declarations, including hosted tools that intentionally have no name. for (const tool of item.tools) promoted.push(tool); @@ -52,3 +75,13 @@ export function normalizeOpenCodeGoAdditionalTools( } return changed ? { ...body, input, tools: [...(body.tools ?? []), ...promoted] } : body; } + +/** Console Go accepts public tools but rejects the private additional_tools input wrapper. */ +export function normalizeOpenCodeGoAdditionalTools( + body: unknown, + responseUrl: string, + replayPrefixLength = 0, +): unknown { + if (!isOpenCodeGoResponsesUrl(responseUrl)) return body; + return promoteResponsesAdditionalTools(body, replayPrefixLength); +} diff --git a/src/adapters/routed-agent-messages.ts b/src/adapters/routed-agent-messages.ts index 2ca67162a72..e9c57d17cc6 100644 --- a/src/adapters/routed-agent-messages.ts +++ b/src/adapters/routed-agent-messages.ts @@ -1,3 +1,5 @@ +import { hasItemProvenance, isHistoricalItem, REPLAY_HISTORICAL_ITEM } from "../responses/item-provenance"; + /** * `agent_message` is Codex's private multi-agent input item: it exists only in the ChatGPT * Codex backend's schema. Codex replays every sub-agent reply in the history it sends, so @@ -36,6 +38,7 @@ export function normalizeRoutedAgentMessages( changed = true; return { type: "message", role: "user", + ...(hasItemProvenance(message) ? { [REPLAY_HISTORICAL_ITEM]: isHistoricalItem(message) } : {}), content: [ ...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []), ...content, diff --git a/src/adapters/xai-web-search.ts b/src/adapters/xai-web-search.ts index 836eb0ca6df..d871234ae73 100644 --- a/src/adapters/xai-web-search.ts +++ b/src/adapters/xai-web-search.ts @@ -2,6 +2,7 @@ import type { OcxProviderConfig } from "../types"; import { debugProviderDiagnostic } from "../lib/debug"; import { isXaiResponsesDestination } from "../providers/xai-transport"; import { isOpenCodeGoResponsesUrl } from "./opencode-go-additional-tools"; +import { hasItemProvenance, isHistoricalItem } from "../responses/item-provenance"; const CODEX_WEB_SEARCH_TOOL = "web_search"; const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview"; @@ -190,8 +191,8 @@ export function normalizeXaiResponsesWebSearch( const xaiDestination = isXaiResponsesDestination(provider); if (!xaiDestination && !isOpenCodeGoGrokResponses(target?.modelId, target?.responseUrl)) return body; // On Go, a wrapper emptied here stays in place: Go promotion (opencode-go-additional-tools.ts) - // removes every wrapper before the wire and splits them into history and current turn by - // index against _replayPrefixLen, so deleting one would shift the current turn into history. + // removes every wrapper before the wire. Retain positions for callers with untagged input; + // transformed adapter input carries per-item historical/live provenance instead. const keepEmptiedWrappers = !xaiDestination; let next: Record = body; @@ -264,7 +265,9 @@ export function injectXaiResponsesXSearch( const input = Array.isArray(body.input) ? body.input : undefined; const inputStart = input ? currentInputStart(input.length, replayPrefixLength) : 0; - const currentInput = input?.slice(inputStart) ?? []; + const currentInput = input?.some(hasItemProvenance) + ? input.filter(item => !isHistoricalItem(item)) + : input?.slice(inputStart) ?? []; const currentXSearchDeclared = hasToolType(body.tools, XAI_SEARCH_TOOL) || currentInput.some(item => isPlainObject(item) diff --git a/src/adapters/zed-additional-tools.ts b/src/adapters/zed-additional-tools.ts index 73a18ca4533..10b52677e52 100644 --- a/src/adapters/zed-additional-tools.ts +++ b/src/adapters/zed-additional-tools.ts @@ -1,4 +1,5 @@ import { isPlainObject as isRecord } from "./openai-responses/internal"; +import { hasItemProvenance, isHistoricalItem, tagHistoricalInputItems } from "../responses/item-provenance"; function toolKey(tool: Record): string | undefined { if (typeof tool.type !== "string" || !tool.type) return undefined; @@ -8,8 +9,8 @@ function toolKey(tool: Record): string | undefined { /** * Zed's OpenAI Responses schema accepts message and call items, not Codex's private - * additional_tools wrapper. Drop every wrapper. Promote only wrappers at or after the - * replayed prefix: earlier ones are history, not the current turn's tool catalog. + * additional_tools wrapper. Drop every wrapper. Item provenance owns current-turn + * membership; a numeric prefix is accepted only for input not yet tagged. * A non-array tools field is left untouched so a malformed catalog is not replaced * by a partial promotion. */ @@ -17,16 +18,15 @@ export function liftZedAdditionalTools(providerRequest: Record, if (!Array.isArray(providerRequest.input)) return; if (providerRequest.tools !== undefined && !Array.isArray(providerRequest.tools)) return; - const currentTurnStart = Number.isFinite(replayPrefixLength) - ? Math.min(providerRequest.input.length, Math.max(0, Math.trunc(replayPrefixLength))) - : 0; + const taggedInput = providerRequest.input.some(hasItemProvenance) ? providerRequest.input + : tagHistoricalInputItems(providerRequest.input, Number.isFinite(replayPrefixLength) ? replayPrefixLength : 0); const input: unknown[] = []; const promoted: unknown[] = []; let changed = false; - for (const [index, item] of providerRequest.input.entries()) { + for (const item of taggedInput) { if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { changed = true; - if (index >= currentTurnStart) promoted.push(...item.tools); + if (!isHistoricalItem(item)) promoted.push(...item.tools); continue; } input.push(item); diff --git a/src/adapters/zed.ts b/src/adapters/zed.ts index a9bb6104df4..e156a59748c 100644 --- a/src/adapters/zed.ts +++ b/src/adapters/zed.ts @@ -354,12 +354,7 @@ export function createZedAdapter(provider: OcxProviderConfig, cacheRetention?: A : item, ); } - if (zedProvider === "open_ai") { - // Codex carries this turn's client tools in a private additional_tools input item. - // Zed rejects that variant, so promote the current turn and drop every wrapper - // before the built-in tool filter runs. - liftZedAdditionalTools(providerRequest, parsed._replayPrefixLen ?? 0); - } + // OpenAI wrappers were promoted before delegation; serialized input has no provenance tags. if (zedProvider === "open_ai" && Array.isArray(providerRequest.tools)) { // Zed's Responses decoder supports function/custom tools, not Codex's built-in web_search. const supported = (tool: unknown) => isRecord(tool) && (tool.type === "function" || tool.type === "custom"); diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 245222ad08e..3eacf076d56 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -360,6 +360,7 @@ export const providerConfigSchema = z.object({ inferContextWindow: z.boolean().optional(), modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), modelSuppressSyntheticMax: z.record(z.string().min(1), z.boolean()).optional(), + promoteAdditionalTools: z.boolean().optional(), preserveResponsesReasoningContent: z.boolean().optional(), preserveResponsesInputItemIds: z.boolean().optional(), preserveResponsesMessageMetadata: z.boolean().optional(), diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index ea003cedccc..a97202d8bdc 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -29,6 +29,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = { supportsServiceTier: "none", fastEnabled: "none", modelSupportsServiceTier: "record", + promoteAdditionalTools: "none", preserveResponsesReasoningContent: "none", preserveResponsesInputItemIds: "none", preserveResponsesMessageMetadata: "none", diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index 4b15bc9f9ff..30a894f06c2 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -232,7 +232,8 @@ function rewriteForUpstream( } let changed = false; - const next: Record = {}; + // Recursive tool lowering must retain the input item's historical/live symbol marker. + const next: Record = { ...value }; for (const [key, entry] of Object.entries(value)) { const rewritten = rewriteForUpstream(entry, names, callIds); next[key] = rewritten; diff --git a/src/responses/item-provenance.ts b/src/responses/item-provenance.ts new file mode 100644 index 00000000000..4cf5b7c365f --- /dev/null +++ b/src/responses/item-provenance.ts @@ -0,0 +1,79 @@ +export const REPLAY_HISTORICAL_ITEM = Symbol.for("ocx.responses.replayHistoricalItem"); + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Mark an input item object as belonging to the historical replay prefix. + * Uses an enumerable symbol so it is preserved across shallow object spreads (`{ ...item }`), + * but completely ignored by `JSON.stringify` and `Object.keys`. + */ +export function markHistoricalItem(item: T): T { + Object.defineProperty(item, REPLAY_HISTORICAL_ITEM, { + value: true, + enumerable: true, + configurable: true, + writable: true, + }); + return item; +} + +/** + * Check whether an input item originated in the historical replay. + */ +export function isHistoricalItem(item: unknown): boolean { + return ( + typeof item === "object" + && item !== null + && (item as Record)[REPLAY_HISTORICAL_ITEM] === true + ); +} + +/** A live marker survives even when a rewrite removes every historical item. */ +export function hasItemProvenance(item: unknown): boolean { + return typeof item === "object" && item !== null + && typeof (item as Record)[REPLAY_HISTORICAL_ITEM] === "boolean"; +} + +/** Replacement and synthesized items inherit the source's historical or live tag. */ +export function copyItemProvenance(source: unknown, target: T): T { + if (hasItemProvenance(source)) { + Object.assign(target, { [REPLAY_HISTORICAL_ITEM]: isHistoricalItem(source) }); + } + return target; +} + +/** + * Tag both historical and live items so an empty replay prefix remains distinguishable + * from an input that has never been tagged. Input that already carries provenance keeps it: + * an earlier rewrite (Zed lifting a replayed wrapper) may have shifted items past the + * numeric prefix, so re-tagging by index would mislabel current-turn items as history. + */ +export function tagHistoricalInputItems(input: unknown[], prefixLength: number): unknown[] { + if (!Array.isArray(input)) return input; + if (input.some(hasItemProvenance)) return input; + const prefix = Math.min(input.length, Math.max(0, Math.trunc(prefixLength))); + if (prefix <= 0) return input; + return input.map((item, index) => { + if (isPlainObject(item)) { + return { ...item, [REPLAY_HISTORICAL_ITEM]: index < prefix }; + } + return item; + }); +} + +/** + * Compute the current replay prefix length dynamically. + * If items carry provenance markers, the boundary is the index of the first non-historical item. + * Otherwise, falls back to the provided fallback prefix length. + */ +export function computeReplayPrefixLength(input: unknown[], fallback = 0): number { + if (!Array.isArray(input)) return fallback; + const hasProvenance = input.some(hasItemProvenance); + if (!hasProvenance) { + return Math.min(input.length, Math.max(0, Math.trunc(fallback))); + } + const firstLiveIndex = input.findIndex(item => !isHistoricalItem(item)); + return firstLiveIndex === -1 ? input.length : firstLiveIndex; +} diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 24b26e8a2ef..0c24ac3e87e 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -1,6 +1,7 @@ import { dottedToolName, namespacedToolName } from "../types"; import { collectResponsesToolGroups } from "./tool-groups"; import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous } from "./tool-name-aliases"; +import { hasItemProvenance, isHistoricalItem } from "./item-provenance"; export interface RoutedNamespaceToolIdentity { namespace: string; @@ -344,13 +345,25 @@ function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Se export function rewriteRoutedNamespaceToolsForUpstream( body: unknown, convertedCustomToolNames?: ReadonlySet, + replayPrefixLength?: number, ): { body: unknown; aliases: Map; } { if (!isPlainObject(body)) return { body, aliases: new Map() }; - const groups = collectResponsesToolGroups(body); + const currentTurnStart = Number.isFinite(replayPrefixLength) + ? Math.max(0, Math.trunc(replayPrefixLength!)) + : 0; + const hasProvenance = Array.isArray(body.input) && body.input.some(hasItemProvenance); + const isHistorical = (item: unknown, index: number) => + hasProvenance ? isHistoricalItem(item) : index < currentTurnStart; + const groups = collectResponsesToolGroups(Array.isArray(body.input) + ? { ...body, input: body.input.filter((item, index) => !isHistorical(item, index)) } + : body); const plan = buildRewritePlan(groups); + const historicalPlan = buildRewritePlan(collectResponsesToolGroups({ + input: Array.isArray(body.input) ? body.input.filter(isHistorical) : [], + })); // Deliberately not gated on the plan being non-empty: a turn whose catalog is absent can still // replay call items carrying a private `namespace`. @@ -360,8 +373,13 @@ export function rewriteRoutedNamespaceToolsForUpstream( let input = body.input; if (Array.isArray(body.input)) { let inputChanged = false; - const rewrittenInput = body.input.map(item => { - const next = rewriteInputItem(item, plan, emitted); + const historicalEmitted = new Set(); + const rewrittenInput = body.input.map((item, index) => { + const historicalCatalog = isHistorical(item, index) && isPlainObject(item) && item.type === "additional_tools"; + const itemEmitted = historicalCatalog + ? historicalEmitted + : emitted; + const next = rewriteInputItem(item, historicalCatalog ? historicalPlan : plan, itemEmitted); if (next !== item) inputChanged = true; return next; }); diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 4d124d4ac7e..11059690ed2 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1089,6 +1089,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { annotateEmptyToolOutputs: "editor", supportsServiceTier: "editor", modelSupportsServiceTier: "editor", + promoteAdditionalTools: "editor", preserveResponsesReasoningContent: "editor", preserveResponsesInputItemIds: "editor", preserveResponsesMessageMetadata: "editor", diff --git a/src/server/management/provider-overwrite-carry.ts b/src/server/management/provider-overwrite-carry.ts index 08f3ceff314..6b1a9b0d550 100644 --- a/src/server/management/provider-overwrite-carry.ts +++ b/src/server/management/provider-overwrite-carry.ts @@ -51,6 +51,7 @@ const RETRY_POLICY_VALIDATORS = { export const PROVIDER_COMPAT_CARRY_FIELDS = [ "preserveReasoningContentModels", "preserveResponsesReasoningContent", + "promoteAdditionalTools", "requiresReasoningPlaceholderModels", "foldDeveloperRoleToSystem", "singleLeadingSystemMessage", @@ -167,6 +168,8 @@ export function providerCompatFieldConfigError(provider: Record if (hideRawReasoning !== undefined && typeof hideRawReasoning !== "boolean") return "hideRawReasoning must be a boolean"; const preserveResponses = provider.preserveResponsesReasoningContent; if (preserveResponses !== undefined && typeof preserveResponses !== "boolean") return "preserveResponsesReasoningContent must be a boolean"; + const promoteAdditionalTools = provider.promoteAdditionalTools; + if (promoteAdditionalTools !== undefined && typeof promoteAdditionalTools !== "boolean") return "promoteAdditionalTools must be a boolean"; const wire = provider.reasoningWireFormat; if (wire !== undefined && !PROVIDER_REASONING_WIRE_FORMATS.includes(wire as never)) { return `reasoningWireFormat must be one of: ${PROVIDER_REASONING_WIRE_FORMATS.join(", ")}`; @@ -225,6 +228,13 @@ export function applyProviderCompatPatchFields( else return { error: "preserveResponsesReasoningContent must be a boolean" }; touched = true; } + if (Object.hasOwn(rawBody, "promoteAdditionalTools")) { + const value = rawBody.promoteAdditionalTools; + if (value === null) delete next.promoteAdditionalTools; + else if (typeof value === "boolean") next.promoteAdditionalTools = value; + else return { error: "promoteAdditionalTools must be a boolean" }; + touched = true; + } if (Object.hasOwn(rawBody, "inferContextWindow")) { const value = rawBody.inferContextWindow; if (value === null) delete next.inferContextWindow; diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 6e4826a959d..db694f1c39e 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -907,6 +907,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise 0, allowPrivateNetwork: p.allowPrivateNetwork === true, preserveResponsesReasoningContent: p.preserveResponsesReasoningContent, + promoteAdditionalTools: p.promoteAdditionalTools, liveModels: p.liveModels !== false, requestPacing: p.requestPacing, models: p.models ?? [], diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index d64a802b2ab..6a2784c7c68 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -1,4 +1,5 @@ import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors"; +import { hasItemProvenance, isHistoricalItem } from "../responses/item-provenance"; import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous, @@ -136,6 +137,9 @@ export function currentTurnWireToolCatalogBody( replayPrefixLength: number | undefined, ): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + if (body.input.some(hasItemProvenance)) { + return { ...body, input: body.input.filter(item => !isHistoricalItem(item)) }; + } if (typeof replayPrefixLength !== "number" || !Number.isFinite(replayPrefixLength)) return body; const start = Math.min(body.input.length, Math.max(0, Math.trunc(replayPrefixLength))); if (start === 0) return body; diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index fdabdf1de4c..86d3afdcbea 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -482,10 +482,9 @@ export async function preparePassthroughExchange( for (const callType of collectProviderExecutedCallTypes(clientToolAuthorizationBody)) { providerExecutedCallTypes.add(callType); } - const currentOutboundCatalog = currentTurnWireToolCatalogBody( - outboundRequestBody, - replayedInputPrefixLength, - ); + // Serialization drops provenance, so never slice transformed input by the raw prefix. + // Current wrappers were captured above; adapter-injected hosted tools are top-level. + const currentOutboundCatalog = { tools: outboundRequestBody?.tools }; for (const callType of collectProviderExecutedCallTypes(currentOutboundCatalog)) { providerExecutedCallTypes.add(callType); } diff --git a/src/types/provider.ts b/src/types/provider.ts index 61c66de5bc5..c2358cc46d7 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -434,6 +434,14 @@ export interface OcxProviderConfig { fastEnabled?: boolean; /** Exact upstream model ids that override the provider-level service-tier capability. */ modelSupportsServiceTier?: Record; + /** + * Whether to promote Codex Desktop's `additional_tools` input items into top-level `tools` + * and strip the wrapper from `input`. Enabled automatically for OpenCode Go and xAI + * (`api.x.ai` and `cli-chat-proxy.grok.com`) except on forward-auth routes, which need an + * explicit `true`; other strict gateways that return HTTP 422 for unknown item types in + * `input` can opt in here. + */ + promoteAdditionalTools?: boolean; /** * Responses upstream whose native contract accepts plaintext reasoning replay * (DeepSeek documents reasoning items with plaintext content). When set, the diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index ef49522edeb..bbdb03ccf55 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -275,7 +275,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Startup safety | `GET /api/startup-health` reports whether injected Codex routing is restart-safe, with secret-free service/shim diagnostics. `POST /api/startup-action` provides allowlisted one-click installation for the background service or launcher shim. On Windows a healthy script shim is CLI-only; Codex Desktop requires the background service for full protection. Optional Codex compatibility uses the [confirmed local runtime](clients/codex-desktop.md#optional-compatibility-runtime), [machine-local dashboard consent](clients/codex-desktop.md#dashboard-controls), and [revision-bound next-start preference with shared awaited teardown](clients/codex-desktop.md#proxy-startup-preference); saving that preference performs no runtime action. | | Windows tray | `GET/POST /api/windows-tray` controls an owned, per-user HKCU login tray. The tray delegates fixed actions to the CLI and is never a proxy supervisor or restart-protection signal. | | Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately, while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/check` and `POST /api/update/run` await one per-channel asynchronous registry lookup and write successful results through to the package cache; run passes that result to the job starter. `GET /api/update/badge` only reads the cache and reports unknown after 40 hours, on missing cache, or on channel mismatch. The badge links to the update surface rather than gating other actions. `GET /api/update/badge?surface=desktop&session=` projects only that process-local Tauri session; missing or expired state is unknown and never falls back to the package cache. `POST /api/update/desktop-snapshot` is a 1 KiB bounded display-state mutation with no install permission. It accepts the existing admin-token principal or a dedicated single-use, ten-second snapshot capability bound to nonce, method, path, PID, port and the SHA-256 digest of the exact body bytes; that capability cannot authorize another route. | -| Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the nine operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged. It keeps omitted `hideRawReasoning` on every overwrite of the same name, including destination moves, because it is a display policy; a submitted boolean wins. An omitted `inferContextWindow` likewise keeps the latest live same-name state after asynchronous validation, including `false` or a concurrent clear; a submitted boolean wins. It never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name; `hideRawReasoning`, `preserveResponsesReasoningContent` and `inferContextWindow` accept a boolean or `null` to clear them, rejecting other types. `GET /api/providers` exposes configured `inferContextWindow` as an optional boolean without substituting a default; its [custom-only, default-off inference policy](catalog-vendor-context.md) is unchanged. Custom Responses provider forms expose `preserveResponsesReasoningContent` as a default-off plaintext replay switch; unrelated settings saves preserve omission and curated/OpenAI destinations keep their existing defaults. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | +| Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the ten operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged. It keeps omitted `hideRawReasoning` on every overwrite of the same name, including destination moves, because it is a display policy; a submitted boolean wins. An omitted `inferContextWindow` likewise keeps the latest live same-name state after asynchronous validation, including `false` or a concurrent clear; a submitted boolean wins. It never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name; `hideRawReasoning`, `preserveResponsesReasoningContent`, `promoteAdditionalTools` and `inferContextWindow` accept a boolean or `null` to clear them, rejecting other types. `GET /api/providers` exposes configured `inferContextWindow` as an optional boolean without substituting a default; its [custom-only, default-off inference policy](catalog-vendor-context.md) is unchanged. Custom Responses provider forms expose `preserveResponsesReasoningContent` as a default-off plaintext replay switch; unrelated settings saves preserve omission and curated/OpenAI destinations keep their existing defaults. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. Ordinary model reads also apply the [new-arrival catalog contract](catalog.md#shared-catalog) before returning models. With a matching persisted configuration, discovery records the baseline, NEW badges, and automatic disables together; HTTP listing does not wait for a separate Codex sync. Inventory already drifted from disk uses a detached policy projection: arrivals remain disabled in management rows without changing live config or disk. | | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `PUT /api/oauth/accounts/pause`, and `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, pause/resume a generic OAuth account, and remove one. Paused generic OAuth accounts are excluded from request selection, 429 failover, and proactive token refresh; pausing an active account selects the next usable account when available, and resuming an account restores an active selection if the current one remains paused. With no unpaused account, requests return 403 rather than a login error. Kiro account-list rows include the current automatic-selection projection and closed exclusion reason; an active singleton may still send. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. Google Antigravity account rows in `gui/src/components/provider-workspace/ProviderAuthPanel.tsx` display the account-summary `plan` as a badge and include it in the account button’s accessible name. The value is the canonical upstream tier ID (for example `free-tier` or `gemini-pro`), preserved literally across locales rather than inferred as a product name. Absent plans render no badge; pause and reauthentication controls keep their existing behavior. | | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host` and `POST /api/link/apply`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; `POST /api/link/join` instead requires an operator-paired session because it durably redirects local client traffic, and also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` and typed `joinDenied` causes only to GUI-session callers, distinguishing pairing, runtime-role and port refusals without changing the admin/CLI DTO, so the dashboard enables the Child role only when the join gates pass; `GET /api/link/status`, `POST /api/link/{id}/reconnect` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress, and reconnect restarts a Home-initiated link's tunnel now; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index a8b49f9dde0..eced4076992 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -157,13 +157,13 @@ policy. Explicitly selected Codex Lite ordinary unreferenced hosted-history lowering follows [the hosted-search continuation boundary](../providers-and-adapters.md#hosted-search-continuation-binding); non-Lite/unknown requests, native compact and public API retain structural history; portable compaction keeps its existing trigger and default projection. Go's non-forward Responses request path moves valid `additional_tools` wrappers into top-level `tools` through `src/adapters/opencode-go-additional-tools.ts`. Placement runs after existing custom/search/namespace lowering and before code-mode, compaction and final hosted-tool pruning. -It does not recalculate wire identities or response aliases. The matcher reads the constructed +It reads per-item replay provenance without recalculating wire identities or response aliases. The matcher reads the constructed send URL, resolving it with URL semantics, and requires HTTPS `opencode.ai`, the standard port and exact `/zen/go/v1/responses`. Normal and endpoint-inclusive bases or split `responsesPath` configurations agree; a custom path resolving to Zen or elsewhere does not acquire Go placement. Credentials, query, fragment, foreign hosts and other resource paths are excluded. The existing URL constructor canonicalizes trailing base slashes before this check. Malformed wrappers remain unchanged and -the shared mixed-ciphertext agent-message gate remains fail-closed. +the shared mixed-ciphertext agent-message gate remains fail-closed. Responses passthrough clones and tags historical and current input items before any rewrite, including identity repair; symbol provenance survives message replacement, prompt-cache cleanup, filtering and tool lowering without entering serialized requests. Promotion uses provenance rather than positions in shortened input. Discovery-source filtering follows the [current-turn catalog contract](responses.md#responses-httpsse) before nested tool definitions enter a live catalog. The canonical `opencode-go` registry entry defaults to `statelessResponses: true` because Go rejects reasoning ciphertext combined with `previous_response_id` (#3838). Existing derive @@ -221,7 +221,7 @@ the destination is the whole predicate, because Meta's refusal is a gateway sche Muse model it serves, its default `muse-spark-1.3` is not a Contributor id, and a missing model id still strips. Because the predicate is the host, a custom provider pointed at that exact URL gets the same strip. -Meta's `api.meta.ai` route uses `src/adapters/openai-responses/muse-tool-catalog.ts` to capture current declarations before history rewrites or deduplication: current wrappers become `tools`, declarations retain their schemas over search discoveries, historical discovery stays replay data, and historical custom calls retain their conversion without live authority. Historical catalogs are not validated as current declarations. The reserved `functions` group and nested function definitions become public function declarations. Catalog validation also covers string/omitted input; unrepresentable namespace children and malformed current catalogs fail locally with HTTP 400 (`tests/responses/responses-muse-tool-choice.test.ts`). +Meta's `api.meta.ai` route uses `src/adapters/openai-responses/muse-tool-catalog.ts` to capture current declarations before history rewrites or deduplication: current wrappers become `tools`, declarations retain their schemas over search discoveries, historical discovery stays replay data, and historical custom calls retain their conversion without live authority. Historical catalogs are not validated as current declarations; once input items carry replay provenance tags, the tags decide membership instead of the numeric prefix, so an earlier rewrite that shortened the input (Zed lifting a replayed wrapper) cannot shift the boundary. The reserved `functions` group and nested function definitions become public function declarations. Catalog validation also covers string/omitted input; unrepresentable namespace children and malformed current catalogs fail locally with HTTP 400 (`tests/responses/responses-muse-tool-choice.test.ts`). Direct Meta Muse / Meta Model Responses (`https://api.meta.ai/v1`) also rejects function tool names longer than 64 characters or containing characters outside `[a-zA-Z0-9_-]`. After namespace flattening, `src/responses/muse-tool-name-alias.ts` rewrites those identities on the `api.meta.ai` diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 82585590f56..d67380d7ec7 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -203,27 +203,24 @@ when the shared xAI normalizer can preserve its meaning; an unsafe function is o letting one incompatible declaration reject the entire request before inference. This is scoped to `cli-chat-proxy.grok.com`: public `api.x.ai` keeps native root unions, as do unrelated Responses gateways. Both top-level `tools` and Responses Lite `additional_tools` pass through this policy. +Codex Desktop packages client tools inside an `additional_tools` input item; strict gateways rejecting non-ResponseItem input types—including official xAI endpoints (`api.x.ai`, `cli-chat-proxy.grok.com`), OpenCode Go, and custom providers opting in with `promoteAdditionalTools: true`—promote declarations to top-level `tools` and strip the wrapper from `input`. `src/responses/item-provenance.ts` tags historical and live input objects with JSON-invisible symbols before replay repair; historical/live tags survive replacements, synthesized outputs and bridge restoration; transformed-input consumers read each tag, never a recalculated prefix. The numeric replay prefix tags only input that carries no tag yet, so a rewrite that already tagged and then shortened the input (Zed lifting a replayed wrapper) keeps a current-turn `tool_search_output` current. +Historical wrappers retain call-rewriting context with separate rewrite plans and declaration sets. Only current-turn `tool_search_output` discoveries join a live wrapper, top-level tools or the top-level fallback; source provenance is checked before merging declarations. Historical discoveries remain replay context and grant no current hosted-tool authority. `tests/providers/xai/xai-loaded-tool-provenance.test.ts` covers these paths and stored two-turn continuations. -Only the ROOT rejects a union, so exclusivity is preserved by moving it down rather than widening -it: a root `oneOf` whose branches differ in one property becomes that property's `oneOf`, or its -`anyOf` when the branches are provably disjoint and the two keywords describe the same set. That -property is also promoted into `required`, because absent it matched every branch — which the root -`oneOf` rejects. Branches that are wholly identical validate nothing and have no faithful -flattening, so they omit the tool. The walk carries depth, node, and variant budgets, since nested -unions are combinatorial and a `$ref` diamond amplifies the same way without ever cycling; -exceeding a budget omits that one function rather than expanding until memory is gone. - -Omitting a function makes `tool_choice` the loose end. A selector naming a dropped tool would reach -Grok as a dangling reference, and relaxing it to `auto` is worse — the turn would quietly run -without the tool the caller required. So an `allowed_tools` list drops the omitted entries while any -remain, and a selection with nothing left to point at fails locally with the same 400 a tool catalog -this proxy cannot lower already returns. +Only the ROOT rejects a union, so exclusivity is preserved by moving it down rather than widening it: +a root `oneOf` whose branches differ in one property becomes that property's `oneOf`, or its `anyOf` +when the branches are provably disjoint and the two keywords describe the same set. That property is also +promoted into `required`, because absent it matched every branch — which the root `oneOf` rejects. +Branches that are wholly identical validate nothing and have no faithful flattening, so they omit the tool. +The walk carries depth, node, and variant budgets, since nested unions are combinatorial and a `$ref` +diamond amplifies the same way without ever cycling; exceeding a budget omits that one function. + +Omitting a function makes `tool_choice` the loose end: a selector naming a dropped tool would reach +Grok as a dangling reference, and relaxing to `auto` runs without the tool the caller required. +An `allowed_tools` list drops omitted entries while any remain, and an empty selection fails with 400. The same noncanonical boundary strips ChatGPT's private `external_web_access` bit from routed -`web_search` declarations. The public tool remains enabled and all other options remain intact; -canonical OpenAI forwarding preserves the bit. xAI's public Responses schema enables browsing by -the presence of `web_search` and rejects the private argument, so forwarding it made the first -post-namespace request fail with HTTP 400. +`web_search` declarations while keeping the public tool and other options intact (canonical OpenAI +forwarding preserves the bit; xAI rejects the private argument with HTTP 400). The option-aware `openai` provider uses `openai-responses` with `authMode: "forward"`. Pool mode resolves main plus added accounts through affinity/quota/cooldown ownership; Direct forwards only diff --git a/tests/adapters/routed-agent-messages.test.ts b/tests/adapters/routed-agent-messages.test.ts index 2a44d118622..4e247cd1615 100644 --- a/tests/adapters/routed-agent-messages.test.ts +++ b/tests/adapters/routed-agent-messages.test.ts @@ -1,12 +1,24 @@ import { expect, test } from "bun:test"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; import { normalizeRoutedAgentMessages } from "../../src/adapters/routed-agent-messages"; +import { hasItemProvenance, isHistoricalItem, tagHistoricalInputItems } from "../../src/responses/item-provenance"; import { parseRequest } from "../../src/responses/parser"; import { routeModel } from "../../src/router"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import type { OcxProviderConfig } from "../../src/types"; const base: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "synthetic-key" }; + +test("agent-message replacement carries historical and live provenance", () => { + const input = tagHistoricalInputItems([ + { type: "agent_message", content: [{ type: "input_text", text: "past" }] }, + { type: "agent_message", content: [{ type: "input_text", text: "current" }] }, + ], 1); + const result = normalizeRoutedAgentMessages({ input }) as { input: unknown[] }; + expect(result.input.map(hasItemProvenance)).toEqual([true, true]); + expect(result.input.map(isHistoricalItem)).toEqual([true, false]); + expect(JSON.stringify(result)).not.toContain("replayHistoricalItem"); +}); const body = () => ({ model: "muse-spark-1.3-contributor", input: [{ type: "agent_message", id: "amsg_test", author: "/root/reader", recipient: "/root/checker", content: [{ type: "input_text", text: "Exact assignment\nwith lines." }] }], stream: true }); test("Responses converts plaintext task and peer messages without mutating replay or losing routing identities", async () => { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3376f412bf1..fb0104a86e8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1489,6 +1489,9 @@ "ws-replay-refusal-attribution.test.ts": "responses", "combo-replay-refusal-attribution.test.ts": "responses", "ws-upstream.test.ts": "responses", + "xai-additional-tools.test.ts": "providers/xai", + "xai-replay-provenance.test.ts": "providers/xai", + "xai-loaded-tool-provenance.test.ts": "providers/xai", "xai-client.test.ts": "images", "xai-empty-catalog-tool-choice.test.ts": "providers/xai", "xai-no-stop.test.ts": "providers/xai", diff --git a/tests/providers/xai/xai-additional-tools.test.ts b/tests/providers/xai/xai-additional-tools.test.ts new file mode 100644 index 00000000000..26f93c6d581 --- /dev/null +++ b/tests/providers/xai/xai-additional-tools.test.ts @@ -0,0 +1,421 @@ +import { providerConfigSchema } from "../../../src/config/schema/leaf-validators"; +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createProductionAdapter } from "../../../src/adapters/openai-responses"; +import { + isXaiResponsesUrl, + promoteResponsesAdditionalTools, +} from "../../../src/adapters/opencode-go-additional-tools"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; +import { CODEX_GPT5_IDENTITY_LINE, identifyRoutedModel } from "../../../src/adapters/identity"; +import { tagHistoricalInputItems } from "../../../src/responses/item-provenance"; +import { rewriteRoutedCustomToolsForUpstream } from "../../../src/responses/custom-tool-compat"; +import type { OcxProviderConfig } from "../../../src/types"; + +const lookup = { + type: "function" as const, + name: "lookup", + description: "finds records", + parameters: { type: "object", properties: { q: { type: "string" } } }, +}; + +function build(providerConfig: OcxProviderConfig, rawBody: Record, replayPrefixLen?: number) { + const adapter = withTestTranslatorBudget(createProductionAdapter(providerConfig)); + const request = adapter.buildRequest({ + modelId: "grok-4.7", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + ...(replayPrefixLen !== undefined ? { _replayPrefixLen: replayPrefixLen } : {}), + }); + return { request, body: JSON.parse(request.body) as Record }; +} + +describe("xAI Responses additional_tools promotion", () => { + test("custom-tool lowering carries historical and live provenance into promotion", () => { + const input = tagHistoricalInputItems([ + { type: "additional_tools", tools: [{ type: "custom", name: "retired", description: "past tool" }] }, + { type: "additional_tools", tools: [lookup] }, + ], 1); + const lowered = rewriteRoutedCustomToolsForUpstream({ input }, false); + const promoted = promoteResponsesAdditionalTools(lowered.body) as { tools: unknown[] }; + expect(promoted.tools).toEqual([lookup]); + }); + + test("custom-tool lowering cannot reactivate a historical wrapper", () => { + const { body } = build({ + adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", supportsResponsesCustomTools: false, + }, { input: [ + { type: "additional_tools", tools: [{ type: "custom", name: "retired", description: "past tool" }] }, + { type: "additional_tools", tools: [lookup] }, + ] }, 1); + expect(body.tools).toEqual([lookup]); + expect(body.input).toEqual([]); + }); + + test("forward cache-marker cleanup cannot promote historical declarations", () => { + const retired = { ...lookup, name: "retired", prompt_cache_breakpoint: true }; + const raw = { input: [ + { type: "additional_tools", tools: [retired] }, + { type: "additional_tools", tools: [lookup] }, + ] }; + const before = structuredClone(raw); + const { body } = build({ + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", promoteAdditionalTools: true, + }, raw, 1); + expect(body.tools).toEqual([lookup]); + expect(body.input).toEqual([]); + expect(raw).toEqual(before); + }); + + test("forward opt-in preserves live tools after removing the historical identity message", () => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + promoteAdditionalTools: true, + }; + const live = { ...lookup, name: "live" }; + const raw = { + input: [ + { type: "message", role: "developer", content: identifyRoutedModel(CODEX_GPT5_IDENTITY_LINE, "old-model") }, + { type: "additional_tools", tools: [live] }, + ], + }; + const before = structuredClone(raw); + const adapter = withTestTranslatorBudget(createProductionAdapter(provider)); + const request = adapter.buildRequest({ + modelId: "gpt-5", context: { messages: [] }, stream: false, options: {}, + _rawBody: raw, _replayPrefixLen: 1, + }, { headers: new Headers({ authorization: "Bearer caller-fixture", "chatgpt-account-id": "caller-account" }) }); + const body = JSON.parse(request.body); + expect(body.tools).toEqual([live]); + expect(body.input).toEqual([]); + expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(new Headers(request.headers).get("authorization")).toBe("Bearer caller-fixture"); + expect(new Headers(request.headers).get("chatgpt-account-id")).toBe("caller-account"); + expect(raw).toEqual(before); + for (const item of raw.input) expect(Object.getOwnPropertySymbols(item)).toEqual([]); + }); + + test("isXaiResponsesUrl matches official xAI endpoints and rejects lookalikes", () => { + expect(isXaiResponsesUrl("https://api.x.ai/v1/responses")).toBe(true); + expect(isXaiResponsesUrl("https://api.x.ai:443/v1/responses")).toBe(true); + expect(isXaiResponsesUrl("https://cli-chat-proxy.grok.com/v1/responses")).toBe(true); + expect(isXaiResponsesUrl("https://cli-chat-proxy.grok.com:443/v1/responses")).toBe(true); + + expect(isXaiResponsesUrl("http://api.x.ai/v1/responses")).toBe(false); + expect(isXaiResponsesUrl("https://api.x.ai:8443/v1/responses")).toBe(false); + expect(isXaiResponsesUrl("https://api.x.ai.evil.test/v1/responses")).toBe(false); + expect(isXaiResponsesUrl("https://example.com/v1/responses")).toBe(false); + }); + + test("promoteResponsesAdditionalTools strips wrapper from input and moves tools to top-level", () => { + const raw = { + input: [ + { type: "message", role: "user", content: "hello" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + const result = promoteResponsesAdditionalTools(raw) as { input: unknown[]; tools: unknown[] }; + expect(result.input).toEqual([{ type: "message", role: "user", content: "hello" }]); + expect(result.tools).toEqual([lookup]); + }); + + test("promoteResponsesAdditionalTools respects replay prefix length", () => { + const historicalTool = { type: "function", name: "old_tool" }; + const raw = { + input: [ + { type: "additional_tools", tools: [historicalTool] }, + { type: "message", role: "user", content: "turn 1" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + // replayPrefixLength = 2 means index 0 and 1 are historical; only index 2 is current turn + const result = promoteResponsesAdditionalTools(raw, 2) as { input: unknown[]; tools: unknown[] }; + expect(result.input).toEqual([{ type: "message", role: "user", content: "turn 1" }]); + expect(result.tools).toEqual([lookup]); + }); + + test("xAI provider strips additional_tools and promotes declarations on buildRequest", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + headers: { authorization: "Bearer xai-key" }, + }; + + const raw = { + model: "grok-4.7", + input: [ + { type: "message", role: "user", content: "test query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(xaiProvider, raw); + expect(body.input).toEqual([{ type: "message", role: "user", content: "test query" }]); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("cli-chat-proxy.grok.com also promotes additional_tools on buildRequest", () => { + const grokCliProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://cli-chat-proxy.grok.com/v1", + headers: { authorization: "Bearer grok-cli-token" }, + }; + + const raw = { + model: "grok-4.7", + input: [ + { type: "message", role: "user", content: "test query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(grokCliProvider, raw); + expect(body.input).toEqual([{ type: "message", role: "user", content: "test query" }]); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("custom provider with promoteAdditionalTools: true promotes on buildRequest", () => { + const customProvider = { + adapter: "openai-responses", + baseUrl: "https://custom-gateway.internal/v1", + promoteAdditionalTools: true, + headers: { authorization: "Bearer internal-token" }, + }; + + const raw = { + model: "custom-model", + input: [ + { type: "message", role: "user", content: "custom query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(customProvider, raw); + expect(body.input).toEqual([{ type: "message", role: "user", content: "custom query" }]); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("providerConfigSchema validates promoteAdditionalTools as optional boolean", () => { + expect(providerConfigSchema.safeParse({ adapter: "openai-responses", baseUrl: "https://example.com/v1", promoteAdditionalTools: true }).success).toBe(true); + expect(providerConfigSchema.safeParse({ adapter: "openai-responses", baseUrl: "https://example.com/v1", promoteAdditionalTools: false }).success).toBe(true); + expect(providerConfigSchema.safeParse({ adapter: "openai-responses", baseUrl: "https://example.com/v1", promoteAdditionalTools: "true" }).success).toBe(false); + }); + + test("unapproved custom provider without flag leaves input untouched", () => { + const unapprovedProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://unapproved.internal/v1", + headers: { authorization: "Bearer token" }, + }; + + const raw = { + model: "custom-model", + input: [ + { type: "message", role: "user", content: "custom query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(unapprovedProvider, raw); + expect(body.input).toEqual(raw.input); + }); + + test("custom provider with authMode: forward and promoteAdditionalTools: true promotes tools", () => { + const forwardOptInProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://forward-proxy.example.com/v1", + authMode: "forward", + promoteAdditionalTools: true, + }; + + const raw = { + model: "custom-model", + input: [ + { type: "message", role: "user", content: "forward query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(forwardOptInProvider, raw); + expect(body.input).toEqual([{ type: "message", role: "user", content: "forward query" }]); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("custom provider with authMode: forward without flag leaves input untouched", () => { + const forwardNoOptInProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "forward", + }; + + const raw = { + model: "custom-model", + input: [ + { type: "message", role: "user", content: "forward query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(forwardNoOptInProvider, raw); + expect(body.input).toEqual(raw.input); + }); + + test("promoteResponsesAdditionalTools recalculates replay boundary when reasoning items are dropped", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + dropResponsesReasoningItems: true, + headers: { authorization: "Bearer xai-key" }, + }; + + const raw = { + model: "grok-4.7", + input: [ + { type: "reasoning", content: "thinking..." }, + { type: "message", role: "user", content: "hello" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + // Original prefix len is 2 (reasoning + user message). + // When reasoning is dropped, prefix len must adjust from 2 to 1. + // additional_tools moves from index 2 to index 1, which matches the new prefix boundary (>= 1), + // so its tools are promoted to top-level tools. + const { body } = build(xaiProvider, raw, 2); + expect(body.input).toEqual([{ type: "message", role: "user", content: "hello" }]); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("promoteResponsesAdditionalTools recalculates replay boundary when canonical-only items are dropped", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + headers: { authorization: "Bearer xai-key" }, + }; + + const raw = { + model: "grok-4.7", + input: [ + { type: "configuration_update", reasoning: { effort: "high" } }, + { type: "message", role: "user", content: "hello" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + // Original prefix len is 2 (configuration_update + user message). + // When configuration_update is dropped, prefix len must adjust from 2 to 1. + // additional_tools moves from index 2 to index 1, which matches the new prefix boundary (>= 1), + // so its tools are promoted to top-level tools. + const { body } = build(xaiProvider, raw, 2); + expect(body.input).toEqual([{ type: "message", role: "user", content: "hello" }]); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + test.each(["configuration_update", "reasoning"])("Finding 3: removing the entire %s replay prefix preserves current tools", type => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + dropResponsesReasoningItems: true, + }; + const { body } = build(provider, { + input: [{ type, content: "historical" }, { type: "additional_tools", tools: [lookup] }], + }, 1); + expect(body.tools).toEqual([lookup]); + }); + + test("Finding 1: historical declarations do not deduplicate current declarations before promotion", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + headers: { authorization: "Bearer xai-key" }, + }; + const raw = { + model: "grok-4.7", + input: [ + { type: "additional_tools", tools: [lookup] }, + { type: "message", role: "user", content: "hello" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + const { body } = build(xaiProvider, raw, 2); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("Finding 1: historical namespaced declarations do not deduplicate current namespaced declarations before promotion", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + headers: { authorization: "Bearer xai-key" }, + }; + const namespacedLookup = { + type: "namespace", + name: "functions", + tools: [lookup], + }; + const raw = { + model: "grok-4.7", + input: [ + { type: "additional_tools", tools: [namespacedLookup] }, + { type: "message", role: "user", content: "hello" }, + { type: "additional_tools", tools: [namespacedLookup] }, + ], + }; + const { body } = build(xaiProvider, raw, 2); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("Finding 2: newly loaded search tools survive when first additional_tools wrapper is historical", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + headers: { authorization: "Bearer xai-key" }, + }; + const raw = { + model: "grok-4.7", + input: [ + { type: "additional_tools", tools: [{ type: "function", name: "old_tool" }] }, + { type: "tool_search_output", tools: [lookup] }, + ], + }; + const { body } = build(xaiProvider, raw, 1); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("Finding 1: a historical bare declaration does not suppress the live functions namespace", () => { + const { body } = build({ adapter: "openai-responses", baseUrl: "https://api.x.ai/v1" }, { + input: [ + { type: "additional_tools", tools: [lookup] }, + { type: "additional_tools", tools: [{ type: "namespace", name: "functions", tools: [lookup] }] }, + ], + }, 1); + expect(body.tools).toEqual([lookup]); + }); + + test("Finding 3: synthesizing missing historical tool output does not reactivate retired tools", () => { + const pWithPairing: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + requiresPairedResponsesToolResults: true, + headers: { authorization: "Bearer xai-key" }, + }; + const raw = { + model: "grok-4.7", + input: [ + { type: "function_call", call_id: "c1", name: "some_call" }, + { type: "additional_tools", tools: [{ type: "function", name: "retired_tool" }] }, + ], + }; + const { body } = build(pWithPairing, raw, 2); + const retired = (body.tools as any[] ?? []).find(t => t.name === "retired_tool"); + expect(retired).toBeUndefined(); + }); +}); diff --git a/tests/providers/xai/xai-loaded-tool-provenance.test.ts b/tests/providers/xai/xai-loaded-tool-provenance.test.ts new file mode 100644 index 00000000000..70586cacfc2 --- /dev/null +++ b/tests/providers/xai/xai-loaded-tool-provenance.test.ts @@ -0,0 +1,128 @@ +import { expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../../src/adapters/openai-responses"; +import { promoteClientLoadedTools } from "../../../src/adapters/openai-responses/tool-schema"; +import { tagHistoricalInputItems } from "../../../src/responses/item-provenance"; +import { handleResponses } from "../../../src/server/responses"; +import type { OcxConfig } from "../../../src/types"; +import { acquireOwnedSpendHome } from "../../helpers/owned-spend-home"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; + +const live = { type: "function", name: "live", parameters: { type: "object", properties: {} } }; +const retired = { type: "web_search", name: "retired_search" }; +const current = { type: "web_search", name: "current_search" }; +const wrap = (tools: unknown[]) => ({ type: "additional_tools", tools }); +const discovery = (tools: unknown[]) => ({ type: "tool_search_output", call_id: "search", tools }); +const provider = { + adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", authMode: "key", + apiKey: "fixture-key", xaiResponsesXSearch: true, +}; + +for (const catalog of ["wrapper", "top-level", "fallback"] as const) { + for (const historical of [true, false]) { + test(`${catalog}: ${historical ? "historical" : "current"} discoveries keep their authority boundary`, () => { + const input = [discovery([retired]), ...(catalog === "wrapper" ? [wrap([live])] : [])]; + const raw = { input, ...(catalog === "top-level" ? { tools: [live] } : {}) }; + const before = JSON.stringify(raw); + const body = JSON.parse(withTestTranslatorBudget(createResponsesPassthroughAdapter(provider)).buildRequest({ + modelId: "grok-4.7", context: { messages: [] }, stream: false, options: {}, + _rawBody: raw, _replayPrefixLen: historical ? 1 : 0, + }, { headers: new Headers() }).body); + const declared = catalog === "fallback" ? [] : [live]; + const active = catalog === "wrapper" ? [{ type: "x_search" }, ...declared, retired] + : [...declared, retired, { type: "x_search" }]; + expect(body.tools ?? []).toEqual(historical ? declared : active); + expect(JSON.stringify(raw)).toBe(before); + expect(input.every(item => Object.getOwnPropertySymbols(item).length === 0)).toBe(true); + }); + } + + test(`${catalog}: source tags override stale positions after reordering`, () => { + const [oldResult, liveResult, wrapper] = tagHistoricalInputItems([ + discovery([retired]), discovery([current]), wrap([live]), + ], 1); + const body = { + input: [liveResult, oldResult, ...(catalog === "wrapper" ? [wrapper] : [])], + ...(catalog === "top-level" ? { tools: [live] } : {}), + }; + const loaded = promoteClientLoadedTools(body, body.input.length) as { + tools?: unknown[]; input: Array<{ type: string; tools?: unknown[] }>; + }; + const declared = catalog === "fallback" ? [] : [live]; + expect(catalog === "wrapper" ? loaded.input.at(-1)?.tools : loaded.tools).toEqual([...declared, current]); + expect(body.input).toEqual([liveResult, oldResult, ...(catalog === "wrapper" ? [wrapper] : [])]); + }); + + test(`${catalog}: untagged callers use the original replay prefix for discoveries`, () => { + const body = { + input: [discovery([retired]), discovery([current]), ...(catalog === "wrapper" ? [wrap([live])] : [])], + ...(catalog === "top-level" ? { tools: [live] } : {}), + }; + const loaded = promoteClientLoadedTools(body, 1) as { + tools?: unknown[]; input: Array<{ type: string; tools?: unknown[] }>; + }; + expect(catalog === "wrapper" ? loaded.input.at(-1)?.tools : loaded.tools) + .toEqual([...(catalog === "fallback" ? [] : [live]), current]); + }); +} + +for (const catalog of ["wrapper", "top-level", "fallback"] as const) { + for (const source of ["historical", "current", "absent"] as const) { + test(`two-turn ${catalog}: ${source} discovery retains only current hosted declarations`, async () => { + const savedFetch = globalThis.fetch; + const releaseHome = acquireOwnedSpendHome(); + const responseId = `resp_loaded_provenance_${catalog}_${source}`; + const outbound: Array<{ tools?: Array> }> = []; + const hostedCall = { + type: "custom_tool_call", id: "ctc_provenance", call_id: "xs_call-round3", + name: "x_keyword_search", input: "{}", status: "completed", + }; + globalThis.fetch = (async (_input, init) => { + outbound.push(JSON.parse(String(init?.body))); + return Response.json({ + id: outbound.length === 1 ? responseId : `${responseId}_next`, status: "completed", + output: outbound.length === 1 + ? [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "done" }] }] + : [hostedCall], + }); + }) as typeof fetch; + const config: OcxConfig = { port: 0, defaultProvider: "fixture", providers: { fixture: provider } }; + const post = (body: Record) => handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/grok-4.7", stream: false, ...body }), + }), config, { model: "", provider: "" }); + try { + const first = await post({ input: [ + wrap([{ type: "web_search" }]), ...(source === "historical" ? [discovery([retired])] : []), + { role: "user", content: "old search" }, + ] }); + expect(first.status).toBe(200); + await first.text(); + const second = await post({ + previous_response_id: responseId, + input: [ + ...(catalog === "wrapper" ? [wrap([live])] : []), + ...(source === "current" ? [discovery([current])] : []), + { role: "user", content: "continue" }, + ], + ...(catalog === "top-level" ? { tools: [live] } : {}), + }); + const result = await second.json() as { output?: unknown[]; error?: { code?: string } }; + expect(outbound).toHaveLength(2); + // An omitted catalog leaves call handling to the client; an explicit live catalog + // activates the server guard. Both paths must exclude historical outbound authority. + const accepted = source === "current" || catalog === "fallback"; + expect(second.status).toBe(accepted ? 200 : 502); + if (accepted) expect(result.output).toEqual([hostedCall]); + else expect(result.error?.code).toBe("undeclared_tool_call"); + const declared = catalog === "fallback" ? [] : [live]; + const active = catalog === "wrapper" ? [{ type: "x_search" }, ...declared, current] + : [...declared, current, { type: "x_search" }]; + expect(outbound[1]?.tools ?? []) + .toEqual(source === "current" ? active : declared); + } finally { + globalThis.fetch = savedFetch; + releaseHome(); + } + }); + } +} diff --git a/tests/providers/xai/xai-replay-provenance.test.ts b/tests/providers/xai/xai-replay-provenance.test.ts new file mode 100644 index 00000000000..fdcc0b619af --- /dev/null +++ b/tests/providers/xai/xai-replay-provenance.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../../src/adapters/openai-responses"; +import { repairOrphanedInputItems, restoreBridgedWebSearchCalls } from "../../../src/adapters/openai-responses/tool-output-recovery"; +import { injectXaiResponsesXSearch } from "../../../src/adapters/xai-web-search"; +import { liftZedAdditionalTools } from "../../../src/adapters/zed-additional-tools"; +import { currentTurnWireToolCatalogBody } from "../../../src/server/responses-undeclared-tool-guard"; +import { hasItemProvenance, isHistoricalItem, tagHistoricalInputItems } from "../../../src/responses/item-provenance"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; +import { rememberBridgeSearchReplay } from "../../../src/responses/bridge-search-replay-cache"; + +const live = { type: "function", name: "live", parameters: { type: "object", properties: {} } }; +const wrap = (tools: unknown[]) => ({ type: "additional_tools", tools }); +const xai = { adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", xaiResponsesXSearch: true }; +const task = { type: "function_call_output", id: "seed", name: "task", namespace: "agent", output: "old task" }; + +function build(seed: unknown) { + return JSON.parse(withTestTranslatorBudget(createResponsesPassthroughAdapter({ + ...xai, requiresPairedResponsesToolResults: true, + })).buildRequest({ + modelId: "grok-4.7", context: { messages: [] }, stream: false, options: {}, + _rawBody: { input: [seed, wrap([{ type: "web_search" }]), wrap([live])] }, _replayPrefixLen: 2, + }, { headers: new Headers() }).body); +} + +test("control: historical user seed does not reactivate historical hosted search", () => { + expect(build({ type: "message", role: "user", content: "old task" }).tools).toEqual([live]); +}); + +test("external task-input replacement must not reactivate historical hosted search", () => { + expect(build(task).tools).toEqual([live]); +}); + +for (const [name, seed, synthesize] of [ + ["external task", task, false], + ["orphan output", { type: "function_call_output", call_id: "old", output: "result" }, false], + ["synthetic output", { type: "function_call", call_id: "old", name: "lookup", arguments: "{}" }, true], +] as const) { + test(`${name} replacement retains both historical and live provenance`, () => { + for (const historical of [true, false]) { + const tagged = tagHistoricalInputItems([{ type: "message" }, seed], historical ? 2 : 1); + const body = repairOrphanedInputItems({ input: tagged }, false, synthesize) as { input: unknown[] }; + const replacement = body.input.at(-1); + expect(hasItemProvenance(replacement)).toBe(true); + expect(isHistoricalItem(replacement)).toBe(historical); + expect(Object.getOwnPropertySymbols(seed)).toEqual([]); + } + }); +} + +test("xAI injection ignores historical web search after a live item moves ahead of it", () => { + const [historical, current] = tagHistoricalInputItems([wrap([{ type: "web_search" }]), wrap([live])], 1); + const body = { input: [current, historical] }; + expect(injectXaiResponsesXSearch(body, xai, 0)).toBe(body); +}); + +test("bridge restoration retains historical and live provenance on both restored items", () => { + const scope = "xai-replay-provenance-fixture"; + rememberBridgeSearchReplay(scope, "ws_provenance", { + callId: "search_provenance", name: "web_search", argumentsText: "{}", output: "fixture result", + }); + for (const historical of [true, false]) { + const input = tagHistoricalInputItems([ + { type: "message" }, { type: "web_search_call", id: "ws_provenance" }, + ], historical ? 2 : 1); + const restored = restoreBridgedWebSearchCalls({ input }, scope) as { input: unknown[] }; + expect(restored.input).toHaveLength(3); + for (const item of restored.input.slice(1)) { + expect(hasItemProvenance(item)).toBe(true); + expect(isHistoricalItem(item)).toBe(historical); + } + } +}); + +test("historical x_search cannot suppress injection for a current web-search wrapper", () => { + const [historical, current] = tagHistoricalInputItems([ + wrap([{ type: "x_search" }]), wrap([{ type: "web_search" }]), + ], 1); + expect(injectXaiResponsesXSearch({ input: [current, historical] }, xai, 0)) + .toMatchObject({ tools: [{ type: "x_search" }] }); +}); + +test("Zed promotion reads provenance after input reordering", () => { + const [historical, current] = tagHistoricalInputItems([wrap([{ ...live, name: "retired" }]), wrap([live])], 1); + const body: Record = { input: [current, historical] }; + liftZedAdditionalTools(body, 0); + expect(body.tools).toEqual([live]); + expect(body.input).toEqual([]); +}); + +test("current-turn authorization reads provenance after input reordering", () => { + const [historical, current] = tagHistoricalInputItems([wrap([{ type: "x_search" }]), wrap([live])], 1); + expect(currentTurnWireToolCatalogBody({ input: [current, historical] }, 0)) + .toEqual({ input: [current] }); +}); diff --git a/tests/providers/zed-hosted-provider.test.ts b/tests/providers/zed-hosted-provider.test.ts index 2046c1d6670..a3763b2a8d4 100644 --- a/tests/providers/zed-hosted-provider.test.ts +++ b/tests/providers/zed-hosted-provider.test.ts @@ -446,6 +446,28 @@ describe("Zed OpenAI tool relay", () => { expect(rawBefore).toContain("reasoning"); }); + test("a current tool_search_output stays current after a replayed wrapper is lifted", async () => { + catalogFetch(); + const lookup = { type: "function", name: "lookup", parameters: { type: "object", properties: {} } }; + const parsed = parseRequest({ + model: "gpt-6.1-sol", + input: [ + { type: "additional_tools", tools: [{ type: "function", name: "old_tool", parameters: { type: "object", properties: {} } }] }, + { type: "tool_search_output", call_id: "search", status: "completed", execution: "client", tools: [lookup] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "now" }] }, + ], + tools: [], + stream: true, + }); + parsed._zedAuthContext = { userId: "user-1" }; + parsed._replayPrefixLen = 1; + const adapter = withTestTranslatorBudget(createZedAdapter(provider)); + const request = await adapter.buildRequest(parsed); + const body = JSON.parse(request.body).provider_request; + expect(JSON.stringify(body.input)).not.toContain("old_tool"); + expect(body.tools.map((tool: { name?: string }) => tool.name)).toEqual(["lookup"]); + }); + async function relay(frames: unknown[]): Promise { catalogFetch(); const parsed = parseRequest({ model: "gpt-6.1-sol", input: "hello", stream: true }); diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index feee0328653..d5bbb818b3d 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -1579,7 +1579,8 @@ describe("OpenAI Responses passthrough sanitization", () => { for (const lite of [false, true]) { const body = build(lite); - const tools = lite ? body.input[0]?.tools : body.tools; + const tools = body.tools; + if (lite) expect(body.input).toEqual([]); expect(tools?.map(tool => tool.name)).toEqual([ "mcp__codex_app__safe_union", "mcp__codex_app__plain", @@ -2478,8 +2479,8 @@ describe("OpenAI Responses passthrough sanitization", () => { tool_choice: Record; }; - expect(body.tools).toBeUndefined(); - expect(body.input[0]?.tools).toEqual([{ type: "web_search" }]); + expect(body.tools).toEqual([{ type: "web_search" }]); + expect(body.input).toEqual([]); expect(body.tool_choice).toEqual({ type: "web_search" }); }); @@ -2641,10 +2642,7 @@ describe("OpenAI Responses passthrough sanitization", () => { tools: [{ type: "function", name: "read", defer_loading: true, parameters: {} }], }, ], - input: [{ - type: "additional_tools", - tools: [{ type: "function", name: "loose", defer_loading: true, parameters: {} }], - }], + input: [{ type: "additional_tools", tools: [{ type: "function", name: "loose", defer_loading: true, parameters: {} }] }], }, }, { headers: new Headers() }); const body = JSON.parse(request.body) as { @@ -2655,7 +2653,9 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.tools[0]).toEqual({ type: "web_search" }); expect(body.tools[1]).toMatchObject({ type: "function", name: "workspace__read" }); expect(body.tools[1]).not.toHaveProperty("defer_loading"); - expect(body.input[0].tools[0]).not.toHaveProperty("defer_loading"); + expect(body.tools[2]).toMatchObject({ type: "function", name: "loose" }); + expect(body.tools[2]).not.toHaveProperty("defer_loading"); + expect(body.input).toEqual([]); }); test("preserves prompt_cache_key in the raw Responses passthrough body", () => { diff --git a/tests/responses/responses-muse-tool-choice.test.ts b/tests/responses/responses-muse-tool-choice.test.ts index 277335ee1ef..08683ea88a3 100644 --- a/tests/responses/responses-muse-tool-choice.test.ts +++ b/tests/responses/responses-muse-tool-choice.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; +import { normalizeMuseToolCatalog } from "../../src/adapters/openai-responses/muse-tool-catalog"; +import { tagHistoricalInputItems } from "../../src/responses/item-provenance"; import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; @@ -442,3 +444,17 @@ describe("Meta Muse additional tool catalogs", () => { } }); }); + +test("Meta catalog follows item provenance once an earlier rewrite shortened the input", () => { + const schema = { type: "object", properties: {} }; + const tagged = tagHistoricalInputItems([ + { type: "additional_tools", tools: [{ type: "function", name: "old_tool", parameters: schema }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "earlier" }] }, + { type: "tool_search_output", call_id: "search", status: "completed", execution: "client", tools: [{ type: "function", name: "lookup", parameters: schema }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "now" }] }, + ], 2); + // Zed lifted the replayed wrapper before the delegate ran, so the numeric prefix of 2 is stale. + const body = normalizeMuseToolCatalog({ model: "muse", input: tagged.slice(1) }, 2) as { tools?: { name?: string }[]; input: unknown[] }; + expect(body.tools?.map(tool => tool.name)).toEqual(["lookup"]); + expect(JSON.stringify(body.input)).not.toContain("old_tool"); +}); diff --git a/tests/server/management-provider-compat-carry.test.ts b/tests/server/management-provider-compat-carry.test.ts index b8ce5a41b7d..5722e8211de 100644 --- a/tests/server/management-provider-compat-carry.test.ts +++ b/tests/server/management-provider-compat-carry.test.ts @@ -127,6 +127,22 @@ const CASES: Record = { return (JSON.parse(String(built.body)) as { input: Array<{ content: unknown }> }).input[0]!.content; }, }, + promoteAdditionalTools: { + seed: { promoteAdditionalTools: true }, + probe: provider => { + const parsed = parseRequest({ + model: MODEL, + input: [ + { type: "message", role: "user", content: "hi" }, + { type: "additional_tools", tools: [tool] }, + ], + }); + parsed.modelId = MODEL; + const built = withTestTranslatorBudget(createResponsesPassthroughAdapter({ ...provider, adapter: "openai-responses" })).buildRequest(parsed, { headers: new Headers() }); + const body = JSON.parse(String(built.body)) as { input: Array<{ type: string }>; tools?: unknown[] }; + return { inputTypes: body.input.map(i => i.type), hasTools: Array.isArray(body.tools) && body.tools.length > 0 }; + }, + }, foldDeveloperRoleToSystem: { seed: { foldDeveloperRoleToSystem: true }, probe: provider => (nativeBody(provider, { @@ -287,6 +303,25 @@ describe("an overwrite that moves the provider carries none of it", () => { }); describe("PATCH and POST validate the settings they name", () => { + test("PATCH writes and clears promoteAdditionalTools", async () => { + await withServer({ relay: { adapter: "openai-responses", baseUrl: BASE_URL } }, async url => { + const set = await send(url, "/api/providers?name=relay", "PATCH", { promoteAdditionalTools: true }); + expect(set.status).toBe(200); + expect(loadConfig().providers.relay?.promoteAdditionalTools).toBe(true); + + const disable = await send(url, "/api/providers?name=relay", "PATCH", { promoteAdditionalTools: false }); + expect(disable.status).toBe(200); + expect(loadConfig().providers.relay?.promoteAdditionalTools).toBe(false); + + const bad = await send(url, "/api/providers?name=relay", "PATCH", { promoteAdditionalTools: "yes" }); + expect(bad.status).toBe(400); + + const clear = await send(url, "/api/providers?name=relay", "PATCH", { promoteAdditionalTools: null }); + expect(clear.status).toBe(200); + expect(loadConfig().providers.relay).not.toHaveProperty("promoteAdditionalTools"); + }); + }); + test("PATCH writes and clears foldDeveloperRoleToSystem and reasoningWireFormat", async () => { await withServer({ relay: { adapter: "openai-chat", baseUrl: BASE_URL } }, async url => { const set = await send(url, "/api/providers?name=relay", "PATCH", { diff --git a/tests/server/server-xai-responses-streaming.test.ts b/tests/server/server-xai-responses-streaming.test.ts index 8a2298972c9..03adfc36dac 100644 --- a/tests/server/server-xai-responses-streaming.test.ts +++ b/tests/server/server-xai-responses-streaming.test.ts @@ -477,11 +477,12 @@ describe("xAI OAuth Responses streaming opt-in", () => { type: string; tools?: Array<{ type: string; name?: string }>; }> | undefined; - const outboundTools = outboundInput?.find(item => item.type === "additional_tools")?.tools; + expect(outboundInput?.find(item => item.type === "additional_tools")).toBeUndefined(); + const outboundTools = outboundBody?.tools as Array<{ type: string; name?: string }> | undefined; expect(outboundTools?.some(tool => tool.type === "namespace")).toBe(false); expect(outboundTools?.find(tool => tool.name === "exec")?.type).toBe("function"); expect(outboundTools?.find(tool => tool.name === "collaboration__spawn_agent")?.type).toBe("function"); - expect(outboundBody?.tools).toEqual([{ type: "web_search" }]); + expect(outboundTools?.find(tool => tool.type === "web_search")).toEqual({ type: "web_search" }); const payloads = clientText .split(/\r?\n/)