From ab6a1c5981f719e82aa19d6bc8d271e1a1a4e023 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 10:51:08 +0900 Subject: [PATCH 1/4] feat(claude): carry Claude Code's Advisor server tool on the translated Messages path Claude Code declares Anthropic's typed Advisor tool (advisor_20260301) with the advisor-tool-2026-03-01 beta. On the translated Messages path the declaration was dropped, and inside a combo with the native Messages lane on it marked every translated child native-only, so the request failed with 529. - The declaration rides the out-of-body server-tool lane. planAnthropicServerTools (src/adapters/anthropic/advisor-tool.ts) forwards it, with the client's completed Advisor history and the beta, to an anthropic-adapter provider on first-party Anthropic, or to a gateway with the new per-provider forwardAdvisorTool: true. false opts out; the Zed delegate sets it because its wire is Zed. - hasNativeOnlyTool treats the Advisor as carried and optional: destinations without it drop the declaration and the turn runs unadvised. - The proxy Advisor's synthetic advisor tool owns the name for its turn; the declaration is withheld and the request log records advisor-tool-shadowed-by-proxy-advisor. - A declaration whose model the caller's admission scope does not admit is not forwarded (advisor-tool-outside-model-scope), mirroring the native lane's refusal. - server_tool_use(advisor) + advisor_tool_result (advisor_result, advisor_redacted_result, advisor_tool_result_error) reach Claude Code verbatim with their srvtoolu_ ids on the stream, JSON and direct-encoder lanes, and replay only with an empty call input and a documented result variant. Encrypted content is never read, rewritten or logged. - forwardAdvisorTool: types, schema, rename roster, field policy, GET, PATCH set/clear, and same-destination carry (dropped on a destination move). Closes #8448 --- .../fr/reference/configuration/providers.md | 8 +- .../src/content/docs/guides/claude-code.md | 35 +- .../ja/reference/configuration/providers.md | 8 +- .../ko/reference/configuration/providers.md | 8 +- .../docs/reference/configuration/providers.md | 9 +- .../ru/reference/configuration/providers.md | 8 +- .../tr/reference/configuration/providers.md | 8 +- .../reference/configuration/providers.md | 8 +- .../reference/configuration/providers.md | 8 +- .../test-layout/layout-server-inbound.json | 2 + src/adapters/anthropic.ts | 42 +- src/adapters/anthropic/advisor-tool.ts | 141 +++++++ src/adapters/zed.ts | 3 + src/claude/inbound-content-options.ts | 42 +- src/claude/outbound.ts | 7 +- src/config/schema/leaf-validators.ts | 1 + src/protocols/contract.ts | 4 + src/providers/model-rename-fields.ts | 1 + src/server/auth-cors.ts | 1 + .../management/provider-overwrite-carry.ts | 11 + src/server/management/provider-routes.ts | 1 + src/server/responses/request-prepare.ts | 13 +- src/server/responses/sidecar-execution.ts | 12 + src/types/provider.ts | 7 + structure/advisor.md | 17 + structure/clients/claude-desktop.md | 2 +- structure/data-planes/protocol-paths.md | 4 +- structure/providers-and-adapters.md | 2 +- .../claude-translated-advisor-tool.test.ts | 389 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 + tests/providers/zed-hosted-provider.test.ts | 19 + .../management-provider-compat-carry.test.ts | 7 + ...ment-provider-forward-advisor-tool.test.ts | 102 +++++ 33 files changed, 864 insertions(+), 68 deletions(-) create mode 100644 src/adapters/anthropic/advisor-tool.ts create mode 100644 tests/claude-integration/claude-translated-advisor-tool.test.ts create mode 100644 tests/server/management-provider-forward-advisor-tool.test.ts 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 7214b3e422d..d665dc4ea74 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -204,17 +204,17 @@ 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. Treize d'entre eux décrivent le comportement d'un amont précis : `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`, `unsupportedHostedTools`, `unsupportedHostedToolsWithClientTools`. +`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. Quatorze d'entre eux décrivent le comportement d'un amont précis : `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`, `unsupportedHostedTools`, `unsupportedHostedToolsWithClientTools`, `forwardAdvisorTool`. -| Sauvegarde | Les treize réglages | `apiKeyPool` enregistré | +| Sauvegarde | Les quatorze 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é | | Champ envoyé dans la requête | La valeur de la requête | La valeur de la requête | -La destination est l'adaptateur, l'URL de base (schéma et hôte comparés sans tenir compte de la casse, barres obliques finales ignorées) et, lorsque la requête en indique un, le mode d'authentification. Déplacer un fournisseur vers une autre destination abandonne les treize réglages, qui décrivent l'amont précédent, ainsi que le pool de clés, dont les clés ont été émises pour lui. Une sauvegarde ne fusionne jamais le reste de l'ancienne ligne dans la nouvelle. +La destination est l'adaptateur, l'URL de base (schéma et hôte comparés sans tenir compte de la casse, barres obliques finales ignorées) et, lorsque la requête en indique un, le mode d'authentification. Déplacer un fournisseur vers une autre destination abandonne les quatorze réglages, qui décrivent l'amont précédent, ainsi que le pool de clés, dont les clés ont été émises pour lui. Une sauvegarde ne fusionne jamais le reste de l'ancienne ligne dans la nouvelle. -`PATCH /api/providers?name=` ne modifie que les champs qu'il nomme et conserve tous les autres champs enregistrés, quelle que soit la destination. Il accepte les treize réglages ; `null` en efface un. Pour les deux listes de raisonnement, un tableau vide est enregistré comme refus explicite au lieu d'être supprimé. +`PATCH /api/providers?name=` ne modifie que les champs qu'il nomme et conserve tous les autres champs enregistrés, quelle que soit la destination. Il accepte les quatorze réglages ; `null` en efface un. Pour les deux listes de raisonnement, un tableau vide est enregistré comme refus explicite au lieu d'être supprimé. ## Sécurité des connexions sortantes de diagnostic des fournisseurs diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 1e82c07d20a..4696087a907 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -1049,7 +1049,7 @@ The proxy translates every Anthropic Messages API request into the Codex Respons | User `tool_result` | `function_call_output` (`is_error` → `[tool error]` prefix) | | `tool_reference` in a tool result | Text `Tool loaded: ` in the paired result by default. The [append-only opt-in](#preserve-cache-prefixes-when-client-tools-load) may place a currently declared deferred function at its successful client discovery result on an eligible final Responses route; a reference alone never grants a tool or enables server-side search | | `thinking` / `redacted_thinking` replay | `reasoning` items with bounded `ocxr1` envelopes for signatures and redacted payloads | -| Function tools | `{type: "function"}` (`web_search*` → `{type: "web_search"}`). On a route to `api.anthropic.com`, the typed `web_search_*` and `web_fetch_*` declarations are also sent to Anthropic as declared and run server-side, unless the web-search sidecar supplies `web_search` itself. A search or fetch with a matching call and complete input comes back to the client as its `server_tool_use` block and its `web_search_tool_result` or `web_fetch_tool_result`, verbatim. Orphan results are dropped without synthesizing a call, while the surrounding text is preserved. When the client sends a complete pair back in its history, the next `api.anthropic.com` request carries it in place only if the following block uniquely identifies its original turn. Repeated anchors, unmatched or duplicate call/result ids, malformed call input, or a result appearing before its call drop the exchange. Call input must be an object with a nonempty search query or fetch URL string; other destinations never see the exchange Hosted search with `allowed_domains`, `blocked_domains` or `user_location` is omitted on translated Responses/Chat routes; forcing omitted search returns 400. Valid `defer_loading` function definitions remain callable as ordinary functions. See [translation limits](#search-restrictions-and-deferred-tools-on-translated-routes). | +| Function tools | `{type: "function"}` (`web_search*` → `{type: "web_search"}`). On a route to `api.anthropic.com`, the typed `web_search_*` and `web_fetch_*` declarations are also sent to Anthropic as declared and run server-side, unless the web-search sidecar supplies `web_search` itself. A search or fetch with a matching call and complete input comes back to the client as its `server_tool_use` block and its `web_search_tool_result` or `web_fetch_tool_result`, verbatim. Orphan results are dropped without synthesizing a call, while the surrounding text is preserved. When the client sends a complete pair back in its history, the next `api.anthropic.com` request carries it in place only if the following block uniquely identifies its original turn. Repeated anchors, unmatched or duplicate call/result ids, malformed call input, or a result appearing before its call drop the exchange. Call input must be an object with a nonempty search query or fetch URL string; other destinations never see the exchange. The typed `advisor_*` declaration follows [its own destination rules](#anthropics-advisor-tool-on-translated-routes). Hosted search with `allowed_domains`, `blocked_domains` or `user_location` is omitted on translated Responses/Chat routes; forcing omitted search returns 400. Valid `defer_loading` function definitions remain callable as ordinary functions. See [translation limits](#search-restrictions-and-deferred-tools-on-translated-routes). | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, named function→`{type:"function",name}`, hosted WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | @@ -1079,6 +1079,39 @@ rejection. Enforce mode still rejects unsupported top-level deferral, schema-les server-executed discovery and unsupported discovery history. Eligible direct Responses routes can use the [append-only opt-in](#preserve-cache-prefixes-when-client-tools-load). +### Anthropic's Advisor tool on translated routes + +Recent Claude Code builds declare Anthropic's Advisor server tool (`advisor_20260301`) with the +`advisor-tool-2026-03-01` beta. Anthropic runs the advisor model server-side inside the same +request and returns a `server_tool_use` block named `advisor` followed by an +`advisor_tool_result`. On the translated path OpenCodex handles it this way: + +- An `anthropic`-adapter provider on first-party `https://api.anthropic.com` receives the + declaration as sent and the beta header. An Anthropic-compatible gateway receives them only when + its provider sets [`forwardAdvisorTool: true`](/reference/configuration/providers/), because a + gateway without the Advisor would reject every Claude Code request that declares it. + `forwardAdvisorTool: false` turns it off for first-party Anthropic too. +- Every other destination, including GPT, Gemini and gateways without the opt-in, drops the + declaration and runs the turn without advice. An Advisor declaration never blocks a combo child + or a fallback. +- When the OpenCodex [Advisor](/reference/configuration/advisor/) is active for a turn, its own + `advisor` tool owns the name and the declared tool is not forwarded. The request log records + the protocol reason `advisor-tool-shadowed-by-proxy-advisor`. +- A declaration whose `model` the caller's API key scope does not admit is not forwarded, and the + request log records `advisor-tool-outside-model-scope`. The managed native Messages lane + refuses such a request instead. +- The `server_tool_use` and `advisor_tool_result` blocks reach Claude Code unchanged, with their + `srvtoolu_` ids, for streaming and non-streaming requests. That covers the plaintext + `advisor_result`, the encrypted `advisor_redacted_result` and `advisor_tool_result_error`. + OpenCodex does not read, rewrite or log the encrypted content. +- When Claude Code sends a completed exchange back in its history, the next request to a + destination that receives the declaration carries it in place, with the beta. The call input + must be an empty object and the result one of the three documented variants; anything else is + dropped, as it is for every other destination. +- An Advisor call still pending when Anthropic ends the response with `pause_turn` is not + surfaced or resumed. The resend still carries the declaration and the beta, so the model can + consult again. + ### Unicode-property patterns in tool schemas A JSON Schema `pattern` written for JavaScript may use Unicode property escapes such as 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 0b2b9086498..79de770e65a 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -194,17 +194,17 @@ API キープロバイダーは、リテラルキーまたは環境参照を保 ### プロバイダーの保存で保持されるもの -既存のプロバイダー名で `POST /api/providers` を送ると、保存済みの行はリクエストから組み立てた行で置き換えられます。ダッシュボードの追加・編集フォームはすべてのフィールドを送れないため、リクエストが省略した保存済みフィールドの一部は保存時に引き継がれます。そのうち次の 13 個は、特定のアップストリームの挙動を記録するものです:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`, `unsupportedHostedTools`, `unsupportedHostedToolsWithClientTools`。 +既存のプロバイダー名で `POST /api/providers` を送ると、保存済みの行はリクエストから組み立てた行で置き換えられます。ダッシュボードの追加・編集フォームはすべてのフィールドを送れないため、リクエストが省略した保存済みフィールドの一部は保存時に引き継がれます。そのうち次の 14 個は、特定のアップストリームの挙動を記録するものです:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`, `unsupportedHostedTools`, `unsupportedHostedToolsWithClientTools`, `forwardAdvisorTool`。 -| 保存 | 13 個の設定 | 保存済みの `apiKeyPool` | +| 保存 | 14 個の設定 | 保存済みの `apiKeyPool` | | --- | --- | --- | | 同じ宛先、フィールド省略 | 保存済みの値を保持(明示的な `[]` や `false` を含む) | 保持 | | 新しい宛先、フィールド省略 | 保持しない。新しい宛先のレジストリ既定値が適用されることがあります | 保持しない | | リクエストでフィールドを送信 | リクエストの値 | リクエストの値 | -宛先とは、アダプター、ベース URL(スキームとホストは大文字小文字を区別せず、末尾のスラッシュは無視)、そしてリクエストが指定した場合は認証モードです。別の宛先へ移すと、以前のアップストリームを表す 13 個の設定と、そのために発行されたキーのプールは引き継がれません。保存時に古い行の残りを新しい行へマージすることはありません。 +宛先とは、アダプター、ベース URL(スキームとホストは大文字小文字を区別せず、末尾のスラッシュは無視)、そしてリクエストが指定した場合は認証モードです。別の宛先へ移すと、以前のアップストリームを表す 14 個の設定と、そのために発行されたキーのプールは引き継がれません。保存時に古い行の残りを新しい行へマージすることはありません。 -`PATCH /api/providers?name=` は指定したフィールドだけを変更し、宛先に関係なくほかの保存済みフィールドはすべて保持します。13 個の設定をすべて受け付け、`null` で消去します。2 つの推論リストでは、空の配列は削除されず明示的なオプトアウトとして保存されます。 +`PATCH /api/providers?name=` は指定したフィールドだけを変更し、宛先に関係なくほかの保存済みフィールドはすべて保持します。14 個の設定をすべて受け付け、`null` で消去します。2 つの推論リストでは、空の配列は削除されず明示的なオプトアウトとして保存されます。 ## プロバイダーによるアウトバウンドの安全性診断 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 156978ba6e7..90a4b3d5cb2 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -194,17 +194,17 @@ API 키 공급자는 리터럴 키나 환경 참조를 둘 수 있습니다. OAu ### 프로바이더 저장이 유지하는 것 -기존 프로바이더 이름으로 `POST /api/providers`를 보내면 저장된 행이 요청으로 만든 행으로 바뀝니다. 대시보드의 추가/편집 폼은 모든 필드를 보낼 수 없으므로, 요청이 빠뜨린 저장 필드 일부는 저장할 때 이어서 유지됩니다. 그중 열세 가지는 특정 업스트림의 동작을 기록한 설정입니다: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`, `unsupportedHostedTools`, `unsupportedHostedToolsWithClientTools`. +기존 프로바이더 이름으로 `POST /api/providers`를 보내면 저장된 행이 요청으로 만든 행으로 바뀝니다. 대시보드의 추가/편집 폼은 모든 필드를 보낼 수 없으므로, 요청이 빠뜨린 저장 필드 일부는 저장할 때 이어서 유지됩니다. 그중 열네 가지는 특정 업스트림의 동작을 기록한 설정입니다: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`, `unsupportedHostedTools`, `unsupportedHostedToolsWithClientTools`, `forwardAdvisorTool`. -| 저장 | 열세 가지 설정 | 저장된 `apiKeyPool` | +| 저장 | 열네 가지 설정 | 저장된 `apiKeyPool` | | --- | --- | --- | | 같은 목적지, 필드 생략 | 저장된 값 유지(명시적인 `[]`나 `false` 포함) | 유지 | | 새 목적지, 필드 생략 | 유지하지 않음. 새 목적지의 레지스트리 기본값이 적용될 수 있음 | 유지하지 않음 | | 요청에 필드를 보냄 | 요청의 값 | 요청의 값 | -목적지는 어댑터, 기본 URL(스킴과 호스트는 대소문자를 구분하지 않고, 끝의 슬래시는 무시), 그리고 요청이 지정한 경우 인증 모드입니다. 프로바이더를 다른 목적지로 옮기면 이전 업스트림을 설명하는 열세 가지 설정과, 그 업스트림용으로 발급된 키 풀을 가져가지 않습니다. 저장은 이전 행의 나머지를 새 행에 병합하지 않습니다. +목적지는 어댑터, 기본 URL(스킴과 호스트는 대소문자를 구분하지 않고, 끝의 슬래시는 무시), 그리고 요청이 지정한 경우 인증 모드입니다. 프로바이더를 다른 목적지로 옮기면 이전 업스트림을 설명하는 열네 가지 설정과, 그 업스트림용으로 발급된 키 풀을 가져가지 않습니다. 저장은 이전 행의 나머지를 새 행에 병합하지 않습니다. -`PATCH /api/providers?name=`는 지정한 필드만 바꾸고, 목적지와 상관없이 나머지 저장 필드는 모두 유지합니다. 열세 가지 설정을 모두 받고, `null`로 지웁니다. 두 추론 목록에서 빈 배열은 삭제되지 않고 명시적인 옵트아웃으로 저장됩니다. +`PATCH /api/providers?name=`는 지정한 필드만 바꾸고, 목적지와 상관없이 나머지 저장 필드는 모두 유지합니다. 열네 가지 설정을 모두 받고, `null`로 지웁니다. 두 추론 목록에서 빈 배열은 삭제되지 않고 명시적인 옵트아웃으로 저장됩니다. ## 공급자 진단용 외부 요청 안전성 diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 1fbccbfcec6..d3fd194a755 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -434,6 +434,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `noVisionModels?` | `string[]` | Text-only models sent through the vision sidecar; matching tolerates an Ollama `:size` tag. | | `escapeBuiltinToolNames?` | `boolean` | Escape built-in tool names for Anthropic-compatible gateways and restore them in returned calls. | | `anthropicEofTolerance?` | `boolean` | Let an Anthropic-compatible gateway complete a stream that ends before `message_stop`, only when visible text or a complete JSON-object tool input was received. Off by default. | +| `forwardAdvisorTool?` | `boolean` | Claude Code's typed Advisor server tool (`advisor_20260301`) on the translated Messages path. Omitted: only an `anthropic`-adapter provider on first-party `https://api.anthropic.com` receives the declaration, the client's completed Advisor exchanges and the `advisor-tool-2026-03-01` beta. `true`: an Anthropic-compatible gateway that runs the Advisor receives them too. `false`: never forwarded, including first-party. Other destinations drop the declaration and the turn runs without advice. An active OpenCodex Advisor owns the `advisor` name for its turns. See [Anthropic's Advisor tool on translated routes](/guides/claude-code/#anthropics-advisor-tool-on-translated-routes). A same-destination save keeps an omitted value and a destination move drops it; `PATCH` accepts a boolean or `null` to clear it. | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google transport/auth mode. Default `ai-studio`. | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Google only. Omitted/`compatible` preserves the compatible tool schema and existing non-direct 400 repair. `reject-lossy` rejects initial schema loss or an indeterminate bounded comparison before dispatch and withholds a Vertex or Cloud Code Assist repair that would open constraints. Direct AI Studio never performs that repair. | | `directGeminiWireRenames?` | `boolean` | Google only. Applies in AI Studio mode, including custom Google-compatible URLs. Omitted or `true` keeps the `-tiered` wire rename for Gemini Flash ids (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`); `false` sends the requested bare ids to the wire unchanged. Vertex and canonical keyed OpenCode Zen routes preserve the requested model ID regardless of this setting; Cloud Code Assist routing is unchanged. Set `false` when the configured upstream still serves the bare ids. | @@ -517,21 +518,21 @@ 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. Thirteen of them record how one upstream behaves: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`, `unsupportedHostedTools` and `unsupportedHostedToolsWithClientTools`. +`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. Fourteen of them record how one upstream behaves: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`, `unsupportedHostedTools`, `unsupportedHostedToolsWithClientTools` and `forwardAdvisorTool`. -| Save | The thirteen settings | Stored `apiKeyPool` | +| Save | The fourteen 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 | | Field sent in the request | The request's value | The request's value | -The destination is the adapter, the base URL (scheme and host compared without regard to case, trailing slashes ignored) and, when the request names one, the auth mode. Moving a provider to another destination drops the thirteen settings because they describe the previous upstream, and drops the key pool because its keys were issued for it. A save never merges the rest of the old row into the new one. The provider-level `modelDiscovery` block follows the same destination rule: a same-destination save that omits it keeps it, and a move drops it. +The destination is the adapter, the base URL (scheme and host compared without regard to case, trailing slashes ignored) and, when the request names one, the auth mode. Moving a provider to another destination drops the fourteen settings because they describe the previous upstream, and drops the key pool because its keys were issued for it. A save never merges the rest of the old row into the new one. The provider-level `modelDiscovery` block follows the same destination rule: a same-destination save that omits it keeps it, and a move drops it. `hideRawReasoning` is an operator display policy, so a save of the same provider name keeps its stored value whenever the request omits it, including after a destination move. A value sent in the request wins, including `false`. The optional `inferContextWindow` setting also survives same-name saves that omit it, including destination moves. Its read, update, and clear behavior is described under [Context windows on custom providers](#context-windows-on-custom-providers). -`PATCH /api/providers?name=` changes only the fields it names and keeps every other stored field, whatever the destination. It accepts all thirteen settings; `null` clears one. It also accepts `modelDiscovery`, where `null` or `{}` clears it. It also accepts a boolean `hideRawReasoning` or `null` to clear it. For the two reasoning lists an empty array is stored as an explicit opt-out rather than removed. +`PATCH /api/providers?name=` changes only the fields it names and keeps every other stored field, whatever the destination. It accepts all fourteen settings; `null` clears one. It also accepts `modelDiscovery`, where `null` or `{}` clears it. It also accepts a boolean `hideRawReasoning` or `null` to clear it. For the two reasoning lists an empty array is stored as an explicit opt-out rather than removed. With `webSearchBridge` enabled, a search continuation stays bound to the API-key selection that served the first request. Changing the selected key, its reference or resolved value, authentication 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 60fc6480ae5..dcda2748acd 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -210,17 +210,17 @@ Claude Code настраивается через ### Что сохраняется при сохранении провайдера -`POST /api/providers` с именем существующего провайдера заменяет сохранённую строку строкой, собранной из запроса. Форма добавления и редактирования в панели не может отправить все поля, поэтому сохранение переносит часть сохранённых полей, которые запрос опустил. Тринадцать из них описывают поведение конкретного апстрима: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`, `unsupportedHostedTools`, `unsupportedHostedToolsWithClientTools`. +`POST /api/providers` с именем существующего провайдера заменяет сохранённую строку строкой, собранной из запроса. Форма добавления и редактирования в панели не может отправить все поля, поэтому сохранение переносит часть сохранённых полей, которые запрос опустил. Четырнадцать из них описывают поведение конкретного апстрима: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`, `unsupportedHostedTools`, `unsupportedHostedToolsWithClientTools`, `forwardAdvisorTool`. -| Сохранение | Тринадцать настроек | Сохранённый `apiKeyPool` | +| Сохранение | Четырнадцать настроек | Сохранённый `apiKeyPool` | | --- | --- | --- | | Та же точка назначения, поле опущено | Сохранённое значение остаётся, включая явный `[]` или `false` | Остаётся | | Новая точка назначения, поле опущено | Не переносится; могут примениться значения реестра по умолчанию для новой точки | Не переносится | | Поле передано в запросе | Значение из запроса | Значение из запроса | -Точка назначения — это адаптер, базовый URL (схема и хост сравниваются без учёта регистра, завершающие слеши игнорируются) и, если запрос его указывает, режим аутентификации. При переносе провайдера на другую точку назначения тринадцать настроек, описывающих прежний апстрим, отбрасываются, как и пул ключей, выпущенных для него. Сохранение никогда не сливает остальную часть старой строки с новой. +Точка назначения — это адаптер, базовый URL (схема и хост сравниваются без учёта регистра, завершающие слеши игнорируются) и, если запрос его указывает, режим аутентификации. При переносе провайдера на другую точку назначения четырнадцать настроек, описывающих прежний апстрим, отбрасываются, как и пул ключей, выпущенных для него. Сохранение никогда не сливает остальную часть старой строки с новой. -`PATCH /api/providers?name=` меняет только названные поля и сохраняет все остальные, независимо от точки назначения. Он принимает все тринадцать настроек; `null` очищает настройку. Для двух списков рассуждений пустой массив сохраняется как явный отказ, а не удаляется. +`PATCH /api/providers?name=` меняет только названные поля и сохраняет все остальные, независимо от точки назначения. Он принимает все четырнадцать настроек; `null` очищает настройку. Для двух списков рассуждений пустой массив сохраняется как явный отказ, а не удаляется. ## Безопасность исходящих диагностических запросов 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 9e5d16b1897..85d0e618155 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -210,17 +210,17 @@ 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 on üçü belirli bir üst kaynağın nasıl davrandığını kaydeder: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`, `unsupportedHostedTools`, `unsupportedHostedToolsWithClientTools`. +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 dördü belirli bir üst kaynağın nasıl davrandığını kaydeder: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`, `unsupportedHostedTools`, `unsupportedHostedToolsWithClientTools`, `forwardAdvisorTool`. -| Kayıt | On üç ayar | Saklanan `apiKeyPool` | +| Kayıt | On dört 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 | | Alan istekte gönderilmiş | İsteğin değeri | İsteğin değeri | -Hedef; bağdaştırıcı, temel URL (şema ve ana makine büyük/küçük harf gözetmeden karşılaştırılır, sondaki eğik çizgiler yok sayılır) ve istek bir tane belirtiyorsa kimlik doğrulama modudur. Bir sağlayıcıyı başka bir hedefe taşımak, önceki üst kaynağı tanımlayan on üç ayarı ve o kaynak için verilmiş anahtar havuzunu bırakır. Bir kayıt, eski satırın geri kalanını yenisiyle asla birleştirmez. +Hedef; bağdaştırıcı, temel URL (şema ve ana makine büyük/küçük harf gözetmeden karşılaştırılır, sondaki eğik çizgiler yok sayılır) ve istek bir tane belirtiyorsa kimlik doğrulama modudur. Bir sağlayıcıyı başka bir hedefe taşımak, önceki üst kaynağı tanımlayan on dört ayarı ve o kaynak için verilmiş anahtar havuzunu bırakır. Bir kayıt, eski satırın geri kalanını yenisiyle asla birleştirmez. -`PATCH /api/providers?name=` yalnızca adını verdiği alanları değiştirir ve hedef ne olursa olsun diğer tüm saklanan alanları korur. On üç ayarın hepsini kabul eder; `null` birini temizler. İki akıl yürütme listesinde boş dizi silinmez, açık bir vazgeçme olarak saklanır. +`PATCH /api/providers?name=` yalnızca adını verdiği alanları değiştirir ve hedef ne olursa olsun diğer tüm saklanan alanları korur. On dört ayarın hepsini kabul eder; `null` birini temizler. İki akıl yürütme listesinde boş dizi silinmez, açık bir vazgeçme olarak saklanır. ## Sağlayıcı teşhis giden güvenliği 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 68554859a64..7261685bf07 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 @@ -196,17 +196,17 @@ API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者 ### 保存提供方时会保留什么 -用已有提供方的名称调用 `POST /api/providers`,会用根据请求构建的行替换已存储的行。仪表板的添加/编辑表单无法发送所有字段,因此保存时会保留请求省略的部分已存储字段。其中十三个记录的是某个上游的行为:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`, `unsupportedHostedTools`, `unsupportedHostedToolsWithClientTools`。 +用已有提供方的名称调用 `POST /api/providers`,会用根据请求构建的行替换已存储的行。仪表板的添加/编辑表单无法发送所有字段,因此保存时会保留请求省略的部分已存储字段。其中十四个记录的是某个上游的行为:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`, `unsupportedHostedTools`, `unsupportedHostedToolsWithClientTools`, `forwardAdvisorTool`。 -| 保存 | 十三项设置 | 已存储的 `apiKeyPool` | +| 保存 | 十四项设置 | 已存储的 `apiKeyPool` | | --- | --- | --- | | 目的地相同,字段省略 | 保留已存储的值,包括显式的 `[]` 或 `false` | 保留 | | 新目的地,字段省略 | 不保留;可能套用新目的地的注册表默认值 | 不保留 | | 请求中发送了该字段 | 请求中的值 | 请求中的值 | -目的地指适配器、base URL(协议与主机名比较时不区分大小写,忽略末尾斜杠),以及请求中指定了时的认证模式。把提供方移到其他目的地时,描述旧上游的十三项设置和为旧上游签发的密钥池都不会带过去。保存从不把旧行的其余部分合并进新行。 +目的地指适配器、base URL(协议与主机名比较时不区分大小写,忽略末尾斜杠),以及请求中指定了时的认证模式。把提供方移到其他目的地时,描述旧上游的十四项设置和为旧上游签发的密钥池都不会带过去。保存从不把旧行的其余部分合并进新行。 -`PATCH /api/providers?name=` 只修改它指定的字段,无论目的地如何都保留其他所有已存储字段。它接受全部十三项设置,`null` 表示清除。对于两个推理列表,空数组会作为显式退出选项保存,而不会被删除。 +`PATCH /api/providers?name=` 只修改它指定的字段,无论目的地如何都保留其他所有已存储字段。它接受全部十四项设置,`null` 表示清除。对于两个推理列表,空数组会作为显式退出选项保存,而不会被删除。 ### 响应服务等级的可信度 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 c5765424a01..ce9d5c3e257 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 @@ -167,17 +167,17 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 ### 儲存供應商時會保留什麼 -以既有供應商的名稱呼叫 `POST /api/providers`,會以根據請求建立的列取代已儲存的列。儀表板的新增/編輯表單無法傳送所有欄位,因此儲存時會保留請求省略的部分已儲存欄位。其中十三個記錄的是某個上游的行為:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`, `unsupportedHostedTools`, `unsupportedHostedToolsWithClientTools`。 +以既有供應商的名稱呼叫 `POST /api/providers`,會以根據請求建立的列取代已儲存的列。儀表板的新增/編輯表單無法傳送所有欄位,因此儲存時會保留請求省略的部分已儲存欄位。其中十四個記錄的是某個上游的行為:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`, `unsupportedHostedTools`, `unsupportedHostedToolsWithClientTools`, `forwardAdvisorTool`。 -| 儲存 | 十三項設定 | 已儲存的 `apiKeyPool` | +| 儲存 | 十四項設定 | 已儲存的 `apiKeyPool` | | --- | --- | --- | | 目的地相同,欄位省略 | 保留已儲存的值,包括明確的 `[]` 或 `false` | 保留 | | 新目的地,欄位省略 | 不保留;可能套用新目的地的登錄檔預設值 | 不保留 | | 請求中傳送了該欄位 | 請求中的值 | 請求中的值 | -目的地指轉接器、base URL(比較協定與主機時不分大小寫,忽略結尾斜線),以及請求有指定時的驗證模式。把供應商移到其他目的地時,描述舊上游的十三項設定和為舊上游核發的金鑰池都不會帶過去。儲存絕不會把舊列的其餘部分合併進新列。 +目的地指轉接器、base URL(比較協定與主機時不分大小寫,忽略結尾斜線),以及請求有指定時的驗證模式。把供應商移到其他目的地時,描述舊上游的十四項設定和為舊上游核發的金鑰池都不會帶過去。儲存絕不會把舊列的其餘部分合併進新列。 -`PATCH /api/providers?name=` 只修改它指定的欄位,無論目的地為何都保留其他所有已儲存欄位。它接受全部十三項設定,`null` 表示清除。對於兩個推理清單,空陣列會作為明確的退出選項儲存,而不會被刪除。 +`PATCH /api/providers?name=` 只修改它指定的欄位,無論目的地為何都保留其他所有已儲存欄位。它接受全部十四項設定,`null` 表示清除。對於兩個推理清單,空陣列會作為明確的退出選項儲存,而不會被刪除。 ## 供應商診斷對外安全 diff --git a/scripts/test-layout/layout-server-inbound.json b/scripts/test-layout/layout-server-inbound.json index 58b9b8933dc..ea06db0ab22 100644 --- a/scripts/test-layout/layout-server-inbound.json +++ b/scripts/test-layout/layout-server-inbound.json @@ -1,4 +1,6 @@ { + "claude-translated-advisor-tool.test.ts": "claude-integration", + "management-provider-forward-advisor-tool.test.ts": "server", "local-control-socket.test.ts": "server", "config-concurrent-inbound-body.test.ts": "config", "inbound-body-admission.test.ts": "server", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 87a4071f22d..12ebbe0c30e 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -41,6 +41,7 @@ import { resolveReasoningCapabilities } from "../providers/reasoning-capabilitie import { declaresThinkingToggleWire, reasoningControlsAreUnsupported } from "./reasoning-controls"; import { applyAgentRouterLanguageFraming, isAgentRouterEndpoint } from "./agentrouter"; import { anthropicCacheWrite1hTokens, resolveAnthropicCacheRetention, type AnthropicCacheRetention } from "./anthropic/cache-retention"; +import { advisorServerExchange, planAnthropicServerTools } from "./anthropic/advisor-tool"; import { claudeFamilyVersion, rejectsCombinedSampling, rejectsForcedToolChoice, rejectsSamplingParameters, supportsExplicitThinkingDisable, usesAdaptiveThinking, usesBetweenToolsFloor } from "./anthropic-model-contract"; /** Map a user content part to an Anthropic content block (text or image source). */ @@ -958,13 +959,14 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: ( } /** - * Appends the Claude client's typed web tools (`parsed._anthropicServerTools`) so Anthropic runs - * them server-side. A same-named function tool wins: the web-search sidecar's `web_search` keeps - * priority, and Anthropic rejects duplicate tool names. + * Appends the Claude client's typed server tools this destination receives + * (`planAnthropicServerTools`) so Anthropic runs them server-side. A same-named function tool + * wins: the web-search sidecar's `web_search` and the proxy Advisor's `advisor` keep priority, + * and Anthropic rejects duplicate tool names. */ -function withAnthropicServerTools(tools: unknown[] | undefined, parsed: OcxParsedRequest): unknown[] | undefined { - const serverTools = parsed._anthropicServerTools?.filter(t => !parsed.context.tools?.some(f => f.name === t.name)); - if (!serverTools?.length) return tools; +function withAnthropicServerTools(tools: unknown[] | undefined, parsed: OcxParsedRequest, declared: readonly Record[]): unknown[] | undefined { + const serverTools = declared.filter(t => !parsed.context.tools?.some(f => f.name === t.name)); + if (!serverTools.length) return tools; // The caller's breakpoints are dropped on this lane; applyPromptCaching places its own. return [...(tools ?? []), ...serverTools.map(t => ({ ...t, cache_control: undefined }))]; } @@ -1048,9 +1050,12 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti throw new Error("anthropic provider requires a non-empty apiKey (authMode: key)"); } - // Only api.anthropic.com runs Anthropic's server tools, so only it gets their history back. + // Only api.anthropic.com runs Anthropic's web tools, so only it gets their history back; the + // Advisor also reaches a gateway that opted in with forwardAdvisorTool (advisor-tool.ts). + const serverToolPlan = planAnthropicServerTools(provider, parsed._anthropicServerTools, parsed._anthropicServerReplay, + new Set(parsed.context.tools?.map(tool => tool.name)), usesNativeAnthropicEndpoint(provider)); const { system, messages } = messagesToAnthropicFormat(parsed, toolNames, - usesNativeAnthropicEndpoint(provider) ? parsed._anthropicServerReplay : undefined); + serverToolPlan.replay); // Before image normalization, so the framing block is present for every downstream pass. if (isAgentRouterEndpoint(provider.baseUrl)) applyAgentRouterLanguageFraming(messages); // Primary image layer: resize/re-encode to fit Anthropic limits without dropping @@ -1065,7 +1070,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti enforceAnthropicImageLimits(messages); const converted = toolsToAnthropicFormat(parsed, toolNames); // A third-party Anthropic-compatible endpoint may not implement Anthropic's server tools. - const tools = usesNativeAnthropicEndpoint(provider) ? withAnthropicServerTools(converted, parsed) : converted; + const tools = withAnthropicServerTools(converted, parsed, serverToolPlan.declared); // Codex never sends `max_output_tokens`, so the omitted-limit default decides how // long a Claude answer may run. Honor the provider's configured output budget @@ -1300,7 +1305,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti if (isOAuth) applyAnthropicOAuthAuth(headers, provider.apiKey, resolveTranslatedAnthropicSessionKey(parsed, incoming)); else applyAnthropicKeyAuth(headers, provider); if (provider.headers) Object.assign(headers, provider.headers); - mergeAnthropicBetaHeader(headers, [...(fastSpeed?.betas ?? []), ...(progressUpdates ? ["thinking-display-updates-2026-08-18"] : [])]); + mergeAnthropicBetaHeader(headers, [...(fastSpeed?.betas ?? []), ...(progressUpdates ? ["thinking-display-updates-2026-08-18"] : []), ...serverToolPlan.betas]); // Prompt caching: native Anthropic supports top-level automatic caching, which // follows the moving final block across turns. Keep one breakpoint slot free for it. @@ -1354,8 +1359,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti let pendingStopReason: string | undefined; let emittedDone = false; let sawVisibleText = false; - /** The open server-side web_search / web_fetch call, until its result block. */ - let serverSearch: { id: string; json: string; streaming: boolean } | undefined; + /** The open server-side web_search / web_fetch / advisor call, until its result block. */ + let serverSearch: { id: string; name: string; json: string; streaming: boolean } | undefined; const emitDone = function* (): Generator { if (emittedDone) return; @@ -1447,11 +1452,20 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti budget.openCall(currentToolCallId); yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName }; } - if (block.type === "server_tool_use" && (block.name === "web_search" || block.name === "web_fetch") && typeof block.id === "string") { + if (block.type === "server_tool_use" && (block.name === "web_search" || block.name === "web_fetch" || block.name === "advisor") && typeof block.id === "string") { // Only the input (query / url) is collected here (input_json_delta below). The cell // opens at its result: a call Anthropic defers beside a client tool_use has // no result in this response and must not surface as a completed search. - serverSearch = { id: block.id, json: "", streaming: true }; + serverSearch = { id: block.id, name: block.name, json: "", streaming: true }; + } + if (block.type === "advisor_tool_result") { + // The Advisor rides the same lane: both blocks verbatim, keyed by the upstream srvtoolu_ id. + const exchange = advisorServerExchange(serverSearch, block as Record); + if (!exchange) break; + yield { type: "web_search_call_begin", id: exchange.blocks.use.id as string }; + yield { type: "web_search_call_end", id: exchange.blocks.use.id as string, queries: [], + status: exchange.completed ? "completed" : "failed", anthropic: exchange.blocks }; + serverSearch = undefined; } if ((block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result") && typeof block.tool_use_id === "string") { // The begin/end pair the sidecar emits, plus both blocks verbatim so the Claude client holds a diff --git a/src/adapters/anthropic/advisor-tool.ts b/src/adapters/anthropic/advisor-tool.ts new file mode 100644 index 00000000000..3be49991231 --- /dev/null +++ b/src/adapters/anthropic/advisor-tool.ts @@ -0,0 +1,141 @@ +/** + * Anthropic's typed Advisor server tool (`advisor_20260301`, beta `advisor-tool-2026-03-01`) on the + * translated Messages lane (#8448). Claude Code declares it by default; Anthropic runs the advisor + * model server-side inside the same request and returns `server_tool_use` (name `advisor`, empty + * input) followed by `advisor_tool_result`. Both blocks are kept verbatim for the next turn. + * + * Pure helpers shared by the inbound replay (`src/claude/inbound-content-options.ts`), the + * adapter (`src/adapters/anthropic.ts`) and the Claude outbound. Nothing here reads, logs or + * rewrites a result's text or `encrypted_content`; validation only checks field types. + */ +import { credentialDomainFor } from "../../protocols/opaque-state"; +import type { OcxProviderConfig } from "../../types"; + +type Rec = Record; + +export const ADVISOR_TOOL_BETA = "advisor-tool-2026-03-01"; +export const ADVISOR_SERVER_TOOL_NAME = "advisor"; + +function isPlainRecord(value: unknown): value is Rec { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** A typed Advisor declaration (`advisor_YYYYMMDD`), the same shape the native lane's scope check reads. */ +export function isAdvisorServerToolType(type: unknown): boolean { + return typeof type === "string" && /^advisor_\d{8}$/.test(type); +} + +/** + * Whether a destination receives the declaration: an `anthropic`-adapter provider on first-party + * Anthropic by default, an Anthropic-compatible gateway only with `forwardAdvisorTool: true`. + * An explicit `false` keeps it off even `api.anthropic.com` (a relay that borrows the + * first-party body builder, like Zed's, sets it). + */ +export function advisorServerToolDestination(provider: Pick): boolean { + if (provider.adapter !== "anthropic" || provider.forwardAdvisorTool === false) return false; + return provider.forwardAdvisorTool === true || credentialDomainFor(provider)?.firstPartyAnthropic === true; +} + +/** The call input Anthropic documents: always an empty object. */ +export function isAdvisorCallInput(input: unknown): input is Rec { + return isPlainRecord(input) && Object.keys(input).length === 0; +} + +/** A streamed call's assembled `input_json_delta` fragments; none at all is the empty input. */ +export function advisorCallInputFromJson(json: string): Rec | undefined { + if (json.trim() === "") return {}; + try { + const input = JSON.parse(json) as unknown; + return isAdvisorCallInput(input) ? input : undefined; + } catch { + return undefined; + } +} + +const RESULT_FIELDS: Readonly> = { + advisor_result: { required: "text", optional: ["stop_reason"] }, + advisor_redacted_result: { required: "encrypted_content", optional: ["stop_reason"] }, + advisor_tool_result_error: { required: "error_code", optional: [] }, +}; + +/** + * `advisor_tool_result.content`: exactly one documented variant with string fields and no others — + * `advisor_result` (`text`), `advisor_redacted_result` (`encrypted_content`), each with an + * optional `stop_reason`, or `advisor_tool_result_error` (`error_code`). + */ +export function isAdvisorResultContent(content: unknown): boolean { + if (!isPlainRecord(content) || typeof content.type !== "string" || !Object.hasOwn(RESULT_FIELDS, content.type)) return false; + const fields = RESULT_FIELDS[content.type]!; + if (typeof content[fields.required] !== "string") return false; + return Object.keys(content).every(key => key === "type" || key === fields.required + || (fields.optional.includes(key) && typeof content[key] === "string")); +} + +/** Whether a declared catalog or replay run names the Advisor. */ +export function hasAdvisorDeclaration(tools: readonly Rec[] | undefined): boolean { + return tools?.some(tool => isAdvisorServerToolType(tool.type)) === true; +} + +function runHasAdvisor(run: readonly Rec[]): boolean { + return run.some(block => block.type === "server_tool_use" && block.name === ADVISOR_SERVER_TOOL_NAME); +} + +function runHasOtherServerTool(run: readonly Rec[]): boolean { + return run.some(block => block.type === "server_tool_use" && block.name !== ADVISOR_SERVER_TOOL_NAME); +} + +export interface AnthropicServerToolPlan { + /** Typed declarations this destination receives, in caller order. */ + declared: Rec[]; + /** Replayable runs this destination receives. */ + replay?: ReadonlyMap; + /** Betas the forwarded Advisor declaration or history needs. */ + betas: string[]; +} + +/** + * Split the caller's out-of-body server tools by destination. Web tools keep their own audience + * (`webDestination`); the Advisor goes to `advisorServerToolDestination` unless a function tool of + * the same name owns it this turn (the proxy Advisor's synthetic tool, or a client function). A + * shadowed or ineligible turn drops both the declaration and the Advisor history, as before #8448. + */ +export function planAnthropicServerTools( + provider: Pick, + declared: readonly Rec[] | undefined, + replay: ReadonlyMap | undefined, + functionNames: ReadonlySet, + webDestination: boolean, +): AnthropicServerToolPlan { + const advisor = advisorServerToolDestination(provider) && !functionNames.has(ADVISOR_SERVER_TOOL_NAME); + const tools = (declared ?? []).filter(tool => isAdvisorServerToolType(tool.type) ? advisor : webDestination); + let runs: Map | undefined; + let replaysAdvisor = false; + for (const [anchor, run] of replay ?? []) { + const hasAdvisor = runHasAdvisor(run); + if ((hasAdvisor && !advisor) || (runHasOtherServerTool(run) && !webDestination)) continue; + replaysAdvisor ||= hasAdvisor; + (runs ??= new Map()).set(anchor, run); + } + const betas = advisor && (replaysAdvisor || hasAdvisorDeclaration(tools)) ? [ADVISOR_TOOL_BETA] : []; + return { declared: tools, ...(runs ? { replay: runs } : {}), betas }; +} + +/** + * A streamed `advisor_tool_result` paired with the open `server_tool_use` of the same response. + * Undefined when the call is missing, its id differs, its input is not the documented empty object, + * or the result content is not a documented variant: the exchange is dropped, never synthesized. + */ +export function advisorServerExchange( + call: { id: string; name?: string; json: string } | undefined, + result: Rec, +): { blocks: { use: Rec; result: Rec }; completed: boolean } | undefined { + if (!call || call.name !== ADVISOR_SERVER_TOOL_NAME || call.id !== result.tool_use_id) return undefined; + const input = advisorCallInputFromJson(call.json); + if (!input || !isAdvisorResultContent(result.content)) return undefined; + return { + blocks: { use: { type: "server_tool_use", id: call.id, name: ADVISOR_SERVER_TOOL_NAME, input }, result }, + completed: (result.content as Rec).type !== "advisor_tool_result_error", + }; +} diff --git a/src/adapters/zed.ts b/src/adapters/zed.ts index e156a59748c..a8963599695 100644 --- a/src/adapters/zed.ts +++ b/src/adapters/zed.ts @@ -48,6 +48,9 @@ function delegateProvider(provider: OcxProviderConfig, zedProvider: ZedProvider) apiKey: "zed-delegate-placeholder", models: undefined, liveModels: false, + // The anthropic delegate names api.anthropic.com only to borrow its body builder; the wire goes + // to Zed, which is not the first-party host the Advisor server tool defaults to (#8448). + forwardAdvisorTool: false, }; if (zedProvider === "anthropic") { return { ...common, adapter: "anthropic", baseUrl: "https://api.anthropic.com" }; diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts index bf67365bc33..0d22110f475 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -1,4 +1,5 @@ import { markProxyError } from "../lib/client-error-body"; +import { ADVISOR_SERVER_TOOL_NAME, isAdvisorCallInput, isAdvisorResultContent, isAdvisorServerToolType } from "../adapters/anthropic/advisor-tool"; import { isClaudeWebSearchToolName } from "./outbound"; import { AnthropicRequestError, isRec, type Rec } from "./inbound-records"; @@ -99,17 +100,23 @@ export function toolsToResponses(tools: unknown): Rec[] | undefined { } /** - * The caller's typed Anthropic web tools (`web_search_*`, `web_fetch_*`), as declared. The - * Responses body has no slot for them — web_search becomes the hosted sidecar tool above and - * web_fetch is dropped — so an Anthropic destination receives them out of body, unchanged. + * The caller's typed Anthropic server tools (`web_search_*`, `web_fetch_*`, `advisor_*`), as + * declared. The Responses body has no slot for them — web_search becomes the hosted sidecar tool + * above, web_fetch and the Advisor are dropped — so an Anthropic destination receives them out of + * body, unchanged (`planAnthropicServerTools` picks which ones per destination). */ export function declaredAnthropicServerTools(raw: unknown): Rec[] | undefined { if (!isRec(raw) || !Array.isArray(raw.tools)) return undefined; - const tools = raw.tools.filter((t): t is Rec => isRec(t) && typeof t.type === "string" && /^web_(search|fetch)_\d+$/.test(t.type)); + const tools = raw.tools.filter((t): t is Rec => isRec(t) && typeof t.type === "string" + && (/^web_(search|fetch)_\d+$/.test(t.type) || isAdvisorServerToolType(t.type))); return tools.length > 0 ? tools : undefined; } -/** A declared tool the translated body drops and no out-of-body lane carries (bash_*, text_editor_*, ...). */ +/** + * A declared tool the translated body drops and no out-of-body lane carries (bash_*, text_editor_*, ...). + * The Advisor rides the out-of-body lane and is optional: a destination without it simply runs the + * turn unadvised, so it never makes a translated child native-only. + */ export function hasNativeOnlyTool(raw: Rec): boolean { return Array.isArray(raw.tools) && raw.tools.some(tool => !toolsToResponses([tool]) && !declaredAnthropicServerTools({ tools: [tool] })); @@ -129,18 +136,24 @@ function replayableServerRun(run: readonly Rec[]): boolean { if (block.type === "server_tool_use") { const { id, name, input } = block; if (typeof id !== "string" || !id.startsWith("srvtoolu_") || pending.has(id) || completed.has(id) - || (name !== "web_search" && name !== "web_fetch") || !isRec(input)) return false; - const prototype = Object.getPrototypeOf(input); - const required = input[name === "web_search" ? "query" : "url"]; - if ((prototype !== Object.prototype && prototype !== null) - || typeof required !== "string" || required.trim().length === 0) return false; + || (name !== "web_search" && name !== "web_fetch" && name !== ADVISOR_SERVER_TOOL_NAME) || !isRec(input)) return false; + if (name === ADVISOR_SERVER_TOOL_NAME) { + // Anthropic documents the Advisor's input as always empty. + if (!isAdvisorCallInput(input)) return false; + } else { + const prototype = Object.getPrototypeOf(input); + const required = input[name === "web_search" ? "query" : "url"]; + if ((prototype !== Object.prototype && prototype !== null) + || typeof required !== "string" || required.trim().length === 0) return false; + } pending.set(id, block); } else { const id = block.tool_use_id; if (typeof id !== "string") return false; const call = pending.get(id); - if (!call || block.type !== `${call.name}_tool_result` - || (Array.isArray(block.content) && !block.content.every(h => isRec(h) && typeof h.encrypted_content === "string"))) return false; + if (!call || block.type !== `${call.name}_tool_result`) return false; + if (call.name === ADVISOR_SERVER_TOOL_NAME ? !isAdvisorResultContent(block.content) + : Array.isArray(block.content) && !block.content.every(h => isRec(h) && typeof h.encrypted_content === "string")) return false; pending.delete(id); completed.add(id); } @@ -150,7 +163,7 @@ function replayableServerRun(run: readonly Rec[]): boolean { /** * Completed Anthropic server-tool exchanges (`server_tool_use` + `web_search_tool_result` / - * `web_fetch_tool_result`) in the caller's assistant history. The Responses body drops them, so + * `web_fetch_tool_result` / `advisor_tool_result`) in the caller's assistant history. The Responses body drops them, so * an Anthropic destination re-inserts them out of body, keyed by the block that follows each run * in its message: `call:`, `text:`, `thinking:` or * `redacted:`, all of which survive translation. An anchor that occurs more than once in the @@ -169,7 +182,8 @@ export function replayedAnthropicServerBlocks(raw: unknown): Map if (!Array.isArray(message.content)) continue; let run: Rec[] = []; for (const block of message.content.filter(isRec)) { - if (block.type === "server_tool_use" || block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result") { + if (block.type === "server_tool_use" || block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result" + || block.type === "advisor_tool_result") { // The caller's breakpoints are dropped on this lane, as for the declared tools above. run.push({ ...block, cache_control: undefined }); continue; diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index e40dad22bf8..7fbd7b2ae77 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -3,6 +3,7 @@ import { ownedAnthropicErrorPayload, messagesOverflowPresentation } from "../lib import { proxyMessagePresentation, markProxyError, hasProxyErrorMessage, hasProxyIncomplete } from "../lib/client-error-body"; import { admitMessagesFailure, collectMessagesFailure, type MessagesFailureVerdictSink } from "./failure-verdict"; import { anthropicProgressEnvelope, isAnthropicProgressMirror } from "../responses/anthropic-progress"; +import { ADVISOR_SERVER_TOOL_NAME, isAdvisorCallInput, isAdvisorResultContent } from "../adapters/anthropic/advisor-tool"; /** * Claude Code outbound: internal /v1/responses output -> Anthropic Messages API shapes. * @@ -284,7 +285,8 @@ export function sanitizeWebSearchInput(input: unknown): Rec { * input (query/queries) and the web_search_tool_result content (hits, or the error * object when the search failed). An item carrying `anthropic_blocks` came from Anthropic's * own server-side call and maps back to those blocks verbatim (`srvtoolu_` id, - * `encrypted_content`, web_fetch), so the client can replay them on its next turn. + * `encrypted_content`, web_fetch, the Advisor's `advisor_tool_result`), so the client can replay + * them on its next turn. * Shared by the SSE, JSON and direct translation paths. */ export function webSearchPairFromItem(item: Rec): { @@ -294,7 +296,8 @@ export function webSearchPairFromItem(item: Rec): { const completed = item.status !== "failed"; const { use, result } = isRec(item.anthropic_blocks) ? item.anthropic_blocks : {}; if (isRec(use) && isRec(result) && typeof use.id === "string" && isRec(use.input) - && (use.name === "web_search" || use.name === "web_fetch") + && (use.name === "web_search" || use.name === "web_fetch" + || (use.name === ADVISOR_SERVER_TOOL_NAME && isAdvisorCallInput(use.input) && isAdvisorResultContent(result.content))) && result.tool_use_id === use.id && result.type === `${use.name}_tool_result`) { return { id: use.id, name: use.name, resultType: result.type, input: use.input, resultContent: result.content, completed }; } diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 78be982477d..4edad1c5a29 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -454,6 +454,7 @@ export const providerConfigSchema = z.object({ responsesSnapshotRepair: z.boolean().optional(), messagePhaseInference: z.boolean().optional(), hideRawReasoning: z.boolean().optional(), + forwardAdvisorTool: z.boolean().optional(), // Invalid blocks degrade to "absent" rather than failing the whole config load: an unusable // bridge block must never send an operator through invalid-config recovery for an opt-in // feature that is off by default. The management write boundary still rejects it loudly. diff --git a/src/protocols/contract.ts b/src/protocols/contract.ts index 16dc967735e..424bf644c58 100644 --- a/src/protocols/contract.ts +++ b/src/protocols/contract.ts @@ -80,6 +80,10 @@ export const PROTOCOL_REASON_CODES = [ "anthropic-beta-dropped", /** Thinking signatures or `redacted_thinking` removed for a destination that cannot verify them. */ "opaque-state-stripped", + /** Claude Code's typed Advisor tool was not forwarded: the proxy Advisor's synthetic `advisor` owns the name this turn. */ + "advisor-tool-shadowed-by-proxy-advisor", + /** A typed Advisor declaration named a model outside the caller's admission scope and was not forwarded. */ + "advisor-tool-outside-model-scope", ] as const; export type ProtocolReasonCode = (typeof PROTOCOL_REASON_CODES)[number]; diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index 07543600058..416968453f9 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -152,6 +152,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = { thinkingBudgetModels: "list", escapeBuiltinToolNames: "none", anthropicEofTolerance: "none", + forwardAdvisorTool: "none", noVisionModels: "list", googleMode: "none", googleToolSchemaPolicy: "none", diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index c9056aec260..3e2e03c704e 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1231,6 +1231,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { thinkingBudgetModels: "editor", escapeBuiltinToolNames: "editor", anthropicEofTolerance: "editor", + forwardAdvisorTool: "editor", noVisionModels: "editor", googleMode: "editor", project: "editor", diff --git a/src/server/management/provider-overwrite-carry.ts b/src/server/management/provider-overwrite-carry.ts index f06231bd1d0..e0afd05f5cc 100644 --- a/src/server/management/provider-overwrite-carry.ts +++ b/src/server/management/provider-overwrite-carry.ts @@ -63,6 +63,9 @@ export const PROVIDER_COMPAT_CARRY_FIELDS = [ // forward a tool the dropped unconditional list had denied. "unsupportedHostedTools", "unsupportedHostedToolsWithClientTools", + // Whether this gateway runs Claude Code's Advisor server tool (#8448): a fact about the upstream, + // so a move to another gateway must not keep forwarding it there. + "forwardAdvisorTool", ...RETRY_POLICY_FIELDS, ] as const satisfies readonly (keyof OcxProviderConfig)[]; @@ -193,6 +196,7 @@ export function providerCompatFieldConfigError(provider: Record 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"; + if (provider.forwardAdvisorTool !== undefined && typeof provider.forwardAdvisorTool !== "boolean") return "forwardAdvisorTool 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(", ")}`; @@ -258,6 +262,13 @@ export function applyProviderCompatPatchFields( else return { error: "promoteAdditionalTools must be a boolean" }; touched = true; } + if (Object.hasOwn(rawBody, "forwardAdvisorTool")) { + const value = rawBody.forwardAdvisorTool; + if (value === null) delete next.forwardAdvisorTool; + else if (typeof value === "boolean") next.forwardAdvisorTool = value; + else return { error: "forwardAdvisorTool 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 12debc10978..1e78beb86cc 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -925,6 +925,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise + !nativeMessagesToolScopeDenial(admissionScope, route.providerName, route.modelId, { tools: [tool] })); + if (serverTools && scopedServerTools && scopedServerTools.length !== serverTools.length) { + parsed._anthropicServerTools = scopedServerTools; + if (advisorServerToolDestination(route.provider)) addProtocolEntryReason(logCtx, "advisor-tool-outside-model-scope"); + } // Attribute local auth/cooldown failures to the public selector too; exact auth may fail before // the normal post-resolution provider label is assigned. if (route.codexAccountNamespace) { diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index 7cbfd5d104b..48d2b970bb2 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -11,6 +11,8 @@ import { formatErrorResponse } from "../../bridge"; import { planWebSearch, buildWebSearchTool, runWithWebSearch } from "../../web-search"; import { createRegisteredAdvisorPlan } from "./advisor-plan-slot"; import { ADVISOR_TOOL_NAME, createAdvisorSidecarStep } from "./advisor-slot"; +import { advisorServerToolDestination, hasAdvisorDeclaration, isAdvisorServerToolType } from "../../adapters/anthropic/advisor-tool"; +import { addProtocolEntryReason } from "../../protocols/trace"; import { buildToolBridgeMaps } from "./collaboration"; import { planImageBridge, @@ -205,6 +207,14 @@ export async function executeResponsesSidecars( if (advisorPlan && !routedCompaction) { await advisorPlan.preflightInject(parsed); } + // The synthetic `advisor` owns the name for the whole turn it joins, so a typed Advisor declaration + // is withheld here, not only while the function is on the catalog: a later loop iteration that + // drops the synthetic tool must not forward it either (adapters/anthropic/advisor-tool.ts). + const withholdShadowedAdvisorTool = () => { + if (!hasAdvisorDeclaration(parsed._anthropicServerTools)) return; + if (advisorServerToolDestination(route.provider)) addProtocolEntryReason(logCtx, "advisor-tool-shadowed-by-proxy-advisor"); + parsed._anthropicServerTools = parsed._anthropicServerTools?.filter(tool => !isAdvisorServerToolType(tool.type)); + }; // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority. // Routed-compaction turns must NOT hit the image bridge: compaction disables tool execution but @@ -671,6 +681,7 @@ export async function executeResponsesSidecars( ...(webSearchAdvisor ? [webSearchAdvisor.tool] : []), ]; if (webSearchAdvisor) { + withholdShadowedAdvisorTool(); requestState.toolBridgeMaps = buildToolBridgeMaps(parsed); translatorBudget.chargeRetained(new TextEncoder().encode(ADVISOR_TOOL_NAME).byteLength * 2, { kind: "retained_collectors", @@ -804,6 +815,7 @@ export async function executeResponsesSidecars( ...(parsed.context.tools ?? []).filter(t => !t.advisor && t.name !== ADVISOR_TOOL_NAME), advisorPlan.tool, ]; + withholdShadowedAdvisorTool(); // The advisor tool joined AFTER prepare computed the bridge maps; recompute so the tool is // declared (undeclared-tool guard, tool_choice mapping, schema repair) on this turn. // diff --git a/src/types/provider.ts b/src/types/provider.ts index 1b22c2d2d69..fb170aabb24 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -1168,6 +1168,13 @@ export interface OcxProviderConfig { * other EOFs remain truncation errors. Absent = strict default behavior. */ anthropicEofTolerance?: boolean; + /** + * Claude Code's typed Advisor server tool (`advisor_20260301`) on the translated Messages lane. + * Absent: forwarded only to an `anthropic`-adapter provider on first-party `api.anthropic.com`. + * `true`: an Anthropic-compatible gateway that runs it receives the declaration, its history and + * the `advisor-tool-2026-03-01` beta too. `false`: never forwarded, even first-party. + */ + forwardAdvisorTool?: boolean; /** * Model ids that do NOT accept image inputs. The proxy gives them "eyes" via the vision sidecar: * attached images are described by a gpt vision model and replaced with text before the call. diff --git a/structure/advisor.md b/structure/advisor.md index dbaf0bfdf56..c5739c99a6c 100644 --- a/structure/advisor.md +++ b/structure/advisor.md @@ -78,6 +78,23 @@ multi-call pairing, overlap and lifecycle release. `tests/advisor/advisor-web-search.test.ts` covers the accounting and independent allowances. - Turns claimed by the image/video sidecar loops keep the advisor tool un-injected; preflight still applies. +## Claude Code's typed Advisor server tool + +Claude Code also declares Anthropic's own Advisor server tool (`advisor_YYYYMMDD`, beta +`advisor-tool-2026-03-01`), which Anthropic runs server-side under the same wire name `advisor`. +The two never share a turn. When this sidecar injects its synthetic tool, +`src/server/responses/sidecar-execution.ts` removes the declaration for the whole turn, so a later +web-search iteration without the synthetic tool cannot forward it either, and records the protocol +reason `advisor-tool-shadowed-by-proxy-advisor` for a destination that would otherwise receive it. +`planAnthropicServerTools` (`src/adapters/anthropic/advisor-tool.ts`) also withholds the completed +Advisor history and the beta while a function tool named `advisor` owns the name, the same rule that +keeps the web-search sidecar's `web_search` ahead of a typed search. With +this sidecar off, the declaration reaches first-party Anthropic, or a gateway with +`forwardAdvisorTool: true`, and its blocks round-trip to the client. Destination, replay and scope +rules are in [providers and adapters](providers-and-adapters.md); +`tests/claude-integration/claude-translated-advisor-tool.test.ts` covers both sides of the precedence +through the Responses core. + ## Recursion fence (server-owned authority) The consultation executor calls the proxy's own `/v1/chat/completions` on loopback and presents diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 7af1e19243e..1c79224876e 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -569,7 +569,7 @@ Translated tool results in `src/claude/inbound.ts` retain nonempty string `tool_ `src/claude/inbound.ts` bounds the text-carrier skill-directory probe to 4,096 UTF-16 code units, plus one character to recognize the terminating newline. A longer first line is preserved intact instead of being scanned or stubbed; normal POSIX, Windows, mixed and UNC separators retain their basename matching. The existing 10,000-character payload threshold and `claudeCode.blockedSkills` policy remain: `claude-api` is blocked by default, and an explicit empty list disables elision. Native Anthropic passthrough and tool-call/result pairing are unchanged. `tests/claude-integration/claude-inbound.test.ts` covers the exact 4,096/4,097 boundary and a long newline-free carrier. -`src/claude/inbound-content-options.ts` strips Claude Code's leading `x-anthropic-billing-header:` line from a string system prompt or from the first text block of a system array before it becomes Responses `instructions`, dropping a block left empty. The line's `cch` value rotates per request, so keeping it made the translated prefix and the system-derived fallback `prompt_cache_key` change every turn (#6627). The match is anchored at the prompt start, like the Antigravity strip in `src/adapters/google.ts`; native Anthropic passthrough does not use this translation and keeps the client preamble. `tests/claude-integration/claude-inbound.test.ts` covers string and array systems, header-only blocks, later mentions and key stability. The same module's `declaredAnthropicServerTools` reads the caller's typed `web_search_*` / `web_fetch_*` declarations unchanged; `src/server/claude-messages.ts` hands them to the Responses core out of body, like the native reasoning replay, and the core keeps them on `parsed._anthropicServerTools` only for `inboundWire: "anthropic"`, so native declarations remain separate from the translated catalog. Restricted hosted search is omitted from translated Responses/Chat catalogs and valid deferred functions remain callable under the [translated tool admission contract](../data-planes/claude-tool-loading.md#translated-tool-admission). `replayedAnthropicServerBlocks` does the same for the completed `server_tool_use` + `web_search_tool_result` / `web_fetch_tool_result` runs in the caller's assistant history, which the translated body drops: each run is keyed by the block that follows it (a `tool_use` id, text, a thinking signature or redacted thinking data); an anchor that repeats anywhere in the assistant history, string content included, names no single owner, so its run stays dropped. Each result must follow exactly one matching server call in the same assistant turn, with the same id and tool type; duplicate ids, orphan results and reversed pairs stay dropped. Calls require a plain input object with a nonempty `web_search.query` or `web_fetch.url` string. Validation walks the original block sequence once and never pairs a result with a later call. The adapter also drops a streamed result whose matching call or complete input is unavailable, preserving the text without synthesizing an empty-input call. The map travels as `parsed._anthropicServerReplay`. `tests/claude-integration/claude-translated-server-tools.test.ts` covers the Anthropic wire, a third-party Anthropic-compatible endpoint and a GPT leg. The translated Messages lane preserves the caller's output cap for non-canonical Responses gateways; canonical ChatGPT parameter stripping remains in its adapter. An explicit operator `cacheRetention` wins; otherwise `src/claude/inbound.ts` selects long retention when every declared caller breakpoint requests one hour and short retention when any breakpoint pins another TTL ("5m"), including nested content and top-level automatic caching. Breakpoints without a TTL express no preference and take the destination default from `src/adapters/anthropic/cache-retention.ts` (1h on first-party `api.anthropic.com`, 5m elsewhere). +`src/claude/inbound-content-options.ts` strips Claude Code's leading `x-anthropic-billing-header:` line from a string system prompt or from the first text block of a system array before it becomes Responses `instructions`, dropping a block left empty. The line's `cch` value rotates per request, so keeping it made the translated prefix and the system-derived fallback `prompt_cache_key` change every turn (#6627). The match is anchored at the prompt start, like the Antigravity strip in `src/adapters/google.ts`; native Anthropic passthrough does not use this translation and keeps the client preamble. `tests/claude-integration/claude-inbound.test.ts` covers string and array systems, header-only blocks, later mentions and key stability. The same module's `declaredAnthropicServerTools` reads the caller's typed `web_search_*` / `web_fetch_*` / `advisor_*` declarations unchanged; `src/server/claude-messages.ts` hands them to the Responses core out of body, like the native reasoning replay, and the core keeps them on `parsed._anthropicServerTools` only for `inboundWire: "anthropic"`, so native declarations remain separate from the translated catalog. Restricted hosted search is omitted from translated Responses/Chat catalogs and valid deferred functions remain callable under the [translated tool admission contract](../data-planes/claude-tool-loading.md#translated-tool-admission). `replayedAnthropicServerBlocks` does the same for the completed `server_tool_use` + `web_search_tool_result` / `web_fetch_tool_result` / `advisor_tool_result` runs in the caller's assistant history, which the translated body drops: each run is keyed by the block that follows it (a `tool_use` id, text, a thinking signature or redacted thinking data); an anchor that repeats anywhere in the assistant history, string content included, names no single owner, so its run stays dropped. Each result must follow exactly one matching server call in the same assistant turn, with the same id and tool type; duplicate ids, orphan results and reversed pairs stay dropped. Calls require a plain input object with a nonempty `web_search.query` or `web_fetch.url` string. An Advisor call requires an empty input object and its result content exactly one documented variant with string fields (`advisor_result.text` or `advisor_redacted_result.encrypted_content`, each with an optional `stop_reason`, or `advisor_tool_result_error.error_code`); `hasNativeOnlyTool` counts the Advisor as carried, so it never makes a translated combo child native-only. Validation walks the original block sequence once and never pairs a result with a later call. The adapter also drops a streamed result whose matching call or complete input is unavailable, preserving the text without synthesizing an empty-input call. The map travels as `parsed._anthropicServerReplay`. `tests/claude-integration/claude-translated-server-tools.test.ts` covers the Anthropic wire, a third-party Anthropic-compatible endpoint and a GPT leg. The translated Messages lane preserves the caller's output cap for non-canonical Responses gateways; canonical ChatGPT parameter stripping remains in its adapter. An explicit operator `cacheRetention` wins; otherwise `src/claude/inbound.ts` selects long retention when every declared caller breakpoint requests one hour and short retention when any breakpoint pins another TTL ("5m"), including nested content and top-level automatic caching. Breakpoints without a TTL express no preference and take the destination default from `src/adapters/anthropic/cache-retention.ts` (1h on first-party `api.anthropic.com`, 5m elsewhere). `src/claude/outbound.ts` carries a Responses provider's own `encrypted_content` in an `ocxr1:` `nat` envelope (`src/responses/reasoning-envelope.ts`) only when the serving attempt has committed and `src/server/responses/core-replay.ts` supplied a durable destination and credential owner. The `nat.tag` HMAC binds the exact blob to that owner and the installation's persisted salt. `src/claude/inbound.ts` restores the blob and item id only for the same client-facing model; each route binding verifies the tag against its effective endpoint, including `responsesPath`, and generation-bearing credential, then removes a mismatch from both the raw reasoning item and the parser's thinking-signature carrier before send. Missing owner or tag fails closed, including direct-forward credentials and OAuth generation changes. Native Anthropic ingress projects away every `nat` on both native branches, dropping blob-only blocks and leaving signed or visible-text fields intact. A thinking block that closes before serving commit has no `nat`. Translated bodies that reason request `include: ["reasoning.encrypted_content"]`; a body without reasoning does not. Anthropic `sig` and `red` envelopes keep their existing behavior. `tests/claude-integration/claude-native-reasoning-provenance.test.ts` covers route ownership and native projection. diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index dd6af0298b4..e580a1d6970 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -376,7 +376,7 @@ probe uses the bridge's credential-free affinity headers, existing plan and auth short-lived selection lease; uncertain admission keeps the bridge. Without a runnable plan search alone does not decline native Messages. `count_tokens` and the planner lack runtime facts and remain conservative. -Failover combos reuse this lane for eligible concrete Anthropic children, including key-auth compatible endpoints, through the existing `core-combo-native.ts` source/dispatcher seam. OAuth remains first-party only. Only strict failover without a default effort qualifies; policy/JEV/other strategies and bridge-only rewrites retain the bridge. The shared executor inherits the combo's attempt, target budget, prepaid permit and gated callbacks, opening no second tracker/final row. Key source system/messages/tools/cache markers retain the native allowlist, image/tool-id safety; explicit operator TTL wins, otherwise TTL-less combo breakpoints stay unchanged. OAuth keeps its preamble/metadata/tool transformations. Counts/previews still decline logical combos without picking accounts. A source declaring a tool the translated body drops with no out-of-body lane (`hasNativeOnlyTool` in `src/claude/inbound-content-options.ts`: `bash_*`, `text_editor_*`, ...; unrestricted `web_search_*` stays portable, while `web_fetch_*` and a domain- or location-restricted `web_search_*` stay portable only to a first-party Anthropic bridge child) makes every translated child unpickable under any `unrepresentable` policy, so a refused native child surfaces its own failure instead of continuing without the tool; non-combo translated routes are unchanged. [Commit boundaries](../transports/responses-combo-boundaries.md) own failover and preserve upstream read evidence before OAuth framing; `tests/claude-integration/messages-native-combo.test.ts` covers the boundary and mocked prefix preservation, not live cache-hit metrics. +Failover combos reuse this lane for eligible concrete Anthropic children, including key-auth compatible endpoints, through the existing `core-combo-native.ts` source/dispatcher seam. OAuth remains first-party only. Only strict failover without a default effort qualifies; policy/JEV/other strategies and bridge-only rewrites retain the bridge. The shared executor inherits the combo's attempt, target budget, prepaid permit and gated callbacks, opening no second tracker/final row. Key source system/messages/tools/cache markers retain the native allowlist, image/tool-id safety; explicit operator TTL wins, otherwise TTL-less combo breakpoints stay unchanged. OAuth keeps its preamble/metadata/tool transformations. Counts/previews still decline logical combos without picking accounts. A source declaring a tool the translated body drops with no out-of-body lane (`hasNativeOnlyTool` in `src/claude/inbound-content-options.ts`: `bash_*`, `text_editor_*`, ...; unrestricted `web_search_*` stays portable, while `web_fetch_*` and a domain- or location-restricted `web_search_*` stay portable only to a first-party Anthropic bridge child; a typed `advisor_*` declaration rides that lane too but is optional, so it never makes a child unpickable) makes every translated child unpickable under any `unrepresentable` policy, so a refused native child surfaces its own failure instead of continuing without the tool; non-combo translated routes are unchanged. [Commit boundaries](../transports/responses-combo-boundaries.md) own failover and preserve upstream read evidence before OAuth framing; `tests/claude-integration/messages-native-combo.test.ts` covers the boundary and mocked prefix preservation, not live cache-hit metrics. With the switch on, a declined route re-marks its bridge entry with the decline reason (after any `effort-row` / `fast-row`), so the trace says why the bridge was taken; with it off nothing is @@ -391,7 +391,7 @@ is `envelope.freshBody()` when a source envelope exists, otherwise the ingress's `src/server/messages-native.ts` is imported lazily, only for an eligible route. Native Messages errors reuse the shared OpenCode Zen guidance before resolving client retry headers. A Zen rate-limit 429, identified by message or explicit `rate_limit_error` type, without an upstream retry hint receives the same 15-second hint as the translated path; quota/billing and spent-usage-limit errors remain unchanged and valid upstream retry headers retain precedence. Both native Messages and `src/server/responses/adapter-dispatch.ts` forward parsed error types and codes to the shared guidance. Local workflow errors retain the Anthropic envelope and refusal metadata under the [physical-send contract](../transports/responses-spend.md#workflow-physical-send-ceiling). Configured admission-key model scope also applies to separately billed Advisor models before -managed native key or OAuth dispatch. Scope intersections retain each member's tool-model restrictions. `src/server/messages-native-scope.ts` resolves active +managed native key or OAuth dispatch. On the translated lane `src/server/responses/request-prepare.ts` applies the same per-declaration check to the settled route and drops a denied `advisor_*` declaration instead of refusing, recording `advisor-tool-outside-model-scope`, because the Advisor is optional there. Scope intersections retain each member's tool-model restrictions. `src/server/messages-native-scope.ts` resolves active top-level and system-message tool declarations, including replacement, removal and re-offer; deferred definitions remain scoped because tool search can load them. Missing Advisor model identity cannot satisfy a model list. Anthropic rejects tool changes in a turn-scoped system diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 71706a0cbee..a9d1aaa1a85 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -204,7 +204,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/openai-responses/transport.ts` | Shared Responses URL, configured auth and caller-header construction for ordinary requests and explicitly enabled custom native v1 compact. Its compact-handoff metadata copier uses the existing client-header allowlist, never credentials, through internal request construction and Codex auth materialization. Protocol capability never grants canonical identity; v2 trigger and native blob replay remain independently declared. On the noncanonical forward path, configured `baseUrl` trailing-slash stripping is a linear scan that preserves interior slash runs and leaves the canonical forward base and non-forward auth modes untouched; `tests/responses/responses-forward-transport-baseurl.test.ts` pins it. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | | `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `serialized-tool-call-content.ts`, `serialized-tool-call-markup.ts`, `standalone-bare-tool-calls.ts`, `tool-call-validation.ts`, `tool-call-id-remint.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | -| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. For a Responses client the bridge delivers that event, and the same one from the Kiro adapter, as a terminal `response.failed` ([Adapter-to-Responses bridge](transports/responses.md#adapter-to-responses-bridge)). It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. For `api.anthropic.com` only, the Claude client's typed web tools (`parsed._anthropicServerTools`) follow the function tools unchanged, minus the caller's `cache_control`; a function tool of the same name wins, so the web-search sidecar's `web_search` keeps priority and `web_fetch` is still carried. No beta header is added: both tools are GA. Each streamed `web_search_tool_result` or `web_fetch_tool_result` becomes a `web_search_call_begin`/`web_search_call_end` pair (the query of its `server_tool_use` in the same response, hit URLs and titles, or `failed` for an error result) carrying `anthropic`: that `server_tool_use` with its complete streamed input and the result block, verbatim. An orphan result, a result with a different call id, or a result whose call input cannot be parsed is dropped with no synthetic call; the surrounding text is preserved. Streamed server-tool input retains at most 4,096 characters, slicing each incoming fragment to the remaining capacity; an oversized input that cannot be recovered drops its exchange under the same rule. The bridge and the direct encoder keep both on the `web_search_call` item as `anthropic_blocks` (a fetch is an `open_page` action), and the Claude outbound renders them back unchanged, with the original `srvtoolu_` id, `encrypted_content` and `page_age`; a fetch is not counted in `web_search_requests`. A search Anthropic defers beside a client `tool_use` has no result in that response and surfaces nothing. On the next turn, `messagesToAnthropicFormat` re-inserts the client's replayed pairs (`parsed._anthropicServerReplay`) ahead of the `tool_use`, text or thinking block that followed them, for `api.anthropic.com` only. An anchor must occur exactly once across assistant history, counting string and block content alike; repeated anchors drop their exchanges and preserve the other turns. Replay requires one-to-one call/result ids and matching tool types within the same assistant turn, with the call preceding its result in the original sequence. A single forward pass tracks pending calls and rejects reversed pairs, duplicate ids and orphan results. Calls require a plain input object with a nonempty `web_search.query` or `web_fetch.url` string; malformed input, a pair without `encrypted_content`, or a pair with no following anchor stays dropped. Stored `anthropic_blocks` restore only when call id, result id, tool/result types and input object agree; malformed metadata falls back to the rebuilt pair. Citations, a deferred search and `pause_turn` are not resumed. `tests/claude-integration/claude-translated-server-tools.test.ts` pins the wire, the sidecar priority, the bridge's client stream and JSON, and the direct encoder's stream. On translated Claude Messages requests, the inbound path carries the caller's last cache-marked system prefix out of the request body. When that prefix still matches the translated instructions after routed-model identity normalization and has a trailing system block, the Anthropic adapter splits it from the per-turn tail and moves its existing system cache breakpoint to the prefix; absent or mismatched prefixes keep the prior single-block behavior. `tests/claude-integration/claude-translated-cache-ttl.test.ts` checks the stable cached bytes across changing tails. Prompt-cache TTL: an omitted `cacheRetention` resolves through `src/adapters/anthropic/cache-retention.ts` to 1h (`ttl: "1h"`) for first-party `api.anthropic.com` (API key and OAuth pools) and to 5m for Anthropic-compatible endpoints, which do not document the 1h TTL; an explicit value wins everywhere. The direct native Messages passthrough upgrades caller breakpoints without a TTL to 1h under the same rule unless the caller pinned any breakpoint to another TTL; exact native combo replay preserves those source breakpoints unless the operator explicitly configures retention. Usage carries `cache_creation.ephemeral_1h_input_tokens` as `cacheCreation1hInputTokens`, which `src/usage/cost.ts` prices at 1.6x the 5m write rate (2x base input). `tests/adapters/adapter-usage.test.ts`, `tests/adapters/anthropic/anthropic-messages-passthrough.test.ts` and `tests/claude-integration/claude-translated-cache-ttl.test.ts` pin the defaults. | +| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. For a Responses client the bridge delivers that event, and the same one from the Kiro adapter, as a terminal `response.failed` ([Adapter-to-Responses bridge](transports/responses.md#adapter-to-responses-bridge)). It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. For `api.anthropic.com` only, the Claude client's typed web tools (`parsed._anthropicServerTools`) follow the function tools unchanged, minus the caller's `cache_control`; a function tool of the same name wins, so the web-search sidecar's `web_search` keeps priority and `web_fetch` is still carried. No beta header is added: both tools are GA. Each streamed `web_search_tool_result` or `web_fetch_tool_result` becomes a `web_search_call_begin`/`web_search_call_end` pair (the query of its `server_tool_use` in the same response, hit URLs and titles, or `failed` for an error result) carrying `anthropic`: that `server_tool_use` with its complete streamed input and the result block, verbatim. An orphan result, a result with a different call id, or a result whose call input cannot be parsed is dropped with no synthetic call; the surrounding text is preserved. Streamed server-tool input retains at most 4,096 characters, slicing each incoming fragment to the remaining capacity; an oversized input that cannot be recovered drops its exchange under the same rule. The bridge and the direct encoder keep both on the `web_search_call` item as `anthropic_blocks` (a fetch is an `open_page` action), and the Claude outbound renders them back unchanged, with the original `srvtoolu_` id, `encrypted_content` and `page_age`; a fetch is not counted in `web_search_requests`. A search Anthropic defers beside a client `tool_use` has no result in that response and surfaces nothing. On the next turn, `messagesToAnthropicFormat` re-inserts the client's replayed pairs (`parsed._anthropicServerReplay`) ahead of the `tool_use`, text or thinking block that followed them, for `api.anthropic.com` only. An anchor must occur exactly once across assistant history, counting string and block content alike; repeated anchors drop their exchanges and preserve the other turns. Replay requires one-to-one call/result ids and matching tool types within the same assistant turn, with the call preceding its result in the original sequence. A single forward pass tracks pending calls and rejects reversed pairs, duplicate ids and orphan results. Calls require a plain input object with a nonempty `web_search.query` or `web_fetch.url` string; malformed input, a pair without `encrypted_content`, or a pair with no following anchor stays dropped. Stored `anthropic_blocks` restore only when call id, result id, tool/result types and input object agree; malformed metadata falls back to the rebuilt pair. Citations, a deferred search and `pause_turn` are not resumed. `tests/claude-integration/claude-translated-server-tools.test.ts` pins the wire, the sidecar priority, the bridge's client stream and JSON, and the direct encoder's stream. Claude Code's typed Advisor (`advisor_YYYYMMDD`) rides the same lane under `planAnthropicServerTools` in `src/adapters/anthropic/advisor-tool.ts`: the declaration and the client's completed Advisor exchanges go to an `anthropic`-adapter provider whose credential domain is first-party Anthropic, or to a gateway with `forwardAdvisorTool: true` (`false` opts out; the Zed delegate sets it because its wire is Zed), and only while no function tool named `advisor` (the proxy Advisor's synthetic tool) owns the name; either one adds the `advisor-tool-2026-03-01` beta. A streamed `advisor_tool_result` pairs with the same response's `server_tool_use`, whose input must be empty, and leaves as a `web_search_call_begin`/`web_search_call_end` pair with no queries whose `anthropic` carries both blocks verbatim (`failed` for `advisor_tool_result_error`, never counted in `web_search_requests`). A result whose content is not `advisor_result`, `advisor_redacted_result` or `advisor_tool_result_error` with string fields only is dropped, and the Claude outbound restores stored blocks under the same check. A call pending at `pause_turn` is not surfaced. `tests/claude-integration/claude-translated-advisor-tool.test.ts` pins the destinations, the beta, the three client lanes and replay. On translated Claude Messages requests, the inbound path carries the caller's last cache-marked system prefix out of the request body. When that prefix still matches the translated instructions after routed-model identity normalization and has a trailing system block, the Anthropic adapter splits it from the per-turn tail and moves its existing system cache breakpoint to the prefix; absent or mismatched prefixes keep the prior single-block behavior. `tests/claude-integration/claude-translated-cache-ttl.test.ts` checks the stable cached bytes across changing tails. Prompt-cache TTL: an omitted `cacheRetention` resolves through `src/adapters/anthropic/cache-retention.ts` to 1h (`ttl: "1h"`) for first-party `api.anthropic.com` (API key and OAuth pools) and to 5m for Anthropic-compatible endpoints, which do not document the 1h TTL; an explicit value wins everywhere. The direct native Messages passthrough upgrades caller breakpoints without a TTL to 1h under the same rule unless the caller pinned any breakpoint to another TTL; exact native combo replay preserves those source breakpoints unless the operator explicitly configures retention. Usage carries `cache_creation.ephemeral_1h_input_tokens` as `cacheCreation1hInputTokens`, which `src/usage/cost.ts` prices at 1.6x the 5m write rate (2x base input). `tests/adapters/adapter-usage.test.ts`, `tests/adapters/anthropic/anthropic-messages-passthrough.test.ts` and `tests/claude-integration/claude-translated-cache-ttl.test.ts` pin the defaults. | | `src/adapters/anthropic-model-contract.ts` | Per-family Anthropic Messages wire rules, shared by the adapter and the web-search and vision sidecars, measured live on 2026-09-29, with Haiku 5.5 documented on 2026-10-08: adaptive vs budget thinking (Haiku 5.5+ is adaptive); explicit `thinking: disabled` (Sonnet 5.0 up to but excluding 5.5, Haiku 5.5+ without effort); the `between_tools` floor that replaces it on Sonnet 5.5+ (sent without an effort, since xhigh/max reject it); forced `tool_choice` downgraded to `auto` on Opus 5.5, Fable 5.1+ and Sonnet 5.5+; `temperature`/`top_p` dropped on Opus 4.7+, Sonnet 5+, Haiku 5.5+ and every Fable, which 400 on any non-default value; `top_p` dropped when sent with `temperature` on the 4.5/4.6 families, which take either alone but not both; and sidecar thinking-off fields, a low effort with no `thinking` for Opus 5.5 and Fable, which reject both off switches. Dotted Bedrock ids do not parse as a family, and the Messages-native passthrough forwards caller fields unchanged. `tests/adapters/anthropic/anthropic-sonnet-5-5-contract.test.ts` and `tests/adapters/anthropic/anthropic-haiku-5-5-contract.test.ts` pin the family table. | | `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | | `src/adapters/unique-tool-call-ids.ts` | Request-scoped tool-call-id uniqueness for every `openai-chat` provider. An upstream that mints an id from the call's position in its response repeats `call-0-0` on every turn; a Messages client has already paired that id, drops the duplicate, and is left with a call that has no result, so the turn reads as empty and the model re-issues it indefinitely. Only a **repeat** is rewritten — the first occurrence stays byte-identical, leaving prompt-cache keys, reasoning-replay lookups and already-unique upstreams untouched. The ids to avoid come from the caller's history, captured in `buildRequest` (the only point that sees it) and applied at emission, never at ingestion: ingestion matches streamed deltas against the id upstream sent, so rewriting there would strip a pending call of its identity mid-stream. A repeat takes a `-` suffix, never `_`, because `_` reads as a batch sub-call of ``; occupied-set search resumes by suffix width and retained base prefix, including when siblings converge as `-9` becomes `-10`. Covered by `tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts`. | diff --git a/tests/claude-integration/claude-translated-advisor-tool.test.ts b/tests/claude-integration/claude-translated-advisor-tool.test.ts new file mode 100644 index 00000000000..1e37a9a6ad5 --- /dev/null +++ b/tests/claude-integration/claude-translated-advisor-tool.test.ts @@ -0,0 +1,389 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { declaredAnthropicServerTools, hasAnthropicOnlyServerTool, hasNativeOnlyTool, replayedAnthropicServerBlocks } from "../../src/claude/inbound-content-options"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { markProtocolEntry, protocolTraceForRequest } from "../../src/protocols/trace"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; +import { getRequestLogEntries } from "../../src/server/request-log"; +import { setAdvisorPlanFactory } from "../../src/server/responses/advisor-plan-slot"; +import { clearComboRecallForTests } from "../../src/server/responses/combo-session-recall"; +import { handleResponses } from "../../src/server/responses/core"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// Claude Code declares Anthropic's typed Advisor server tool (#8448). On the translated Messages +// lane it travels out of body to first-party Anthropic, or to a gateway that opts in with +// forwardAdvisorTool; its server_tool_use + advisor_tool_result come back and replay verbatim. +type Rec = Record; +const MODEL = "claude-opus-5-5"; +const ADVISOR = { type: "advisor_20260301", name: "advisor", model: "claude-opus-4-8", max_uses: 2 }; +const READ = { name: "Read", description: "read", input_schema: { type: "object", properties: {} } }; +const BETA = "advisor-tool-2026-03-01"; +const FIRST_PARTY = "https://api.anthropic.com"; +const GATEWAY = "https://gateway.example.com"; +const CIPHERTEXT = "EqQBCkYIBRgCIiQ5ZjE0-fixture-ciphertext"; +const PLAIN = { type: "advisor_result", text: "Close the input channel first.", stop_reason: "end_turn" }; +const REDACTED = { type: "advisor_redacted_result", encrypted_content: CIPHERTEXT }; +const ERRORED = { type: "advisor_tool_result_error", error_code: "overloaded" }; + +let previousHome: string | undefined; +let home = ""; +let releaseSpend: (() => void) | undefined; +beforeAll(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-advisor-server-tool-")); + process.env.OPENCODEX_HOME = home; + releaseSpend = acquireOwnedSpendHome(); +}); +afterAll(() => { + releaseSpend?.(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); +// A refused child cools its combo target down; every case starts from a fresh waterfall. +beforeEach(() => { + clearComboSelectionState(); clearComboTargetCooldowns(); clearComboRecallForTests(); clearKeyCooldowns(); +}); + +const sse = (frames: Rec[]) => new Response( + frames.map(f => `event: ${String(f.type)}\ndata: ${JSON.stringify(f)}\n\n`).join(""), + { headers: { "content-type": "text/event-stream" } }, +); +const START = { type: "message_start", message: { id: "msg_x", type: "message", role: "assistant", model: MODEL, content: [], stop_reason: null, usage: { input_tokens: 1, output_tokens: 0 } } }; +const text = (index: number, value: string): Rec[] => [ + { type: "content_block_start", index, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index, delta: { type: "text_delta", text: value } }, + { type: "content_block_stop", index }, +]; +const end = (stopReason = "end_turn"): Rec[] => [ + { type: "message_delta", delta: { stop_reason: stopReason, stop_sequence: null }, usage: { output_tokens: 5 } }, + { type: "message_stop" }, +]; +const OK_TURN = [START, ...text(0, "ok"), ...end()]; +const call = (index: number, id = "srvtoolu_adv1"): Rec[] => [ + { type: "content_block_start", index, content_block: { type: "server_tool_use", id, name: "advisor", input: {} } }, + { type: "content_block_stop", index }, +]; +const advisorTurn = (result: unknown): Rec[] => [ + START, ...text(0, "Let me check."), ...call(1), + { type: "content_block_start", index: 2, content_block: { type: "advisor_tool_result", tool_use_id: "srvtoolu_adv1", content: result } }, + { type: "content_block_stop", index: 2 }, + ...text(3, "Done."), ...end(), +]; + +interface Wire { body: Rec; headers: Headers } +interface TurnOptions { + baseUrl?: string; provider?: Rec; model?: string; stream?: boolean; direct?: boolean; + messages?: unknown[]; tools?: unknown[]; frames?: Rec[]; gptFirst?: boolean; gptOnly?: boolean; nativeRollout?: boolean; requestId?: string; +} + +async function claudeTurn(options: TurnOptions = {}): Promise<{ wires: Wire[]; status: number; body: string }> { + const wires: Wire[] = []; + const capture = (target: "anth" | "gpt") => (async (_input: unknown, init?: RequestInit) => { + wires.push({ body: JSON.parse(String(init?.body)) as Rec, headers: new Headers(init?.headers) }); + if (target === "gpt") { + return new Response(`data: ${JSON.stringify({ id: "c", object: "chat.completion.chunk", choices: [{ index: 0, delta: { content: "gpt ok" }, finish_reason: "stop" }] })}\n\ndata: [DONE]\n\n`, + { headers: { "content-type": "text/event-stream" } }); + } + return sse(options.frames ?? OK_TURN); + }) as typeof globalThis.fetch; + const anth = { provider: "anth", model: MODEL }; + const gpt = { provider: "gpt", model: "g" }; + const config = { + port: 0, defaultProvider: "anth", + claudeCode: { nativePassthrough: false }, + webSearchSidecar: { enabled: false }, + ...(options.direct || options.nativeRollout + ? { protocols: { rollout: { directEncoders: options.direct === true, managedMessagesNative: options.nativeRollout === true } } } : {}), + providers: { + anth: { adapter: "anthropic", baseUrl: options.baseUrl ?? FIRST_PARTY, authMode: "key", apiKey: "sk-ant-fixture", models: [MODEL], fetch: capture("anth"), ...options.provider }, + gpt: { adapter: "openai-chat", baseUrl: "https://gpt.invalid/v1", authMode: "key", apiKey: "sk-fixture", models: ["g"], fetch: capture("gpt") }, + }, + combos: { waterfall: { strategy: "failover", targets: options.gptOnly ? [gpt] : options.gptFirst ? [gpt, anth] : [anth, gpt] } }, + } as unknown as OcxConfig; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { "content-type": "application/json", "anthropic-beta": BETA }, + body: JSON.stringify({ + model: options.model ?? (options.direct ? `anth/${MODEL}` : "combo/waterfall"), max_tokens: 64, stream: options.stream ?? true, + tools: options.tools ?? [READ, ADVISOR], + messages: options.messages ?? [{ role: "user", content: "Plan the shutdown." }], + }), + }), config, { model: "", provider: "" }, options.requestId ? { requestId: options.requestId, start: Date.now() } : undefined); + return { wires, status: response.status, body: await response.text() }; +} + +const toolTypes = (wire: Wire) => ((wire.body.tools ?? []) as Rec[]).map(tool => String(tool.type ?? "function")); +const betas = (wire: Wire) => (wire.headers.get("anthropic-beta") ?? "").split(",").map(value => value.trim()).filter(Boolean); +const frames = (body: string) => body.split("\n").filter(line => line.startsWith("data: ")).map(line => JSON.parse(line.slice(6)) as Rec); + +/** The client's content blocks, as Claude Code stores them for its next turn. */ +function clientContent(body: string, stream: boolean): Rec[] { + if (!stream) return (JSON.parse(body) as { content: Rec[] }).content; + const blocks: Rec[] = []; + const inputs: string[] = []; + for (const frame of frames(body)) { + const index = frame.index as number; + const delta = frame.delta as Record | undefined; + if (frame.type === "content_block_start") blocks[index] = { ...(frame.content_block as Rec) }; + else if (delta?.type === "input_json_delta") inputs[index] = (inputs[index] ?? "") + delta.partial_json; + else if (delta?.type === "text_delta") blocks[index]!.text = String(blocks[index]!.text) + delta.text; + } + return blocks.map((block, index) => inputs[index] ? { ...block, input: JSON.parse(inputs[index]!) } : block); +} + +const followUp = (assistant: unknown[]) => [ + { role: "user", content: "Plan the shutdown." }, { role: "assistant", content: assistant }, { role: "user", content: "Go on." }, +]; + +describe("advisor declarations on the translated Messages lane", () => { + test("module: advisor is carried out of body and is not a native-only or Anthropic-only tool", () => { + expect(declaredAnthropicServerTools({ tools: [READ, ADVISOR] })).toEqual([ADVISOR]); + expect(hasNativeOnlyTool({ tools: [READ, ADVISOR] })).toBe(false); + expect(hasAnthropicOnlyServerTool({ tools: [READ, ADVISOR] })).toBe(false); + expect(hasNativeOnlyTool({ tools: [READ, { type: "bash_20250124", name: "bash" }] })).toBe(true); + }); + + // The combo judge marks children native-only only while the managed native Messages lane is on. + test("with the native lane on, a combo whose only child is translated serves an advisor request", async () => { + const { wires, status, body } = await claudeTurn({ nativeRollout: true, gptOnly: true }); + expect(status).toBe(200); + expect(body).toContain("gpt ok"); + expect(wires).toHaveLength(1); + expect(JSON.stringify(wires[0]!.body)).not.toContain("advisor"); + }); + + test("with the native lane on, a refused native child falls back to its translated sibling", async () => { + const refused = [START, { type: "error", error: { type: "overloaded_error", message: "fixture overloaded" } }]; + const { wires, status, body } = await claudeTurn({ nativeRollout: true, frames: refused }); + expect(status).toBe(200); + expect(body).toContain("gpt ok"); + expect(wires).toHaveLength(2); + expect(wires[0]!.body.tools).toContainEqual(ADVISOR); + expect(JSON.stringify(wires[1]!.body)).not.toContain("advisor"); + }); + + test("with the native lane off, a combo of translated children serves an advisor request", async () => { + const { wires, status } = await claudeTurn({ baseUrl: GATEWAY }); + expect(status).toBe(200); + expect(wires).toHaveLength(1); + }); + + test("a single translated route still serves an advisor request", async () => { + const { wires, status } = await claudeTurn({ baseUrl: GATEWAY, direct: true }); + expect(status).toBe(200); + expect(toolTypes(wires[0]!)).toEqual(["function"]); + }); + + test("api.anthropic.com receives advisor_20260301 as declared, with its beta", async () => { + const { wires, status } = await claudeTurn({ tools: [READ, { ...ADVISOR, cache_control: { type: "ephemeral" } }] }); + expect(status).toBe(200); + const tools = wires[0]!.body.tools as Rec[]; + expect(tools.map(tool => tool.name)).toEqual(["Read", "advisor"]); + expect(tools[1]).toMatchObject(ADVISOR); + expect(betas(wires[0]!)).toContain(BETA); + }); + + test("an Anthropic-compatible gateway does not receive it by default", async () => { + const { wires } = await claudeTurn({ baseUrl: GATEWAY }); + expect(toolTypes(wires[0]!)).toEqual(["function"]); + expect(betas(wires[0]!)).not.toContain(BETA); + }); + + test("forwardAdvisorTool: true opts a gateway in; false keeps it off api.anthropic.com", async () => { + const optedIn = (await claudeTurn({ baseUrl: GATEWAY, provider: { forwardAdvisorTool: true } })).wires[0]!; + expect(toolTypes(optedIn)).toEqual(["function", "advisor_20260301"]); + expect(betas(optedIn)).toContain(BETA); + const optedOut = (await claudeTurn({ provider: { forwardAdvisorTool: false } })).wires[0]!; + expect(toolTypes(optedOut)).toEqual(["function"]); + expect(betas(optedOut)).not.toContain(BETA); + }); + + test("a GPT leg carries no advisor declaration", async () => { + const { wires } = await claudeTurn({ gptFirst: true }); + expect(JSON.stringify(wires[0]!.body)).not.toContain("advisor"); + }); +}); + +describe("advisor blocks reach the Claude client and replay verbatim", () => { + for (const [lane, options] of [["bridge stream", {}], ["bridge JSON", { stream: false }], ["direct encoder", { direct: true }]] as const) { + test(`${lane}: server_tool_use advisor + advisor_tool_result, unchanged`, async () => { + for (const result of [PLAIN, REDACTED, ERRORED]) { + const { body, status } = await claudeTurn({ ...options, frames: advisorTurn(result) }); + expect(status).toBe(200); + const blocks = clientContent(body, !("stream" in options)); + expect(blocks.map(block => block.type)).toEqual(["text", "server_tool_use", "advisor_tool_result", "text"]); + expect(blocks[1]).toEqual({ type: "server_tool_use", id: "srvtoolu_adv1", name: "advisor", input: {} }); + expect(blocks[2]).toEqual({ type: "advisor_tool_result", tool_use_id: "srvtoolu_adv1", content: result }); + // An Advisor call is not a web search. + const usage = "stream" in options ? (JSON.parse(body) as { usage: Rec }).usage + : frames(body).find(frame => frame.type === "message_delta")!.usage as Rec; + expect(usage.server_tool_use).toBeUndefined(); + } + }); + } + + test("an advisor result without its call, or with a non-empty call input, is dropped", async () => { + const orphan = [START, ...text(0, "Let me check."), + { type: "content_block_start", index: 1, content_block: { type: "advisor_tool_result", tool_use_id: "srvtoolu_adv1", content: PLAIN } }, + { type: "content_block_stop", index: 1 }, ...text(2, "Done."), ...end()]; + const withInput = advisorTurn(PLAIN); + withInput.splice(5, 0, { type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: "{\"q\":\"x\"}" } }); + const malformed = advisorTurn({ type: "advisor_result" }); + for (const turn of [orphan, withInput, malformed]) { + const { body } = await claudeTurn({ frames: turn }); + // Nothing separates the two texts once the exchange is dropped, so they share one block. + const blocks = clientContent(body, true); + expect(blocks.every(block => block.type === "text")).toBe(true); + expect(blocks.map(block => block.text).join("")).toBe("Let me check.Done."); + } + }); + + for (const [label, result] of [["advisor_result", PLAIN], ["advisor_redacted_result", REDACTED], ["advisor_tool_result_error", ERRORED]] as const) { + test(`a replayed ${label} pair reaches api.anthropic.com in place and an opted-in gateway only`, async () => { + const turn1 = clientContent((await claudeTurn({ frames: advisorTurn(result) })).body, true); + const replay = followUp(turn1); + const first = (await claudeTurn({ messages: replay })).wires[0]!; + const assistant = (first.body.messages as { content: Rec[] }[])[1]!.content; + expect(assistant).toEqual([ + { type: "text", text: "Let me check." }, + { type: "server_tool_use", id: "srvtoolu_adv1", name: "advisor", input: {} }, + { type: "advisor_tool_result", tool_use_id: "srvtoolu_adv1", content: result }, + { type: "text", text: "Done." }, + ]); + expect(betas(first)).toContain(BETA); + const optedIn = JSON.stringify((await claudeTurn({ baseUrl: GATEWAY, provider: { forwardAdvisorTool: true }, messages: replay })).wires[0]!.body); + expect(optedIn).toContain("srvtoolu_adv1"); + for (const other of [ + (await claudeTurn({ baseUrl: GATEWAY, messages: replay })).wires[0]!, + (await claudeTurn({ gptFirst: true, messages: replay })).wires[0]!, + ]) { + expect(JSON.stringify(other.body)).not.toContain("srvtoolu_adv1"); + expect(JSON.stringify(other.body)).toContain("Done."); + } + }); + } + + test("a replayed advisor pair outside the documented shapes stays dropped", () => { + const pair = (input: unknown, content: unknown) => ({ messages: followUp([ + { type: "server_tool_use", id: "srvtoolu_adv1", name: "advisor", input }, + { type: "advisor_tool_result", tool_use_id: "srvtoolu_adv1", content }, + { type: "text", text: "Done." }, + ]) }); + expect(replayedAnthropicServerBlocks(pair({}, PLAIN))?.get("text:Done.")).toHaveLength(2); + for (const [input, content] of [ + [{ question: "x" }, PLAIN], + [{}, { type: "advisor_result" }], + [{}, { type: "advisor_result", text: 7 }], + [{}, { type: "advisor_redacted_result" }], + [{}, { type: "advisor_redacted_result", encrypted_content: CIPHERTEXT, extra: true }], + [{}, { type: "advisor_result", text: "x", stop_reason: 1 }], + [{}, { type: "advisor_tool_result_error" }], + [{}, { type: "something_else", text: "x" }], + [{}, [PLAIN]], + [{}, "advice"], + ] as const) { + expect(replayedAnthropicServerBlocks(pair(input, content))).toBeUndefined(); + } + // A non-srvtoolu_ id or a result before its call never replays either. + expect(replayedAnthropicServerBlocks({ messages: followUp([ + { type: "server_tool_use", id: "toolu_adv1", name: "advisor", input: {} }, + { type: "advisor_tool_result", tool_use_id: "toolu_adv1", content: PLAIN }, + { type: "text", text: "Done." }, + ]) })).toBeUndefined(); + }); + + test("the redacted ciphertext never reaches the request log", async () => { + const requestId = `advisor-log-${crypto.randomUUID()}`; + const { status } = await claudeTurn({ frames: advisorTurn(REDACTED), requestId }); + expect(status).toBe(200); + const rows = getRequestLogEntries().filter(row => row.requestId === requestId); + expect(rows.length).toBeGreaterThan(0); + expect(JSON.stringify(rows)).not.toContain(CIPHERTEXT); + }); +}); + +describe("pause_turn with a pending advisor call", () => { + test("the resend still carries the advisor declaration and its beta", async () => { + const paused = [START, ...text(0, "Thinking it over."), ...call(1), ...end("pause_turn")]; + const turn1 = await claudeTurn({ frames: paused }); + expect(turn1.status).toBe(200); + expect(toolTypes(turn1.wires[0]!)).toContain("advisor_20260301"); + const resend = await claudeTurn({ messages: [ + { role: "user", content: "Plan the shutdown." }, + { role: "assistant", content: clientContent(turn1.body, true) }, + ] }); + expect(resend.status).toBe(200); + const tools = resend.wires[0]!.body.tools as Rec[]; + expect(tools.find(tool => tool.type === "advisor_20260301")).toMatchObject(ADVISOR); + expect(betas(resend.wires[0]!)).toContain(BETA); + }); +}); + +describe("the proxy Advisor owns the advisor name for its turn", () => { + async function coreTurn(proxyAdvisor: boolean, admissionScope?: { providers: string[]; models: string[] }): Promise<{ wire: Wire; reasons: readonly string[] }> { + const wires: Wire[] = []; + const config = { + port: 0, defaultProvider: "anth", webSearchSidecar: { enabled: false }, + providers: { anth: { adapter: "anthropic", baseUrl: FIRST_PARTY, authMode: "key", apiKey: "sk-ant-fixture", models: [MODEL], + fetch: (async (_input: unknown, init?: RequestInit) => { + wires.push({ body: JSON.parse(String(init?.body)) as Rec, headers: new Headers(init?.headers) }); + return sse(OK_TURN); + }) as typeof globalThis.fetch } }, + } as unknown as OcxConfig; + if (proxyAdvisor) setAdvisorPlanFactory(config, async () => ({ + tool: { name: "advisor", description: "proxy advisor", parameters: { type: "object", properties: {} }, advisor: true }, + preflightInject: async () => false, + attachGuard: () => {}, + })); + const logCtx: { model: string; provider: string; attempts?: Parameters[1] } = { model: "", provider: "" }; + // The Messages ingress marks its lane before handing the translated body to the core. + markProtocolEntry(logCtx, { inbound: "messages", lane: "bridge" }); + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: `anth/${MODEL}`, stream: true, input: "Plan the shutdown.", + tools: [{ type: "function", name: "Read", parameters: { type: "object", properties: {} } }] }), + }), config, logCtx, { inboundWire: "anthropic", anthropicServerTools: [ADVISOR], ...(admissionScope ? { admissionScope } : {}) }); + expect(response.status).toBe(200); + await response.text(); + } finally { setAdvisorPlanFactory(config, null); } + expect(wires).toHaveLength(1); + // The same projection the request log row stores. + return { wire: wires[0]!, reasons: protocolTraceForRequest(logCtx, logCtx.attempts)?.reasonCodes ?? [] }; + } + + test("an active proxy Advisor shadows the declared tool and records why", async () => { + const { wire, reasons } = await coreTurn(true); + const tools = wire.body.tools as Rec[]; + expect(tools.filter(tool => tool.name === "advisor")).toHaveLength(1); + expect(tools.find(tool => tool.name === "advisor")).toHaveProperty("input_schema"); + expect(toolTypes(wire)).not.toContain("advisor_20260301"); + expect(betas(wire)).not.toContain(BETA); + expect(reasons).toContain("advisor-tool-shadowed-by-proxy-advisor"); + }); + + test("with the proxy Advisor off the declared tool is forwarded", async () => { + const { wire, reasons } = await coreTurn(false); + expect(toolTypes(wire)).toEqual(["function", "advisor_20260301"]); + expect(betas(wire)).toContain(BETA); + expect(reasons).not.toContain("advisor-tool-shadowed-by-proxy-advisor"); + }); + + // The Advisor's model runs server-side on the same provider: a scoped key may not reach a model + // through it that the scope does not admit (the native lane refuses the same declaration). + test("a declaration naming a model outside the caller's scope is not forwarded", async () => { + const denied = await coreTurn(false, { providers: [], models: [MODEL] }); + expect(toolTypes(denied.wire)).toEqual(["function"]); + expect(betas(denied.wire)).not.toContain(BETA); + expect(denied.reasons).toContain("advisor-tool-outside-model-scope"); + const admitted = await coreTurn(false, { providers: [], models: [MODEL, ADVISOR.model] }); + expect(toolTypes(admitted.wire)).toEqual(["function", "advisor_20260301"]); + expect(admitted.reasons).not.toContain("advisor-tool-outside-model-scope"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f8ab4139a4f..aeb88addf18 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -271,6 +271,7 @@ "claude-client-refusal-status.test.ts": "claude-integration", "claude-translated-cache-ttl.test.ts": "claude-integration", "claude-translated-server-tools.test.ts": "claude-integration", + "claude-translated-advisor-tool.test.ts": "claude-integration", "claude-translated-tool-restrictions.test.ts": "claude-integration", "web-search-forced-declaration.test.ts": "web-search", "web-search-loop-iteration-usage.test.ts": "web-search", "web-search-loop-error-usage.test.ts": "web-search", "web-search-rotation-usage-attribution.test.ts": "web-search", @@ -1047,6 +1048,7 @@ "management-model-roster.test.ts": "server", "model-export-new-model-policy.test.ts": "server", "management-origin-tls.test.ts": "server", "management-provider-compat-carry.test.ts": "server", "management-provider-hide-raw-reasoning.test.ts": "server", + "management-provider-forward-advisor-tool.test.ts": "server", "management-provider-responses-reasoning.test.ts": "server", "management-provider-atomicity.test.ts": "server", "management-provider-pinsless-validation.test.ts": "server", "management-provider-proto-override.test.ts": "server", "management-provider-reasoning-lists.test.ts": "server", "management-provider-reset-replay.test.ts": "server", diff --git a/tests/providers/zed-hosted-provider.test.ts b/tests/providers/zed-hosted-provider.test.ts index a3763b2a8d4..99465a34708 100644 --- a/tests/providers/zed-hosted-provider.test.ts +++ b/tests/providers/zed-hosted-provider.test.ts @@ -276,6 +276,25 @@ describe("Zed Hosted AI provider", () => { expect(request.body).toContain("cache_control"); expect(request.body).not.toContain("\"ttl\""); }); + test("the borrowed api.anthropic.com builder never forwards Claude Code's Advisor tool to Zed", async () => { + const responses = [ + jsonResponse({ default_organization_id: "org-1" }), + jsonResponse({ token: "llm-token-1" }), + jsonResponse({ models: [{ id: "claude-haiku-4-5", provider: "anthropic" }] }), + ]; + globalThis.fetch = (async () => { + const response = responses.shift(); + if (!response) throw new Error("unexpected Zed catalog fetch"); + return response; + }) as typeof globalThis.fetch; + // An operator opt-in on the Zed row does not reach the delegate either: Zed is not first-party. + const provider: OcxProviderConfig = { adapter: "zed", baseUrl: "https://cloud.zed.dev", authMode: "oauth", apiKey: "access-token", forwardAdvisorTool: true }; + const parsed = parseRequest({ model: "claude-haiku-4-5", input: "hello zed", stream: true }); + parsed._zedAuthContext = { userId: "user-1" }; + parsed._anthropicServerTools = [{ type: "advisor_20260301", name: "advisor", model: "claude-opus-4-8" }]; + const request = await withTestTranslatorBudget(createZedAdapter(provider)).buildRequest(parsed); + expect(request.body).not.toContain("advisor_20260301"); + }); test("normalizes OpenAI input and removes Codex-only fields for Zed backend", async () => { const responses = [ jsonResponse({ default_organization_id: "org-1" }), diff --git a/tests/server/management-provider-compat-carry.test.ts b/tests/server/management-provider-compat-carry.test.ts index 698b4cd7a80..e95858683ef 100644 --- a/tests/server/management-provider-compat-carry.test.ts +++ b/tests/server/management-provider-compat-carry.test.ts @@ -17,6 +17,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildOpenAIChatPassthroughRequest, createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { planAnthropicServerTools } from "../../src/adapters/anthropic/advisor-tool"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; import { loadConfig, saveConfig } from "../../src/config"; import * as destinationPolicy from "../../src/lib/destination-policy"; @@ -209,6 +210,12 @@ const CASES: Record = { seed: { unsupportedHostedToolsWithClientTools: ["web_search"] }, probe: mixedHostedToolTypes, }, + forwardAdvisorTool: { + seed: { forwardAdvisorTool: true }, + // The Anthropic adapter's server-tool plan for this (non-first-party) gateway. + probe: provider => planAnthropicServerTools({ ...provider, adapter: "anthropic" }, + [{ type: "advisor_20260301", name: "advisor", model: "claude-opus-4-8" }], undefined, new Set(), false).declared, + }, }; function withoutField(provider: OcxProviderConfig, field: ProviderCompatCarryField): OcxProviderConfig { diff --git a/tests/server/management-provider-forward-advisor-tool.test.ts b/tests/server/management-provider-forward-advisor-tool.test.ts new file mode 100644 index 00000000000..67ffabd6d7b --- /dev/null +++ b/tests/server/management-provider-forward-advisor-tool.test.ts @@ -0,0 +1,102 @@ +/** forwardAdvisorTool: the per-provider opt-in that lets an Anthropic-compatible gateway receive Claude Code's Advisor tool (#8448). */ +import { afterEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../../src/config"; +import { providerConfigSchema } from "../../src/config/schema/leaf-validators"; +import * as destinationPolicy from "../../src/lib/destination-policy"; +import { startServer } from "../../src/server"; +import type { OcxProviderConfig } from "../../src/types"; +import { managementFetch as fetch } from "../helpers/management-auth"; +import { config } from "../helpers/management-relative-send-paths"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +setDefaultTimeout(60_000); + +const BASE_URL = "https://gateway.example/v1"; +const previousHome = process.env.OPENCODEX_HOME; +let home = ""; + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (home) removeTreeWithRetry(home); + home = ""; +}); + +async function withServer(provider: OcxProviderConfig, run: (url: URL) => Promise): Promise { + home = mkdtempSync(join(tmpdir(), "ocx-provider-forward-advisor-")); + process.env.OPENCODEX_HOME = home; + const base = config("127.0.0.1"); + saveConfig({ ...base, providers: { ...base.providers, gateway: provider } }); + const server = startServer(0); + const resolved = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + await run(server.url); + } finally { + resolved.mockRestore(); + await server.stop(true); + } +} + +function send(url: URL, path: string, method: "PATCH" | "POST", body: unknown): Promise { + return fetch(new URL(path, url), { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); +} + +function provider(forwardAdvisorTool?: boolean): OcxProviderConfig { + return { adapter: "anthropic", baseUrl: BASE_URL, ...(forwardAdvisorTool === undefined ? {} : { forwardAdvisorTool }) }; +} + +describe("forwardAdvisorTool management", () => { + test("GET reports it and PATCH sets, clears, and rejects it", async () => { + await withServer(provider(), async url => { + const row = async () => (await (await fetch(new URL("/api/providers", url))).json() as Array<{ name: string; forwardAdvisorTool?: boolean }>) + .find(entry => entry.name === "gateway"); + expect((await row())?.forwardAdvisorTool).toBeUndefined(); + const set = await send(url, "/api/providers?name=gateway", "PATCH", { forwardAdvisorTool: true }); + expect(set.status).toBe(200); + expect(loadConfig().providers.gateway?.forwardAdvisorTool).toBe(true); + expect((await row())?.forwardAdvisorTool).toBe(true); + const reject = await send(url, "/api/providers?name=gateway", "PATCH", { forwardAdvisorTool: "yes" }); + expect(reject.status).toBe(400); + expect(await reject.json()).toMatchObject({ error: "forwardAdvisorTool must be a boolean" }); + expect(loadConfig().providers.gateway?.forwardAdvisorTool).toBe(true); + const clear = await send(url, "/api/providers?name=gateway", "PATCH", { forwardAdvisorTool: null }); + expect(clear.status).toBe(200); + expect(loadConfig().providers.gateway).not.toHaveProperty("forwardAdvisorTool"); + }); + }); + + test("a same-destination save keeps an omitted opt-in", async () => { + await withServer(provider(true), async url => { + const response = await send(url, "/api/providers", "POST", { name: "gateway", provider: { adapter: "anthropic", baseUrl: BASE_URL, note: "edited" } }); + expect(response.status).toBe(200); + expect(loadConfig().providers.gateway).toMatchObject({ note: "edited", forwardAdvisorTool: true }); + }); + }); + + test("moving the provider to another destination drops the opt-in", async () => { + await withServer(provider(true), async url => { + const movedUrl = "https://other-gateway.example/v1"; + const response = await send(url, "/api/providers", "POST", { name: "gateway", provider: { adapter: "anthropic", baseUrl: movedUrl } }); + expect(response.status).toBe(200); + expect(loadConfig().providers.gateway?.baseUrl).toBe(movedUrl); + expect(loadConfig().providers.gateway).not.toHaveProperty("forwardAdvisorTool"); + }); + }); + + test("POST rejects a string value", async () => { + await withServer(provider(), async url => { + const response = await send(url, "/api/providers", "POST", { name: "gateway", provider: { ...provider(), forwardAdvisorTool: "true" } }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: "forwardAdvisorTool must be a boolean" }); + expect(loadConfig().providers.gateway).not.toHaveProperty("forwardAdvisorTool"); + }); + }); + + test("provider schema rejects a string instead of passing it through at config load", () => { + expect(providerConfigSchema.safeParse({ ...provider(), forwardAdvisorTool: "true" }).success).toBe(false); + expect(providerConfigSchema.safeParse(provider(true)).success).toBe(true); + }); +}); From 1fd0f58f7961beb08bd040359c71b158e51ac406 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 10:54:04 +0900 Subject: [PATCH 2/4] docs(providers): keep the reference under its 2,000-line cap --- .../src/content/docs/reference/configuration/providers.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 9cce00dc244..e15f397dfb4 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -534,8 +534,7 @@ The optional `inferContextWindow` setting also survives same-name saves that omi `PATCH /api/providers?name=` changes only the fields it names and keeps every other stored field, whatever the destination. It accepts all fourteen settings; `null` clears one. It also accepts `modelDiscovery`, where `null` or `{}` clears it. It also accepts a boolean `hideRawReasoning` or `null` to clear it. For the two reasoning lists an empty array is stored as an explicit opt-out rather than removed. -With `webSearchBridge` enabled, a search continuation stays bound to the API-key selection that -served the first request. Changing the selected key, its reference or resolved value, authentication +With `webSearchBridge` enabled, a search continuation stays bound to the API-key selection that served the first request. Changing the selected key, its reference or resolved value, authentication mode, or base URL during search or provider pacing ends the turn with a bridge error before another provider request is sent. Changing away and back also ends that continuation. Start a new turn to use the new selection. Selection changes before the first provider send retain normal reselection. From 6fe28174955f188f268e0f774ffdcfb9dcb85c74 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 11:06:03 +0900 Subject: [PATCH 3/4] fix(claude): keep a shadowed Advisor out of every leg and drop malformed stored exchanges When the proxy Advisor owns the name, withhold the caller's Advisor history as well as its declaration, so a later leg without the synthetic tool does not bring back the history and beta mid-turn. A stored Advisor exchange that fails validation is dropped instead of becoming an empty web_search. Document the advisor cost and the pending call at a client tool stop. Refs #8448 --- .../src/content/docs/guides/claude-code.md | 11 ++++-- src/adapters/anthropic/advisor-tool.ts | 21 ++++++++++ src/claude/outbound.ts | 9 ++++- src/protocols/encoders/messages.ts | 1 + src/server/responses/sidecar-execution.ts | 8 ++-- .../claude-translated-advisor-tool.test.ts | 38 +++++++++++++++++++ 6 files changed, 80 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 4696087a907..0a635f4e7e5 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -1108,9 +1108,14 @@ request and returns a `server_tool_use` block named `advisor` followed by an destination that receives the declaration carries it in place, with the beta. The call input must be an empty object and the result one of the three documented variants; anything else is dropped, as it is for every other destination. -- An Advisor call still pending when Anthropic ends the response with `pause_turn` is not - surfaced or resumed. The resend still carries the declaration and the beta, so the model can - consult again. +- An Advisor call still pending when Anthropic ends the response with `pause_turn`, or with + `tool_use` for a client tool, is not surfaced or resumed. The next request still carries the + declaration and the beta, so the model can consult again. +- When the OpenCodex Advisor owns the name for a turn, Claude Code's earlier Advisor exchanges are + also withheld for every request of that turn, so the prompt does not change partway through it. +- Anthropic reports the advisor model's tokens separately from the main model's usage, and + OpenCodex does not add them to its usage log, key spend or the usage Claude Code receives. + Check Anthropic's console for the advisor cost. ### Unicode-property patterns in tool schemas diff --git a/src/adapters/anthropic/advisor-tool.ts b/src/adapters/anthropic/advisor-tool.ts index 3be49991231..eaafa63756c 100644 --- a/src/adapters/anthropic/advisor-tool.ts +++ b/src/adapters/anthropic/advisor-tool.ts @@ -86,6 +86,27 @@ function runHasOtherServerTool(run: readonly Rec[]): boolean { return run.some(block => block.type === "server_tool_use" && block.name !== ADVISOR_SERVER_TOOL_NAME); } +/** + * Withhold the caller's Advisor for a turn the proxy Advisor joins: its declaration and every + * replayed run that contains an Advisor call. The synthetic `advisor` owns the name for the whole + * turn, so a later leg that drops the synthetic tool (consultations exhausted) must not bring the + * history or the beta back and change the prompt mid-turn. Returns whether a declaration was withheld. + */ +export function withholdAdvisorServerTools(parsed: { + _anthropicServerTools?: readonly Rec[]; + _anthropicServerReplay?: ReadonlyMap; +}): boolean { + const declared = hasAdvisorDeclaration(parsed._anthropicServerTools); + if (declared) parsed._anthropicServerTools = parsed._anthropicServerTools!.filter(tool => !isAdvisorServerToolType(tool.type)); + const replay = parsed._anthropicServerReplay; + if (replay && [...replay.values()].some(runHasAdvisor)) { + const kept = new Map([...replay].filter(([, run]) => !runHasAdvisor(run))); + if (kept.size > 0) parsed._anthropicServerReplay = kept; + else delete parsed._anthropicServerReplay; + } + return declared; +} + export interface AnthropicServerToolPlan { /** Typed declarations this destination receives, in caller order. */ declared: Rec[]; diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index 7fbd7b2ae77..b328948398f 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -291,10 +291,15 @@ export function sanitizeWebSearchInput(input: unknown): Rec { */ export function webSearchPairFromItem(item: Rec): { id: string; name: string; resultType: string; input: Rec; resultContent: unknown; completed: boolean; -} { +} | undefined { const action = isRec(item.action) ? item.action : {}; const completed = item.status !== "failed"; const { use, result } = isRec(item.anthropic_blocks) ? item.anthropic_blocks : {}; + // An Advisor exchange is only ever emitted verbatim; one that fails validation is dropped rather + // than falling through to an empty web_search below. + if (isRec(use) && use.name === ADVISOR_SERVER_TOOL_NAME && !(isRec(result) && typeof use.id === "string" + && isAdvisorCallInput(use.input) && isAdvisorResultContent(result.content) + && result.tool_use_id === use.id && result.type === "advisor_tool_result")) return undefined; if (isRec(use) && isRec(result) && typeof use.id === "string" && isRec(use.input) && (use.name === "web_search" || use.name === "web_fetch" || (use.name === ADVISOR_SERVER_TOOL_NAME && isAdvisorCallInput(use.input) && isAdvisorResultContent(result.content))) @@ -796,6 +801,7 @@ export function responsesSseToAnthropicSse( ensureStarted(); closeOpenBlock(); const pair = webSearchPairFromItem(item); + if (!pair) break; const toolIndex = blockIndex++; emit("content_block_start", { type: "content_block_start", index: toolIndex, @@ -1143,6 +1149,7 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string, tr case "web_search_call": { // Server-side search: emit the Anthropic pair. Does NOT set sawToolUse. const pair = webSearchPairFromItem(raw); + if (!pair) break; content.push({ type: "server_tool_use", id: pair.id, name: pair.name, input: pair.input }); content.push({ type: pair.resultType, tool_use_id: pair.id, content: pair.resultContent }); if (pair.completed && pair.name === "web_search") webSearchRequests++; diff --git a/src/protocols/encoders/messages.ts b/src/protocols/encoders/messages.ts index 1206ed814d9..ba549a75b4a 100644 --- a/src/protocols/encoders/messages.ts +++ b/src/protocols/encoders/messages.ts @@ -368,6 +368,7 @@ function createAnthropicMessageWriter( ...(search.sources.length > 0 ? { sources: search.sources } : {}), ...(search.anthropic ? { anthropic_blocks: search.anthropic } : {}), }); + if (!pair) return; const toolIndex = blockIndex++; emit("content_block_start", { type: "content_block_start", index: toolIndex, diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index 48d2b970bb2..2a16f74ead1 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -11,7 +11,7 @@ import { formatErrorResponse } from "../../bridge"; import { planWebSearch, buildWebSearchTool, runWithWebSearch } from "../../web-search"; import { createRegisteredAdvisorPlan } from "./advisor-plan-slot"; import { ADVISOR_TOOL_NAME, createAdvisorSidecarStep } from "./advisor-slot"; -import { advisorServerToolDestination, hasAdvisorDeclaration, isAdvisorServerToolType } from "../../adapters/anthropic/advisor-tool"; +import { advisorServerToolDestination, withholdAdvisorServerTools } from "../../adapters/anthropic/advisor-tool"; import { addProtocolEntryReason } from "../../protocols/trace"; import { buildToolBridgeMaps } from "./collaboration"; import { @@ -211,9 +211,9 @@ export async function executeResponsesSidecars( // is withheld here, not only while the function is on the catalog: a later loop iteration that // drops the synthetic tool must not forward it either (adapters/anthropic/advisor-tool.ts). const withholdShadowedAdvisorTool = () => { - if (!hasAdvisorDeclaration(parsed._anthropicServerTools)) return; - if (advisorServerToolDestination(route.provider)) addProtocolEntryReason(logCtx, "advisor-tool-shadowed-by-proxy-advisor"); - parsed._anthropicServerTools = parsed._anthropicServerTools?.filter(tool => !isAdvisorServerToolType(tool.type)); + if (withholdAdvisorServerTools(parsed) && advisorServerToolDestination(route.provider)) { + addProtocolEntryReason(logCtx, "advisor-tool-shadowed-by-proxy-advisor"); + } }; // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority. diff --git a/tests/claude-integration/claude-translated-advisor-tool.test.ts b/tests/claude-integration/claude-translated-advisor-tool.test.ts index 1e37a9a6ad5..d57a8f576c7 100644 --- a/tests/claude-integration/claude-translated-advisor-tool.test.ts +++ b/tests/claude-integration/claude-translated-advisor-tool.test.ts @@ -14,6 +14,8 @@ import { handleResponses } from "../../src/server/responses/core"; import type { OcxConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { planAnthropicServerTools, withholdAdvisorServerTools } from "../../src/adapters/anthropic/advisor-tool"; +import { webSearchPairFromItem } from "../../src/claude/outbound"; // Claude Code declares Anthropic's typed Advisor server tool (#8448). On the translated Messages // lane it travels out of body to first-party Anthropic, or to a gateway that opts in with @@ -387,3 +389,39 @@ describe("the proxy Advisor owns the advisor name for its turn", () => { expect(admitted.reasons).not.toContain("advisor-tool-outside-model-scope"); }); }); + +describe("a shadowed turn stays shadowed on every leg", () => { + const provider = { adapter: "anthropic" as const, baseUrl: FIRST_PARTY, apiKey: "k" }; + const run: Rec[] = [ + { type: "server_tool_use", id: "srvtoolu_adv1", name: "advisor", input: {} }, + { type: "advisor_tool_result", tool_use_id: "srvtoolu_adv1", content: PLAIN }, + ]; + test("withholding drops the declaration and the Advisor history, so a later leg without the synthetic tool adds neither", () => { + const parsed: { _anthropicServerTools?: Rec[]; _anthropicServerReplay?: ReadonlyMap } = { + _anthropicServerTools: [ADVISOR, { type: "web_search_20250305", name: "web_search" }], + _anthropicServerReplay: new Map([["text:after", run]]), + }; + expect(withholdAdvisorServerTools(parsed)).toBe(true); + // The leg after the proxy Advisor runs out of consultations has no function named advisor. + const plan = planAnthropicServerTools(provider, parsed._anthropicServerTools, parsed._anthropicServerReplay, new Set(), true); + expect(plan.declared.map(tool => tool.type)).toEqual(["web_search_20250305"]); + expect(plan.replay).toBeUndefined(); + expect(plan.betas).toEqual([]); + expect(withholdAdvisorServerTools(parsed)).toBe(false); + }); +}); + +describe("a stored Advisor item that fails validation", () => { + test("is dropped instead of becoming an empty web_search", () => { + const item = { + type: "web_search_call", id: "ws_1", status: "completed", + anthropic_blocks: { + use: { type: "server_tool_use", id: "srvtoolu_adv2", name: "advisor", input: { smuggled: "x" } }, + result: { type: "advisor_tool_result", tool_use_id: "srvtoolu_adv2", content: PLAIN }, + }, + }; + expect(webSearchPairFromItem(item)).toBeUndefined(); + const valid = { ...item, anthropic_blocks: { ...item.anthropic_blocks, use: { ...item.anthropic_blocks.use, input: {} } } }; + expect(webSearchPairFromItem(valid)?.name).toBe("advisor"); + }); +}); From 30b94fc7bf5992dc7648a1961b868b42c5f0f88e Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 11:08:41 +0900 Subject: [PATCH 4/4] fix(claude): replay Advisor blocks from checked fields and surface them only from Advisor destinations Security review follow-up: a replayed Advisor pair keeps only its documented fields, and a gateway that was never sent the Advisor cannot surface an exchange a later first-party turn would replay. Refs #8448 --- src/adapters/anthropic.ts | 7 ++++-- src/adapters/anthropic/advisor-tool.ts | 14 +++++++++++ src/claude/inbound-content-options.ts | 7 +++--- .../claude-translated-advisor-tool.test.ts | 23 +++++++++++++++++++ 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 12ebbe0c30e..2f3404ac094 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -41,7 +41,7 @@ import { resolveReasoningCapabilities } from "../providers/reasoning-capabilitie import { declaresThinkingToggleWire, reasoningControlsAreUnsupported } from "./reasoning-controls"; import { applyAgentRouterLanguageFraming, isAgentRouterEndpoint } from "./agentrouter"; import { anthropicCacheWrite1hTokens, resolveAnthropicCacheRetention, type AnthropicCacheRetention } from "./anthropic/cache-retention"; -import { advisorServerExchange, planAnthropicServerTools } from "./anthropic/advisor-tool"; +import { advisorServerExchange, advisorServerToolDestination, planAnthropicServerTools } from "./anthropic/advisor-tool"; import { claudeFamilyVersion, rejectsCombinedSampling, rejectsForcedToolChoice, rejectsSamplingParameters, supportsExplicitThinkingDisable, usesAdaptiveThinking, usesBetweenToolsFloor } from "./anthropic-model-contract"; /** Map a user content part to an Anthropic content block (text or image source). */ @@ -1460,7 +1460,10 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } if (block.type === "advisor_tool_result") { // The Advisor rides the same lane: both blocks verbatim, keyed by the upstream srvtoolu_ id. - const exchange = advisorServerExchange(serverSearch, block as Record); + // Only a destination that receives the Advisor may surface one, so a gateway never sent + // it cannot plant an exchange that a later first-party turn would replay. + const exchange = advisorServerToolDestination(provider) + ? advisorServerExchange(serverSearch, block as Record) : undefined; if (!exchange) break; yield { type: "web_search_call_begin", id: exchange.blocks.use.id as string }; yield { type: "web_search_call_end", id: exchange.blocks.use.id as string, queries: [], diff --git a/src/adapters/anthropic/advisor-tool.ts b/src/adapters/anthropic/advisor-tool.ts index eaafa63756c..4d7ee9fd941 100644 --- a/src/adapters/anthropic/advisor-tool.ts +++ b/src/adapters/anthropic/advisor-tool.ts @@ -73,6 +73,20 @@ export function isAdvisorResultContent(content: unknown): boolean { || (fields.optional.includes(key) && typeof content[key] === "string")); } +/** + * A replayed Advisor block rebuilt from the fields Anthropic documents, so nothing a client added to + * its history rides along; undefined for any other block, which the caller copies as before. + */ +export function advisorReplayBlock(block: Rec): Rec | undefined { + if (block.type === "server_tool_use" && block.name === ADVISOR_SERVER_TOOL_NAME) { + return { type: "server_tool_use", id: block.id, name: ADVISOR_SERVER_TOOL_NAME, input: block.input }; + } + if (block.type === "advisor_tool_result") { + return { type: "advisor_tool_result", tool_use_id: block.tool_use_id, content: block.content }; + } + return undefined; +} + /** Whether a declared catalog or replay run names the Advisor. */ export function hasAdvisorDeclaration(tools: readonly Rec[] | undefined): boolean { return tools?.some(tool => isAdvisorServerToolType(tool.type)) === true; diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts index 0d22110f475..7d8b1a898bf 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -1,5 +1,5 @@ import { markProxyError } from "../lib/client-error-body"; -import { ADVISOR_SERVER_TOOL_NAME, isAdvisorCallInput, isAdvisorResultContent, isAdvisorServerToolType } from "../adapters/anthropic/advisor-tool"; +import { ADVISOR_SERVER_TOOL_NAME, advisorReplayBlock, isAdvisorCallInput, isAdvisorResultContent, isAdvisorServerToolType } from "../adapters/anthropic/advisor-tool"; import { isClaudeWebSearchToolName } from "./outbound"; import { AnthropicRequestError, isRec, type Rec } from "./inbound-records"; @@ -184,8 +184,9 @@ export function replayedAnthropicServerBlocks(raw: unknown): Map for (const block of message.content.filter(isRec)) { if (block.type === "server_tool_use" || block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result" || block.type === "advisor_tool_result") { - // The caller's breakpoints are dropped on this lane, as for the declared tools above. - run.push({ ...block, cache_control: undefined }); + // The caller's breakpoints are dropped on this lane, as for the declared tools above; an + // Advisor block keeps only its documented fields. + run.push(advisorReplayBlock(block) ?? { ...block, cache_control: undefined }); continue; } const anchor = block.type === "tool_use" ? `call:${String(block.id)}` diff --git a/tests/claude-integration/claude-translated-advisor-tool.test.ts b/tests/claude-integration/claude-translated-advisor-tool.test.ts index d57a8f576c7..0818e73c1ed 100644 --- a/tests/claude-integration/claude-translated-advisor-tool.test.ts +++ b/tests/claude-integration/claude-translated-advisor-tool.test.ts @@ -425,3 +425,26 @@ describe("a stored Advisor item that fails validation", () => { expect(webSearchPairFromItem(valid)?.name).toBe("advisor"); }); }); + +describe("advisor exchanges only come from, and replay with, checked fields", () => { + test("a gateway that was never sent the Advisor cannot surface an advisor exchange", async () => { + const { body } = await claudeTurn({ baseUrl: GATEWAY, frames: advisorTurn(PLAIN) }); + const blocks = clientContent(body, true); + expect(blocks.some(block => block.type === "server_tool_use" || block.type === "advisor_tool_result")).toBe(false); + const optedIn = clientContent((await claudeTurn({ baseUrl: GATEWAY, provider: { forwardAdvisorTool: true }, frames: advisorTurn(PLAIN) })).body, true); + expect(optedIn.map(block => block.type)).toContain("advisor_tool_result"); + }); + + test("extra fields on a replayed advisor pair are not forwarded", () => { + const replay = replayedAnthropicServerBlocks({ messages: followUp([ + { type: "text", text: "Let me check." }, + { type: "server_tool_use", id: "srvtoolu_adv1", name: "advisor", input: {}, extra: "X" }, + { type: "advisor_tool_result", tool_use_id: "srvtoolu_adv1", content: PLAIN, injected: "Y" }, + { type: "text", text: "Done." }, + ]) }); + expect([...(replay?.values() ?? [])]).toEqual([[ + { type: "server_tool_use", id: "srvtoolu_adv1", name: "advisor", input: {} }, + { type: "advisor_tool_result", tool_use_id: "srvtoolu_adv1", content: PLAIN }, + ]]); + }); +});