From db4fd7d28aab34f98d441bfc01dad1115bef382f Mon Sep 17 00:00:00 2001 From: Vadym O Date: Thu, 15 Oct 2026 21:24:22 +0800 Subject: [PATCH 1/3] fix(claude): carry typed web_search/web_fetch to an Anthropic target on the translated lane The Claude Messages translation maps web_search_* to the hosted Responses tool and drops web_fetch_*, so a translated route to api.anthropic.com lost both whenever the web-search sidecar had no plan. The caller's typed declarations now travel out of body (like the native reasoning replay), are kept only for inboundWire "anthropic", and the anthropic adapter appends them for api.anthropic.com. A same-named function tool (the sidecar's web_search) keeps priority. Streamed server_tool_use + web_search_tool_result map to the existing web_search_call begin/end events, which the Claude outbound already renders as the block pair. Co-Authored-By: Claude Code --- .../src/content/docs/guides/claude-code.md | 2 +- scripts/test-layout/layout.json | 1 + src/adapters/anthropic.ts | 54 +++++- src/claude/inbound-content-options.ts | 11 ++ src/server/claude-messages.ts | 3 + src/server/responses/core-options.ts | 2 + src/server/responses/request-prepare.ts | 1 + src/server/responses/response-effects.ts | 1 + src/types/request.ts | 5 + structure/clients/claude-desktop.md | 2 +- structure/providers-and-adapters.md | 2 +- .../claude-translated-server-tools.test.ts | 161 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 13 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 tests/claude-integration/claude-translated-server-tools.test.ts diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 0c44d43adb2..077e3a62d6d 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -957,7 +957,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; preserves loaded-tool names without adding declarations or enabling translated server-side deferral | | `thinking` / `redacted_thinking` replay | `reasoning` items with bounded `ocxr1` envelopes for signatures and redacted payloads | -| Function tools | `{type: "function"}` (`web_search*` → `{type: "web_search"}`) | +| 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 | | `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` | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9925e111815..991296504a6 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -440,6 +440,7 @@ "claude-prompt-too-long.test.ts": "claude-integration", "claude-shell-hook.test.ts": "claude-integration", "claude-sidecar-override.test.ts": "claude-integration", + "claude-translated-server-tools.test.ts": "claude-integration", "claude-source-envelope.test.ts": "claude-integration", "claude-system-env-auto.test.ts": "claude-integration", "cleanup-orphaned-workflows.test.ts": "ci-workflows", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 2e98267362b..54ca631266d 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -521,6 +521,16 @@ function streamedToolArgumentsParse(assembled: string): boolean { } } +/** The query a server-side web_search streamed as its input; [] when it did not parse. */ +function webSearchQueries(json: string): string[] { + try { + const query = (JSON.parse(json) as { query?: unknown } | null)?.query; + return typeof query === "string" && query.length > 0 ? [query] : []; + } catch { + return []; + } +} + function anthropicKeyUsesBearer(provider: OcxProviderConfig): boolean { return provider.apiKeyTransport === "bearer"; } @@ -896,6 +906,18 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: ( return converted; } +/** + * 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. + */ +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; + // The caller's breakpoints are dropped on this lane; applyPromptCaching places its own. + return [...(tools ?? []), ...serverTools.map(t => ({ ...t, cache_control: undefined }))]; +} + function normalizeAnthropicInputSchema(schema: unknown): Record { const stripped = stripResponsesOnlyEncryptedMarker(schema); const obj = stripped && typeof stripped === "object" && !Array.isArray(stripped) @@ -988,7 +1010,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // Anthropic rejects many-image requests (>20 images) carrying any image over // 2000px per side; see anthropic-image-guard.ts for the full limit policy. enforceAnthropicImageLimits(messages); - const tools = toolsToAnthropicFormat(parsed, toolNames); + 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; // 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 @@ -1220,6 +1244,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti let pendingStopReason: string | undefined; let emittedDone = false; let sawVisibleText = false; + /** The open server-side web_search call, until its web_search_tool_result block. */ + let serverSearch: { id: string; json: string; streaming: boolean } | undefined; const emitDone = function* (): Generator { if (emittedDone) return; @@ -1288,7 +1314,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti break; } case "content_block_start": { - const block = data.content_block as { type: string; id?: string; name?: unknown; data?: string; thinking?: string } | undefined; + const block = data.content_block as { type: string; id?: string; name?: unknown; data?: string; thinking?: string; tool_use_id?: unknown; content?: unknown } | undefined; if (!block) break; currentBlockType = block.type; if (block.type === "thinking") { @@ -1303,6 +1329,25 @@ 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" && typeof block.id === "string") { + // Anthropic ran the caller's typed web_search: surface it as the same search + // cell the sidecar emits. The query arrives as input_json_delta below. + serverSearch = { id: block.id, json: "", streaming: true }; + yield { type: "web_search_call_begin", id: block.id }; + } + if (block.type === "web_search_tool_result" && serverSearch && block.tool_use_id === serverSearch.id) { + const hits = Array.isArray(block.content) ? block.content as Array<{ url?: unknown; title?: unknown }> : undefined; + yield { + type: "web_search_call_end", + id: serverSearch.id, + queries: webSearchQueries(serverSearch.json), + // A failed search carries one error object instead of the hit list. + status: hits ? "completed" : "failed", + sources: (hits ?? []).flatMap(h => typeof h.url === "string" + ? [{ url: h.url, ...(typeof h.title === "string" ? { title: h.title } : {}) }] : []), + }; + serverSearch = undefined; + } if (block.type === "redacted_thinking" && typeof block.data === "string") { // Opaque redacted block: replay verbatim later or tool-use turns 400. yield { type: "redacted_thinking", data: block.data }; @@ -1346,10 +1391,15 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti throw error; } yield { type: "tool_call_delta", arguments: delta.partial_json }; + } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && serverSearch?.streaming + && serverSearch.json.length < 4096) { + // Capped: a query past 4 KiB loses its label (queries: []), never the search. + serverSearch.json += delta.partial_json; } break; } case "content_block_stop": { + if (serverSearch) serverSearch.streaming = false; if (currentBlockType === "tool_use") { // The non-stream path repairs an unparseable payload in toolUseArguments(); the // stream cannot, because the fragments are already downstream. Fail the turn diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts index cd7c93b15e4..1358de6e1a8 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -76,6 +76,17 @@ export function toolsToResponses(tools: unknown): Rec[] | undefined { return out.length > 0 ? out : 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. + */ +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)); + return tools.length > 0 ? tools : undefined; +} + export function toolChoiceToResponses(choice: unknown, body: Rec): void { if (!isRec(choice)) return; if (choice.disable_parallel_tool_use === true) body.parallel_tool_calls = false; diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 7bf4e6fa456..0e7479b4fcd 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -35,6 +35,7 @@ import { resolveAlias, claudeCodeNativeAlias, legacyAliasForNative } from "../cl import { recordDesktopRequest } from "../claude/desktop-health"; import { stripOneMillionMarker } from "../claude/context-windows"; import { captureClaudeInbound } from "../claude/inbound-debug"; +import { declaredAnthropicServerTools } from "../claude/inbound-content-options"; import { claudeCodeForIngress } from "../claude/intercept/model-bindings"; import { classifyInterceptClient } from "../claude/intercept/client-class"; import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility"; @@ -1428,6 +1429,8 @@ async function handleClaudeMessagesWithBudget( && anthropicBody.tools.length === 0, nativeReasoningReplay, nativeReasoningMint, + // Out of body like the replay above: the core hands these to an Anthropic destination only. + anthropicServerTools: declaredAnthropicServerTools(anthropicBody), claudeGoAffinity: { sessionLane: claudeGoSessionLane }, claudeNativeSessionId, stripClaudeMainAuthForNoncanonicalForward: true, diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index 954cdc2638d..daac9b1cebe 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -175,6 +175,8 @@ export interface HandleResponsesOptions { clientDeclaredEmptyToolCatalog?: boolean; nativeReasoningReplay?: ReadonlyMap; nativeReasoningMint?: { owner?: NativeReasoningOwner }; + /** The Claude client's typed `web_search_*` / `web_fetch_*` declarations; kept only for `inboundWire: "anthropic"`. */ + anthropicServerTools?: readonly Record[]; /** Droid's per-request effort default; each concrete combo or policy target applies it only if its ladder allows it. */ droidDefaultEffort?: string; /** PF-07: the Chat source a combo child may send natively; set only by the Chat ingress. */ diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 35871ccfc70..1ffbb23a125 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -473,6 +473,7 @@ export async function prepareResponsesRequest( if (options.inboundWire === "anthropic") { parsed._nativeReasoningReplay = options.nativeReasoningReplay; parsed._nativeReasoningMint = options.nativeReasoningMint; + parsed._anthropicServerTools = options.anthropicServerTools; } parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort; // The body may have been rebuilt since the inbound observation (previous-response diff --git a/src/server/responses/response-effects.ts b/src/server/responses/response-effects.ts index 97ea0b28652..9b29aae01c4 100644 --- a/src/server/responses/response-effects.ts +++ b/src/server/responses/response-effects.ts @@ -89,6 +89,7 @@ export function createResponsesEffects( if (routedCompaction) { delete parsed.context.tools; delete parsed._webSearch; + delete parsed._anthropicServerTools; delete parsed.options.toolChoice; delete parsed.options.parallelToolCalls; // The compaction turn is a plain prose summary; a surviving structured-output format diff --git a/src/types/request.ts b/src/types/request.ts index 23c4986a2ec..1071456a543 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -62,6 +62,11 @@ export interface OcxParsedRequest { _nativeReasoningReplay?: ReadonlyMap; _nativeReasoningBoundOwner?: NativeReasoningOwner; _nativeReasoningMint?: { owner?: NativeReasoningOwner }; + /** + * The Claude client's typed Anthropic web tools, as declared. Only the anthropic adapter reads + * them, and only for api.anthropic.com; every other destination never sees them. + */ + _anthropicServerTools?: readonly Record[]; /** * Boundary between replayed history and this turn's newly appended input. Usually the * items the proxy restored from local previous_response_id state; also set when the diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 64eb97a4940..a46bf6bc6f2 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. +`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 the translated body and every non-Anthropic leg stay as before. `tests/claude-integration/claude-translated-server-tools.test.ts` covers the Anthropic wire, a third-party Anthropic-compatible endpoint and a GPT leg. `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/providers-and-adapters.md b/structure/providers-and-adapters.md index a8c63a4cdc2..e9aab404f08 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -202,7 +202,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/openai-responses.ts`, `src/adapters/openai-responses/` | Native OpenAI/ChatGPT Responses passthrough. The canonical ChatGPT adapter forces its upstream-only `stream: true` requirement without changing caller `store`; downstream JSON negotiation remains owned by the [Responses HTTP/SSE contract](transports/responses.md#responses-httpsse). | | `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`, `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`. 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. | +| `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`. 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. A streamed `server_tool_use` named `web_search` and its `web_search_tool_result` become `web_search_call_begin`/`web_search_call_end` (query, hit URLs and titles, or `failed` for an error result), which the Claude outbound renders back as the same block pair; `web_fetch` blocks and citations are not relayed. `tests/claude-integration/claude-translated-server-tools.test.ts` pins the wire, the sidecar priority and the client stream. | | `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-server-tools.test.ts b/tests/claude-integration/claude-translated-server-tools.test.ts new file mode 100644 index 00000000000..16fdef309dc --- /dev/null +++ b/tests/claude-integration/claude-translated-server-tools.test.ts @@ -0,0 +1,161 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createAnthropicAdapter } from "../../src/adapters/anthropic"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// A Claude client's typed web_search / web_fetch reach an Anthropic destination on the +// translated lane (a combo declines the native Messages lane), and Anthropic's server-side +// search comes back to the client as server_tool_use + web_search_tool_result. +const MODEL = "claude-opus-5-5"; +const WEB_SEARCH = { type: "web_search_20250305", name: "web_search", max_uses: 3 }; +const WEB_FETCH = { type: "web_fetch_20250910", name: "web_fetch", max_uses: 2, cache_control: { type: "ephemeral" } }; +let previousHome: string | undefined; +let home = ""; +let releaseSpend: (() => void) | undefined; + +beforeAll(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-server-tools-")); + 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); +}); + +const sse = (frames: Record[]) => new Response( + frames.map(f => `event: ${String(f.type)}\ndata: ${JSON.stringify(f)}\n\n`).join(""), + { headers: { "content-type": "text/event-stream" } }, +); + +const SEARCH_TURN = [ + { type: "message_start", message: { id: "msg_x", type: "message", role: "assistant", model: MODEL, content: [], stop_reason: null, usage: { input_tokens: 1, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "server_tool_use", id: "srvtoolu_1", name: "web_search", input: {} } }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: "{\"query\":\"bun " } }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: "release\"}" } }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "web_search_tool_result", tool_use_id: "srvtoolu_1", content: [ + { type: "web_search_result", url: "https://bun.sh/blog", title: "Bun blog", encrypted_content: "enc", page_age: null }, + ] } }, + { type: "content_block_stop", index: 1 }, + { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Bun shipped." } }, + { type: "content_block_stop", index: 2 }, + { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 5, server_tool_use: { web_search_requests: 1 } } }, + { type: "message_stop" }, +]; + +const OK_TURN = [ + { type: "message_start", message: { id: "msg_x", type: "message", role: "assistant", model: MODEL, content: [], stop_reason: null, usage: { input_tokens: 1, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, +]; + +async function claudeTurn( + baseUrl: string, + frames: Record[], + target: "anth" | "gpt" = "anth", +): Promise<{ wire: Record; clientSse: string }> { + const wires: string[] = []; + const fetch = (async (_input: unknown, init?: RequestInit) => { + wires.push(String(init?.body)); + if (target === "gpt") { + return new Response(`data: ${JSON.stringify({ id: "c", object: "chat.completion.chunk", choices: [{ index: 0, delta: { content: "ok" }, finish_reason: "stop" }] })}\n\ndata: [DONE]\n\n`, + { headers: { "content-type": "text/event-stream" } }); + } + return sse(frames); + }) as typeof globalThis.fetch; + const config = { + port: 0, defaultProvider: "anth", + claudeCode: { nativePassthrough: false }, + webSearchSidecar: { enabled: false }, + providers: { + anth: { adapter: "anthropic", baseUrl, authMode: "key", apiKey: "sk-ant-fixture", models: [MODEL], fetch }, + gpt: { adapter: "openai-chat", baseUrl: "https://gpt.invalid/v1", authMode: "key", apiKey: "sk-fixture", models: ["g"], fetch }, + }, + combos: { waterfall: { strategy: "failover", targets: target === "anth" + ? [{ provider: "anth", model: MODEL }, { provider: "gpt", model: "g" }] + : [{ provider: "gpt", model: "g" }, { provider: "anth", model: MODEL }] } }, + } as unknown as OcxConfig; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "combo/waterfall", max_tokens: 64, stream: true, + tools: [{ name: "Read", description: "read", input_schema: { type: "object", properties: {} } }, WEB_SEARCH, WEB_FETCH], + messages: [{ role: "user", content: "What did Bun release? https://bun.sh/blog" }], + }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + const clientSse = await response.text(); + expect(wires).toHaveLength(1); + return { wire: JSON.parse(wires[0]!) as Record, clientSse }; +} + +function clientFrames(text: string): Record[] { + return text.split("\n").filter(l => l.startsWith("data: ")).map(l => JSON.parse(l.slice(6)) as Record); +} + +describe("translated Claude lane carries typed web tools to Anthropic", () => { + test("api.anthropic.com receives web_search and web_fetch as declared", async () => { + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN); + const tools = wire.tools as Record[]; + expect(tools.map(t => t.name)).toEqual(["Read", "web_search", "web_fetch"]); + expect(tools[1]).toMatchObject(WEB_SEARCH); + // The caller's own breakpoint is dropped; the adapter's tools breakpoint lands on the last tool. + expect(tools[2]).toMatchObject({ type: "web_fetch_20250910", name: "web_fetch", max_uses: 2 }); + expect(tools[2]!.cache_control).toEqual({ type: "ephemeral" }); + }); + + test("an Anthropic-compatible third party does not receive them", async () => { + const { wire } = await claudeTurn("https://compat.example.com", OK_TURN); + expect((wire.tools as Record[]).map(t => t.name)).toEqual(["Read"]); + }); + + test("a GPT leg's body carries no typed Anthropic tool", async () => { + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "gpt"); + expect(JSON.stringify(wire)).not.toContain("web_fetch"); + expect(JSON.stringify(wire)).not.toContain("web_search_2025"); + }); + + test("Anthropic's server-side search reaches the Claude client as server_tool_use + web_search_tool_result", async () => { + const { clientSse } = await claudeTurn("https://api.anthropic.com", SEARCH_TURN); + const frames = clientFrames(clientSse); + const starts = frames.filter(f => f.type === "content_block_start").map(f => f.content_block as Record); + expect(starts.map(b => b.type)).toEqual(["server_tool_use", "web_search_tool_result", "text"]); + const toolUse = starts[0]!; + expect(toolUse.name).toBe("web_search"); + const queryDelta = frames.find(f => f.type === "content_block_delta" + && (f.delta as Record).type === "input_json_delta")!; + expect(JSON.parse((queryDelta.delta as { partial_json: string }).partial_json)).toEqual({ query: "bun release" }); + expect(starts[1]).toMatchObject({ tool_use_id: toolUse.id, content: [{ type: "web_search_result", url: "https://bun.sh/blog", title: "Bun blog" }] }); + expect(clientSse).toContain("Bun shipped."); + const usage = frames.find(f => f.type === "message_delta")!.usage as Record; + expect(usage.server_tool_use).toEqual({ web_search_requests: 1 }); + }); +}); + +describe("anthropic adapter: typed web tools beside function tools", () => { + const provider = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "sk-x", authMode: "key" } as unknown as OcxProviderConfig; + test("the web-search sidecar's web_search function keeps priority; web_fetch is still carried", async () => { + const parsed = { + modelId: MODEL, stream: true, options: {}, + context: { messages: [{ role: "user", content: "hi", timestamp: 0 }], tools: [{ name: "web_search", description: "search", parameters: { type: "object", properties: {} } }] }, + _anthropicServerTools: [WEB_SEARCH, WEB_FETCH], + } as unknown as OcxParsedRequest; + const { body } = await createAnthropicAdapter(provider).buildRequest(parsed); + const tools = (JSON.parse(String(body)) as { tools: Record[] }).tools; + expect(tools.map(t => t.type ?? "function")).toEqual(["function", "web_fetch_20250910"]); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f5047535934..d3a054d5dd8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -354,6 +354,7 @@ "claude-context-accounting.test.ts": "claude-integration", "claude-long-context-eligibility.test.ts": "claude-integration", "claude-prompt-too-long.test.ts": "claude-integration", + "claude-translated-server-tools.test.ts": "claude-integration", "claude-shell-hook.test.ts": "claude-integration", "claude-sidecar-override.test.ts": "claude-integration", "claude-source-envelope.test.ts": "claude-integration", "claude-system-env-auto.test.ts": "claude-integration", "cleanup-orphaned-workflows.test.ts": "ci-workflows", "clearable-deadline.test.ts": "lib", From bdc656a7a2664a9ada266714f046ec7156d44810 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Thu, 15 Oct 2026 21:36:01 +0800 Subject: [PATCH 2/3] fix(anthropic): open the server web_search cell at its result A server_tool_use Anthropic defers beside a client tool_use has no web_search_tool_result in that response, so the early begin left a phantom completed search. The begin/end pair is now emitted at the result block, matched by tool_use_id, so a result arriving in a later response is still surfaced. Co-Authored-By: Claude Code --- src/adapters/anthropic.ts | 14 +++--- structure/providers-and-adapters.md | 2 +- .../claude-translated-server-tools.test.ts | 46 ++++++++++++++++++- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 54ca631266d..7f87856e168 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1330,17 +1330,19 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName }; } if (block.type === "server_tool_use" && block.name === "web_search" && typeof block.id === "string") { - // Anthropic ran the caller's typed web_search: surface it as the same search - // cell the sidecar emits. The query arrives as input_json_delta below. + // Only the query is collected here (input_json_delta below). The search 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 }; - yield { type: "web_search_call_begin", id: block.id }; } - if (block.type === "web_search_tool_result" && serverSearch && block.tool_use_id === serverSearch.id) { + if (block.type === "web_search_tool_result" && typeof block.tool_use_id === "string") { const hits = Array.isArray(block.content) ? block.content as Array<{ url?: unknown; title?: unknown }> : undefined; + // The begin/end pair the sidecar emits. A result whose call came in an earlier response has no query here. + yield { type: "web_search_call_begin", id: block.tool_use_id }; yield { type: "web_search_call_end", - id: serverSearch.id, - queries: webSearchQueries(serverSearch.json), + id: block.tool_use_id, + queries: serverSearch?.id === block.tool_use_id ? webSearchQueries(serverSearch.json) : [], // A failed search carries one error object instead of the hit list. status: hits ? "completed" : "failed", sources: (hits ?? []).flatMap(h => typeof h.url === "string" diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index e9aab404f08..5d384406795 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -202,7 +202,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/openai-responses.ts`, `src/adapters/openai-responses/` | Native OpenAI/ChatGPT Responses passthrough. The canonical ChatGPT adapter forces its upstream-only `stream: true` requirement without changing caller `store`; downstream JSON negotiation remains owned by the [Responses HTTP/SSE contract](transports/responses.md#responses-httpsse). | | `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`, `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`. 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. A streamed `server_tool_use` named `web_search` and its `web_search_tool_result` become `web_search_call_begin`/`web_search_call_end` (query, hit URLs and titles, or `failed` for an error result), which the Claude outbound renders back as the same block pair; `web_fetch` blocks and citations are not relayed. `tests/claude-integration/claude-translated-server-tools.test.ts` pins the wire, the sidecar priority and the client stream. | +| `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`. 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` 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), which the Claude outbound renders back as the same block pair; a search Anthropic defers beside a client `tool_use` has no result in that response and surfaces nothing, and the replayed pair is dropped by the inbound translation, so a result without `encrypted_content` never reaches Anthropic; `web_fetch` blocks and citations are not relayed. `tests/claude-integration/claude-translated-server-tools.test.ts` pins the wire, the sidecar priority and the client stream. | | `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-server-tools.test.ts b/tests/claude-integration/claude-translated-server-tools.test.ts index 16fdef309dc..7a9c1af906e 100644 --- a/tests/claude-integration/claude-translated-server-tools.test.ts +++ b/tests/claude-integration/claude-translated-server-tools.test.ts @@ -66,6 +66,7 @@ async function claudeTurn( baseUrl: string, frames: Record[], target: "anth" | "gpt" = "anth", + messages: unknown[] = [{ role: "user", content: "What did Bun release? https://bun.sh/blog" }], ): Promise<{ wire: Record; clientSse: string }> { const wires: string[] = []; const fetch = (async (_input: unknown, init?: RequestInit) => { @@ -94,7 +95,7 @@ async function claudeTurn( body: JSON.stringify({ model: "combo/waterfall", max_tokens: 64, stream: true, tools: [{ name: "Read", description: "read", input_schema: { type: "object", properties: {} } }, WEB_SEARCH, WEB_FETCH], - messages: [{ role: "user", content: "What did Bun release? https://bun.sh/blog" }], + messages, }), }), config, { model: "", provider: "" }); expect(response.status).toBe(200); @@ -146,6 +147,49 @@ describe("translated Claude lane carries typed web tools to Anthropic", () => { }); }); +const MESSAGE_START = SEARCH_TURN[0]!; +const MESSAGE_END = SEARCH_TURN.slice(-2); +const blockStarts = (clientSse: string) => clientFrames(clientSse) + .filter(f => f.type === "content_block_start").map(f => f.content_block as Record); + +describe("server-side search across responses", () => { + test("a search Anthropic defers beside a client tool_use is not surfaced as a completed search", async () => { + const { clientSse } = await claudeTurn("https://api.anthropic.com", [ + MESSAGE_START, + ...SEARCH_TURN.slice(1, 5), + { type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "toolu_1", name: "Read", input: {} } }, + { type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: "{}" } }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", delta: { stop_reason: "tool_use", stop_sequence: null }, usage: { output_tokens: 3 } }, + { type: "message_stop" }, + ]); + expect(blockStarts(clientSse).map(b => b.type)).toEqual(["tool_use"]); + }); + + test("a result whose call came in an earlier response still reaches the client by its tool_use_id", async () => { + const { clientSse } = await claudeTurn("https://api.anthropic.com", [MESSAGE_START, ...SEARCH_TURN.slice(5, 10), ...MESSAGE_END]); + const starts = blockStarts(clientSse); + expect(starts.map(b => b.type)).toEqual(["server_tool_use", "web_search_tool_result", "text"]); + expect(starts[1]).toMatchObject({ tool_use_id: starts[0]!.id, content: [{ type: "web_search_result", url: "https://bun.sh/blog" }] }); + }); + + test("a replayed search pair without encrypted_content never reaches Anthropic", async () => { + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", [ + { role: "user", content: "What did Bun release?" }, + { role: "assistant", content: [ + { type: "server_tool_use", id: "srvtoolu_1", name: "web_search", input: { query: "bun release" } }, + { type: "web_search_tool_result", tool_use_id: "srvtoolu_1", content: [{ type: "web_search_result", url: "https://bun.sh/blog", title: "Bun blog" }] }, + { type: "text", text: "Bun shipped." }, + ] }, + { role: "user", content: "More?" }, + ]); + const wireText = JSON.stringify(wire.messages); + expect(wireText).not.toContain("web_search_tool_result"); + expect(wireText).not.toContain("server_tool_use"); + expect(wireText).toContain("Bun shipped."); + }); +}); + describe("anthropic adapter: typed web tools beside function tools", () => { const provider = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "sk-x", authMode: "key" } as unknown as OcxProviderConfig; test("the web-search sidecar's web_search function keeps priority; web_fetch is still carried", async () => { From 30365c96307f61bf69dc1405c5af7b000d39c4ff Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 11:35:38 +0800 Subject: [PATCH 3/3] fix(anthropic): cap the server-tool input buffer on the incoming fragment A single input_json_delta fragment past 4 KiB was appended whole when the buffer was empty, so the result handler retained and parsed input the cap means to discard. Append at most the remaining capacity. Co-Authored-By: Claude Code --- src/adapters/anthropic.ts | 2 +- .../claude-translated-server-tools.test.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 7f87856e168..f911dac5798 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1396,7 +1396,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && serverSearch?.streaming && serverSearch.json.length < 4096) { // Capped: a query past 4 KiB loses its label (queries: []), never the search. - serverSearch.json += delta.partial_json; + serverSearch.json += delta.partial_json.slice(0, 4096 - serverSearch.json.length); } break; } diff --git a/tests/claude-integration/claude-translated-server-tools.test.ts b/tests/claude-integration/claude-translated-server-tools.test.ts index 7a9c1af906e..2ceec94a827 100644 --- a/tests/claude-integration/claude-translated-server-tools.test.ts +++ b/tests/claude-integration/claude-translated-server-tools.test.ts @@ -173,6 +173,17 @@ describe("server-side search across responses", () => { expect(starts[1]).toMatchObject({ tool_use_id: starts[0]!.id, content: [{ type: "web_search_result", url: "https://bun.sh/blog" }] }); }); + test("a single input fragment past 4 KiB is not retained beyond the cap", async () => { + const query = "q".repeat(5000); + const { clientSse } = await claudeTurn("https://api.anthropic.com", [ + ...SEARCH_TURN.slice(0, 2), + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify({ query }) } }, + ...SEARCH_TURN.slice(4), + ]); + expect(blockStarts(clientSse).map(b => b.type)).toEqual(["server_tool_use", "web_search_tool_result", "text"]); + expect(clientSse).not.toContain(query); + }); + test("a replayed search pair without encrypted_content never reaches Anthropic", async () => { const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", [ { role: "user", content: "What did Bun release?" },