From 0089283b743f8749137f175f7c4d3cc8128fb312 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 11:23:33 +0900 Subject: [PATCH 01/17] fix(claude): carry typed web_search/web_fetch to an Anthropic target on the translated lane (carry #7194) Carried from #7194. Co-authored-by: Vadym O --- .../src/content/docs/guides/claude-code.md | 2 +- scripts/test-layout/layout.json | 1 + src/adapters/anthropic.ts | 56 ++++- 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 | 205 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 13 files changed, 287 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..7f87856e168 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,27 @@ 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") { + // 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 }; + } + 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: 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" + ? [{ 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 +1393,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..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. | +| `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 new file mode 100644 index 00000000000..7a9c1af906e --- /dev/null +++ b/tests/claude-integration/claude-translated-server-tools.test.ts @@ -0,0 +1,205 @@ +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", + 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) => { + 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, + }), + }), 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 }); + }); +}); + +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 () => { + 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 4170d677a2bc540cddc25dee17269c50a1a89d86 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 11:23:58 +0900 Subject: [PATCH 02/17] fix(claude): relay Anthropic's server-side web_fetch results to the Claude client (carry #7201) Carried from #7201. Co-authored-by: Vadym O --- .../src/content/docs/guides/claude-code.md | 2 +- src/adapters/anthropic.ts | 39 +++++++++++---- src/bridge/internal.ts | 4 +- src/bridge/response-json.ts | 3 +- src/bridge/sse.ts | 9 ++-- src/claude/outbound.ts | 30 +++++++----- src/protocols/encoders/adapter-events.ts | 7 +-- src/protocols/encoders/messages.ts | 9 ++-- src/types/request.ts | 6 ++- structure/providers-and-adapters.md | 2 +- .../claude-translated-server-tools.test.ts | 48 ++++++++++++++++++- 11 files changed, 122 insertions(+), 37 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 077e3a62d6d..3d3f79255fe 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"}`). 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 | +| 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. Each search or fetch comes back to the client as its `server_tool_use` block and its `web_search_tool_result` or `web_fetch_tool_result` | | `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/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 7f87856e168..0d211a74446 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -521,13 +521,13 @@ 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[] { +/** A string field of a server tool's streamed input (web_search `query`, web_fetch `url`); "" when it did not parse. */ +function serverToolInputField(json: string, key: "query" | "url"): string { try { - const query = (JSON.parse(json) as { query?: unknown } | null)?.query; - return typeof query === "string" && query.length > 0 ? [query] : []; + const value = (JSON.parse(json) as Record | null)?.[key]; + return typeof value === "string" ? value : ""; } catch { - return []; + return ""; } } @@ -1244,7 +1244,7 @@ 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. */ + /** The open server-side web_search / web_fetch call, until its result block. */ let serverSearch: { id: string; json: string; streaming: boolean } | undefined; const emitDone = function* (): Generator { @@ -1329,8 +1329,8 @@ 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") { - // Only the query is collected here (input_json_delta below). The search cell + if (block.type === "server_tool_use" && (block.name === "web_search" || block.name === "web_fetch") && 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 }; @@ -1338,11 +1338,12 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti 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. + const query = serverSearch?.id === block.tool_use_id ? serverToolInputField(serverSearch.json, "query") : ""; yield { type: "web_search_call_begin", id: block.tool_use_id }; yield { type: "web_search_call_end", id: block.tool_use_id, - queries: serverSearch?.id === block.tool_use_id ? webSearchQueries(serverSearch.json) : [], + queries: query ? [query] : [], // A failed search carries one error object instead of the hit list. status: hits ? "completed" : "failed", sources: (hits ?? []).flatMap(h => typeof h.url === "string" @@ -1350,6 +1351,24 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti }; serverSearch = undefined; } + if (block.type === "web_fetch_tool_result" && typeof block.tool_use_id === "string") { + // Relayed as the same cell, opened at the result for the same reason; the result is + // carried verbatim (a web_fetch_result document or a web_fetch_tool_error). + const result = block.content ?? { type: "web_fetch_tool_error", error_code: "unavailable" }; + const url = (result as { url?: unknown }).url; + yield { type: "web_search_call_begin", id: block.tool_use_id }; + yield { + type: "web_search_call_end", + id: block.tool_use_id, + queries: [], + status: (result as { type?: unknown }).type === "web_fetch_result" ? "completed" : "failed", + fetch: { + url: typeof url === "string" ? url : serverSearch?.id === block.tool_use_id ? serverToolInputField(serverSearch.json, "url") : "", + result, + }, + }; + 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 }; @@ -1395,7 +1414,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti 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. + // Capped: an input past 4 KiB loses its label (query / url), never the call. serverSearch.json += delta.partial_json; } break; diff --git a/src/bridge/internal.ts b/src/bridge/internal.ts index 4b1543d1f13..45e70a610c4 100644 --- a/src/bridge/internal.ts +++ b/src/bridge/internal.ts @@ -186,7 +186,9 @@ export function adapterFailureFromEvent(event: Extract { +export function webSearchAction(queries: string[], fetch?: { url: string }): Record { + // Anthropic's server-side web_fetch opens one page instead of searching. + if (fetch) return { type: "open_page", url: fetch.url }; const first = queries[0] ?? ""; return { type: "search", query: first, queries: queries.length > 0 ? queries : [first] }; } diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index f5840a1e457..e6e1f714738 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -544,8 +544,9 @@ function buildResponseJSONWithBudget( const safeSources = safeWebSearchSources(e.sources); pushOutput({ type: "web_search_call", id: `ws_${uuid()}`, status: e.status ?? "completed", - action: webSearchAction(e.queries), + action: webSearchAction(e.queries, e.fetch), ...(safeSources.length > 0 ? { sources: safeSources } : {}), + ...(e.fetch ? { fetch_result: e.fetch.result } : {}), }); if (safeSources.length > 0) { for (const source of safeSources) { diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index 0a8340a503a..1e7e55d9a01 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -677,12 +677,15 @@ export function bridgeToResponsesSSE( // leaves a "Searching the web" spinner spinning forever. // `sources` rides on the done item (additive field; codex-rs serde ignores unknown fields) so // downstream translators (claude outbound) can fill web_search_tool_result content. - const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[]) => { + // `fetch` (Anthropic's server-side web_fetch) makes the cell a page open and rides its result + // verbatim as `fetch_result`, for the same claude outbound translator. + const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], fetch?: { url: string; result: unknown }) => { if (!currentWebSearch) return; const item = { type: "web_search_call", id: currentWebSearch.itemId, status, - action: webSearchAction(queries), + action: webSearchAction(queries, fetch), ...(sources && sources.length > 0 ? { sources } : {}), + ...(fetch ? { fetch_result: fetch.result } : {}), }; emit("response.output_item.done", { output_index: currentWebSearch.outputIndex, item }); retainFinishedItem(item as OutputItem); @@ -1220,7 +1223,7 @@ export function bridgeToResponsesSSE( currentWebSearch = { itemId: wsItemId2, eventId: event.id, outputIndex }; } const safeSources = safeWebSearchSources(event.sources); - closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources); + closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources, event.fetch); // Queue this search's sources for the next assistant message (dedup by URL). if (safeSources.length > 0) { for (const source of safeSources) { diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index b5e44e85ba9..c920d9acdb8 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -223,10 +223,20 @@ export function sanitizeWebSearchInput(input: unknown): Rec { /** * Map a Responses `web_search_call` item to its Anthropic pair: the server_tool_use * input (query/queries) and the web_search_tool_result content (hits, or the error - * object when the search failed). Shared by the SSE and JSON translation paths. + * object when the search failed). An item carrying `fetch_result` is Anthropic's + * server-side web_fetch and maps to web_fetch + its web_fetch_tool_result, verbatim. + * Shared by the SSE and JSON translation paths. */ -export function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultContent: unknown; completed: boolean } { +export function webSearchPairFromItem(item: Rec): { + id: string; name: "web_search" | "web_fetch"; resultType: string; input: Rec; resultContent: unknown; completed: boolean; +} { const action = isRec(item.action) ? item.action : {}; + const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`; + const completed = item.status !== "failed"; + if (item.fetch_result !== undefined) { + const input = { url: typeof action.url === "string" ? action.url : "" }; + return { id, name: "web_fetch", resultType: "web_fetch_tool_result", input, resultContent: item.fetch_result, completed }; + } const queries = Array.isArray(action.queries) ? action.queries.filter((q): q is string => typeof q === "string" && q.length > 0) : []; @@ -234,7 +244,6 @@ export function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resu const input = sanitizeWebSearchInput( queries.length > 1 ? { queries } : { query: queries[0] ?? query }, ); - const completed = item.status !== "failed"; let resultContent: unknown; if (completed) { const hits: Rec[] = []; @@ -249,8 +258,7 @@ export function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resu } else { resultContent = { type: "web_search_tool_result_error", error_code: "unavailable" }; } - const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`; - return { id, input, resultContent, completed }; + return { id, name: "web_search", resultType: "web_search_tool_result", input, resultContent, completed }; } /** @@ -712,7 +720,7 @@ export function responsesSseToAnthropicSse( const toolIndex = blockIndex++; emit("content_block_start", { type: "content_block_start", index: toolIndex, - content_block: { type: "server_tool_use", id: pair.id, name: "web_search" }, + content_block: { type: "server_tool_use", id: pair.id, name: pair.name }, }); emit("content_block_delta", { type: "content_block_delta", index: toolIndex, @@ -722,10 +730,10 @@ export function responsesSseToAnthropicSse( const resultIndex = blockIndex++; emit("content_block_start", { type: "content_block_start", index: resultIndex, - content_block: { type: "web_search_tool_result", tool_use_id: pair.id, content: pair.resultContent }, + content_block: { type: pair.resultType, tool_use_id: pair.id, content: pair.resultContent }, }); emit("content_block_stop", { type: "content_block_stop", index: resultIndex }); - if (pair.completed) webSearchRequests++; + if (pair.completed && pair.name === "web_search") webSearchRequests++; break; } // Close the matching open block (message/reasoning items close implicitly on @@ -1051,9 +1059,9 @@ 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); - content.push({ type: "server_tool_use", id: pair.id, name: "web_search", input: pair.input }); - content.push({ type: "web_search_tool_result", tool_use_id: pair.id, content: pair.resultContent }); - if (pair.completed) webSearchRequests++; + 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++; break; } default: diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index 8c10eb2db49..d5ddc7cab40 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -61,6 +61,7 @@ export interface ClientWebSearch { status: "completed" | "failed"; queries: string[]; sources: { url: string; title?: string }[]; + fetch?: { url: string; result: unknown }; } /** @@ -411,9 +412,9 @@ export function encodeAdapterEventStream( budget.closeCall(call.callId); currentToolCall = null; }; - const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[]) => { + const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], fetch?: ClientWebSearch["fetch"]) => { if (!currentWebSearch) return; - writer.webSearchDone({ itemId: currentWebSearch.itemId, status, queries, sources: sources ?? [] }); + writer.webSearchDone({ itemId: currentWebSearch.itemId, status, queries, sources: sources ?? [], ...(fetch ? { fetch } : {}) }); currentWebSearch = null; }; const appendToolArgs = (call: OpenToolCall, fragment: string) => { @@ -688,7 +689,7 @@ export function encodeAdapterEventStream( if (currentWebSearch) closeCurrentWebSearch("completed", []); currentWebSearch = { itemId: `ws_${uuid()}`, eventId: event.id }; } - closeCurrentWebSearch(event.status ?? "completed", event.queries, safeWebSearchSources(event.sources)); + closeCurrentWebSearch(event.status ?? "completed", event.queries, safeWebSearchSources(event.sources), event.fetch); break; } case "done": { diff --git a/src/protocols/encoders/messages.ts b/src/protocols/encoders/messages.ts index cbcf746ac46..9aee92aee3b 100644 --- a/src/protocols/encoders/messages.ts +++ b/src/protocols/encoders/messages.ts @@ -345,13 +345,14 @@ function createAnthropicMessageWriter( type: "web_search_call", id: search.itemId, status: search.status, - action: webSearchAction(search.queries), + action: webSearchAction(search.queries, search.fetch), ...(search.sources.length > 0 ? { sources: search.sources } : {}), + ...(search.fetch ? { fetch_result: search.fetch.result } : {}), }); const toolIndex = blockIndex++; emit("content_block_start", { type: "content_block_start", index: toolIndex, - content_block: { type: "server_tool_use", id: pair.id, name: "web_search" }, + content_block: { type: "server_tool_use", id: pair.id, name: pair.name }, }, { sideEffect: true }); emit("content_block_delta", { type: "content_block_delta", index: toolIndex, @@ -361,10 +362,10 @@ function createAnthropicMessageWriter( const resultIndex = blockIndex++; emit("content_block_start", { type: "content_block_start", index: resultIndex, - content_block: { type: "web_search_tool_result", tool_use_id: pair.id, content: pair.resultContent }, + content_block: { type: pair.resultType, tool_use_id: pair.id, content: pair.resultContent }, }); emit("content_block_stop", { type: "content_block_stop", index: resultIndex }); - if (pair.completed) webSearchRequests++; + if (pair.completed && pair.name === "web_search") webSearchRequests++; }, terminal(terminal) { if (terminal.status === "completed") { diff --git a/src/types/request.ts b/src/types/request.ts index 1071456a543..2c2475282ef 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -431,7 +431,11 @@ export type AdapterEvent = // output_item.added(in_progress) and end → the matching output_item.done(completed|failed) under // the SAME output index, so the activity animates instead of flashing completed instantly. | { type: "web_search_call_begin"; id: string } - | { type: "web_search_call_end"; id: string; queries: string[]; status?: "completed" | "failed"; sources?: OcxUrlCitation[] } + | { + type: "web_search_call_end"; id: string; queries: string[]; status?: "completed" | "failed"; sources?: OcxUrlCitation[]; + /** Anthropic's server-side web_fetch (adapters/anthropic.ts): the opened page and its `web_fetch_tool_result` content, verbatim. */ + fetch?: { url: string; result: unknown }; + } | { type: "done"; usage?: OcxUsage; diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 5d384406795..b5d336e0c32 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. 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.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. A streamed `web_fetch_tool_result` opens and closes the same cell with `fetch` set (the fetched URL and Anthropic's result content, verbatim); the bridge records it as an `open_page` `web_search_call` carrying `fetch_result`, and the Claude outbound renders it back as `server_tool_use` `web_fetch` + that `web_fetch_tool_result`, not counted in `web_search_requests`. Citations are not relayed. `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. | | `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 7a9c1af906e..75e45c572cf 100644 --- a/tests/claude-integration/claude-translated-server-tools.test.ts +++ b/tests/claude-integration/claude-translated-server-tools.test.ts @@ -67,6 +67,7 @@ async function claudeTurn( frames: Record[], target: "anth" | "gpt" = "anth", messages: unknown[] = [{ role: "user", content: "What did Bun release? https://bun.sh/blog" }], + { stream = true, direct = false }: { stream?: boolean; direct?: boolean } = {}, ): Promise<{ wire: Record; clientSse: string }> { const wires: string[] = []; const fetch = (async (_input: unknown, init?: RequestInit) => { @@ -81,6 +82,7 @@ async function claudeTurn( port: 0, defaultProvider: "anth", claudeCode: { nativePassthrough: false }, webSearchSidecar: { enabled: false }, + ...(direct ? { protocols: { rollout: { directEncoders: true } } } : {}), 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 }, @@ -93,7 +95,7 @@ async function claudeTurn( method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - model: "combo/waterfall", max_tokens: 64, stream: true, + model: direct ? `anth/${MODEL}` : "combo/waterfall", max_tokens: 64, stream, tools: [{ name: "Read", description: "read", input_schema: { type: "object", properties: {} } }, WEB_SEARCH, WEB_FETCH], messages, }), @@ -190,6 +192,50 @@ describe("server-side search across responses", () => { }); }); +const FETCH_RESULT = { + type: "web_fetch_result", url: "https://bun.sh/blog", retrieved_at: "2026-10-15T00:00:00Z", + content: { type: "document", title: "Bun blog", citations: { enabled: true }, source: { type: "text", media_type: "text/plain", data: "Bun 2 is out." } }, +}; +const fetchTurn = (result: unknown) => [ + MESSAGE_START, + { type: "content_block_start", index: 0, content_block: { type: "server_tool_use", id: "srvtoolu_f", name: "web_fetch", input: {} } }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: "{\"url\":\"https://bun.sh/blog\"}" } }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "web_fetch_tool_result", tool_use_id: "srvtoolu_f", content: result } }, + { type: "content_block_stop", index: 1 }, + ...SEARCH_TURN.slice(7, 10), + { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 5, server_tool_use: { web_fetch_requests: 1 } } }, + { type: "message_stop" }, +]; + +describe("server-side web_fetch reaches the Claude client", () => { + for (const [lane, opts] of [["bridge stream", {}], ["bridge JSON", { stream: false }], ["direct encoder", { direct: true }]] as const) { + test(`${lane}: server_tool_use web_fetch + its web_fetch_tool_result, verbatim`, async () => { + for (const result of [FETCH_RESULT, { type: "web_fetch_tool_error", error_code: "url_not_accessible" }]) { + const { clientSse } = await claudeTurn("https://api.anthropic.com", fetchTurn(result), "anth", undefined, opts); + let blocks: Record[]; + let usage: Record; + if ("stream" in opts) { + const message = JSON.parse(clientSse) as { content: Record[]; usage: Record }; + blocks = message.content; + usage = message.usage; + } else { + const frames = clientFrames(clientSse); + blocks = blockStarts(clientSse); + const input = frames.find(f => f.type === "content_block_delta" && (f.delta as Record).type === "input_json_delta")!; + blocks[0] = { ...blocks[0], input: JSON.parse((input.delta as { partial_json: string }).partial_json) }; + usage = frames.find(f => f.type === "message_delta")!.usage as Record; + } + expect(blocks.map(b => b.type)).toEqual(["server_tool_use", "web_fetch_tool_result", "text"]); + expect(blocks[0]).toMatchObject({ name: "web_fetch", input: { url: "https://bun.sh/blog" } }); + expect(blocks[1]).toEqual({ type: "web_fetch_tool_result", tool_use_id: blocks[0]!.id, content: result }); + // A fetch is not a search. + expect(usage.server_tool_use).toBeUndefined(); + } + }); + } +}); + 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 a32d311d2f983b4b89b7a4d1f4244bacd9151e21 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 11:24:32 +0900 Subject: [PATCH 03/17] fix(claude): replay Anthropic server-tool exchanges on the translated lane (carry #7205) Carried from #7205. Co-authored-by: Vadym O --- .../src/content/docs/guides/claude-code.md | 2 +- src/adapters/anthropic.ts | 63 ++++++++-------- src/bridge/internal.ts | 8 ++- src/bridge/response-json.ts | 4 +- src/bridge/sse.ts | 13 ++-- src/claude/inbound-content-options.ts | 36 ++++++++++ src/claude/outbound.ts | 17 ++--- src/protocols/encoders/adapter-events.ts | 10 +-- src/protocols/encoders/messages.ts | 4 +- 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.ts | 1 + src/types/request.ts | 12 +++- structure/clients/claude-desktop.md | 2 +- structure/providers-and-adapters.md | 2 +- .../claude-translated-server-tools.test.ts | 71 +++++++++++++++++++ 18 files changed, 192 insertions(+), 60 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 3d3f79255fe..60a601df72f 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"}`). 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. Each search or fetch comes back to the client as its `server_tool_use` block and its `web_search_tool_result` or `web_fetch_tool_result` | +| 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. Each search or fetch comes back to the client as its `server_tool_use` block and its `web_search_tool_result` or `web_fetch_tool_result`, verbatim. When the client sends that pair back in its history, the next `api.anthropic.com` request carries it in place; other destinations never see it | | `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/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 0d211a74446..0e14c6f2020 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -521,13 +521,13 @@ function streamedToolArgumentsParse(assembled: string): boolean { } } -/** A string field of a server tool's streamed input (web_search `query`, web_fetch `url`); "" when it did not parse. */ -function serverToolInputField(json: string, key: "query" | "url"): string { +/** A server tool's streamed input (web_search `{query}`, web_fetch `{url}`); {} when it did not parse. */ +function serverToolInput(json: string): Record { try { - const value = (JSON.parse(json) as Record | null)?.[key]; - return typeof value === "string" ? value : ""; + const input = JSON.parse(json) as unknown; + return isAnthropicRecord(input) ? input : {}; } catch { - return ""; + return {}; } } @@ -734,7 +734,17 @@ function orphanToolResultContent(msg: OcxToolResultMessage): string | unknown[] function messagesToAnthropicFormat( parsed: OcxParsedRequest, toolNames: { toWire: (name: string) => string }, + serverReplay?: ReadonlyMap[]>, ): { system: string | undefined; messages: unknown[] } { + // The client's server-tool exchanges go back ahead of the block they preceded. Each key is used + // once. ponytail: an identical text reused across turns takes the run at its first occurrence; + // key on message index + text if that ever misplaces one. + const pendingReplay = new Map(serverReplay); + const replayed = (key: string) => { + const blocks = pendingReplay.get(key) ?? []; + pendingReplay.delete(key); + return blocks; + }; // One allocator for the whole request: a tool_result must resolve to the SAME wire id its // call got, and two distinct raw ids must never collapse into one. Conforming ids are claimed // first so a rewritten id can never squat on an id another call legitimately owns. @@ -788,9 +798,13 @@ function messagesToAnthropicFormat( for (const part of aMsg.content) { if (part.type === "text") { const text = (part as OcxTextContent).text; + for (const b of replayed(`text:${text}`)) appendPreface(b); if (text) appendPreface({ type: "text", text }); } else if (part.type === "thinking") { const t = part as OcxThinkingContent; + // Server blocks that preceded this reasoning keep their place, even when it is dropped below. + for (const data of t.redacted ?? []) for (const b of replayed(`redacted:${data}`)) appendPreface(b); + for (const b of replayed(`thinking:${t.signature}`)) appendPreface(b); // History minted under another serving identity (or already rejected as opaque) is not // this destination's to verify: drop its opaque blocks, as the Responses passthrough does. if (parsed._stripReasoningEncryptedContent === true) continue; @@ -805,6 +819,7 @@ function messagesToAnthropicFormat( } } else if (part.type === "toolCall") { const tc = part as OcxToolCall; + for (const b of replayed(`call:${tc.id}`)) appendPreface(b); const flatName = namespacedToolName(tc.namespace, tc.name); // Normalized here, and identically for the matching tool_result above, so a history // replayed from another provider path keeps its call/result pairing (#1767). @@ -997,7 +1012,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti throw new Error("anthropic provider requires a non-empty apiKey (authMode: key)"); } - const { system, messages } = messagesToAnthropicFormat(parsed, toolNames); + // Only api.anthropic.com runs Anthropic's server tools, so only it gets their history back. + const { system, messages } = messagesToAnthropicFormat(parsed, toolNames, + usesNativeAnthropicEndpoint(provider) ? parsed._anthropicServerReplay : undefined); // 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 @@ -1335,36 +1352,24 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // no result in this response and must not surface as a completed search. serverSearch = { id: block.id, json: "", streaming: true }; } - if (block.type === "web_search_tool_result" && typeof block.tool_use_id === "string") { + 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 + // replayable copy. A result whose call came in an earlier response has no streamed input here. + const fetch = block.type === "web_fetch_tool_result"; + const input = serverSearch?.id === block.tool_use_id ? serverToolInput(serverSearch.json) : {}; 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. - const query = serverSearch?.id === block.tool_use_id ? serverToolInputField(serverSearch.json, "query") : ""; yield { type: "web_search_call_begin", id: block.tool_use_id }; yield { type: "web_search_call_end", id: block.tool_use_id, - queries: query ? [query] : [], - // A failed search carries one error object instead of the hit list. - status: hits ? "completed" : "failed", + queries: typeof input.query === "string" && input.query.length > 0 ? [input.query] : [], + // A failed call carries one error object instead of the hit list / fetched page. + status: (fetch ? (block.content as { type?: unknown } | undefined)?.type === "web_fetch_result" : 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 === "web_fetch_tool_result" && typeof block.tool_use_id === "string") { - // Relayed as the same cell, opened at the result for the same reason; the result is - // carried verbatim (a web_fetch_result document or a web_fetch_tool_error). - const result = block.content ?? { type: "web_fetch_tool_error", error_code: "unavailable" }; - const url = (result as { url?: unknown }).url; - yield { type: "web_search_call_begin", id: block.tool_use_id }; - yield { - type: "web_search_call_end", - id: block.tool_use_id, - queries: [], - status: (result as { type?: unknown }).type === "web_fetch_result" ? "completed" : "failed", - fetch: { - url: typeof url === "string" ? url : serverSearch?.id === block.tool_use_id ? serverToolInputField(serverSearch.json, "url") : "", - result, + anthropic: { + use: { type: "server_tool_use", id: block.tool_use_id, name: fetch ? "web_fetch" : "web_search", input }, + result: block as Record, }, }; serverSearch = undefined; diff --git a/src/bridge/internal.ts b/src/bridge/internal.ts index 45e70a610c4..4c2f7e2352d 100644 --- a/src/bridge/internal.ts +++ b/src/bridge/internal.ts @@ -1,5 +1,6 @@ import type { AdapterEvent, + AnthropicServerBlocks, OcxMessagePhase, OcxProviderContinuationState, OcxProviderOpaqueToolCallMetadata, @@ -186,9 +187,12 @@ export function adapterFailureFromEvent(event: Extract { +export function webSearchAction(queries: string[], anthropic?: AnthropicServerBlocks): Record { // Anthropic's server-side web_fetch opens one page instead of searching. - if (fetch) return { type: "open_page", url: fetch.url }; + if (anthropic?.use.name === "web_fetch") { + const url = (anthropic.result.content as { url?: unknown } | undefined)?.url ?? (anthropic.use.input as { url?: unknown }).url; + return { type: "open_page", url: typeof url === "string" ? url : "" }; + } const first = queries[0] ?? ""; return { type: "search", query: first, queries: queries.length > 0 ? queries : [first] }; } diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index e6e1f714738..07c2c4a6e09 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -544,9 +544,9 @@ function buildResponseJSONWithBudget( const safeSources = safeWebSearchSources(e.sources); pushOutput({ type: "web_search_call", id: `ws_${uuid()}`, status: e.status ?? "completed", - action: webSearchAction(e.queries, e.fetch), + action: webSearchAction(e.queries, e.anthropic), ...(safeSources.length > 0 ? { sources: safeSources } : {}), - ...(e.fetch ? { fetch_result: e.fetch.result } : {}), + ...(e.anthropic ? { anthropic_blocks: e.anthropic } : {}), }); if (safeSources.length > 0) { for (const source of safeSources) { diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index 1e7e55d9a01..d2b89035bc1 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -1,6 +1,7 @@ import { anthropicProgressItems } from "../responses/anthropic-progress"; import type { AdapterEvent, + AnthropicServerBlocks, OcxMessagePhase, OcxProviderContinuationState, OcxProviderOpaqueToolCallMetadata, @@ -677,15 +678,15 @@ export function bridgeToResponsesSSE( // leaves a "Searching the web" spinner spinning forever. // `sources` rides on the done item (additive field; codex-rs serde ignores unknown fields) so // downstream translators (claude outbound) can fill web_search_tool_result content. - // `fetch` (Anthropic's server-side web_fetch) makes the cell a page open and rides its result - // verbatim as `fetch_result`, for the same claude outbound translator. - const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], fetch?: { url: string; result: unknown }) => { + // `anthropic` (Anthropic's server-side call) rides verbatim as `anthropic_blocks`, for the same + // claude outbound translator; a web_fetch makes the cell a page open. + const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], anthropic?: AnthropicServerBlocks) => { if (!currentWebSearch) return; const item = { type: "web_search_call", id: currentWebSearch.itemId, status, - action: webSearchAction(queries, fetch), + action: webSearchAction(queries, anthropic), ...(sources && sources.length > 0 ? { sources } : {}), - ...(fetch ? { fetch_result: fetch.result } : {}), + ...(anthropic ? { anthropic_blocks: anthropic } : {}), }; emit("response.output_item.done", { output_index: currentWebSearch.outputIndex, item }); retainFinishedItem(item as OutputItem); @@ -1223,7 +1224,7 @@ export function bridgeToResponsesSSE( currentWebSearch = { itemId: wsItemId2, eventId: event.id, outputIndex }; } const safeSources = safeWebSearchSources(event.sources); - closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources, event.fetch); + closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources, event.anthropic); // Queue this search's sources for the next assistant message (dedup by URL). if (safeSources.length > 0) { for (const source of safeSources) { diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts index 1358de6e1a8..98e5d514f5b 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -87,6 +87,42 @@ export function declaredAnthropicServerTools(raw: unknown): Rec[] | undefined { return tools.length > 0 ? tools : undefined; } +/** + * 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 + * 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. A run with none, an unpaired call, a + * non-Anthropic id, or a search hit without `encrypted_content` is not replayable and stays dropped. + */ +export function replayedAnthropicServerBlocks(raw: unknown): Map | undefined { + if (!isRec(raw) || !Array.isArray(raw.messages)) return undefined; + const out = new Map(); + for (const message of raw.messages) { + if (!isRec(message) || message.role !== "assistant" || !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") { + // The caller's breakpoints are dropped on this lane, as for the declared tools above. + run.push({ ...block, cache_control: undefined }); + continue; + } + const key = block.type === "tool_use" ? `call:${String(block.id)}` + : block.type === "text" ? `text:${String(block.text)}` + : block.type === "thinking" ? `thinking:${String(block.signature)}` + : block.type === "redacted_thinking" ? `redacted:${String(block.data)}` : undefined; + const uses = run.filter(b => b.type === "server_tool_use"); + const results = run.filter(b => b.type !== "server_tool_use"); + const replayable = uses.length > 0 && uses.length === results.length + && uses.every(u => typeof u.id === "string" && u.id.startsWith("srvtoolu_") && results.some(r => r.tool_use_id === u.id)) + && results.every(r => !Array.isArray(r.content) || r.content.every(h => isRec(h) && typeof h.encrypted_content === "string")); + if (key && replayable && !out.has(key)) out.set(key, run); + run = []; + } + } + return out.size > 0 ? out : 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/claude/outbound.ts b/src/claude/outbound.ts index c920d9acdb8..a6af40041c3 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -223,20 +223,21 @@ export function sanitizeWebSearchInput(input: unknown): Rec { /** * Map a Responses `web_search_call` item to its Anthropic pair: the server_tool_use * input (query/queries) and the web_search_tool_result content (hits, or the error - * object when the search failed). An item carrying `fetch_result` is Anthropic's - * server-side web_fetch and maps to web_fetch + its web_fetch_tool_result, verbatim. - * Shared by the SSE and JSON translation paths. + * 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. + * Shared by the SSE, JSON and direct translation paths. */ export function webSearchPairFromItem(item: Rec): { - id: string; name: "web_search" | "web_fetch"; resultType: string; input: Rec; resultContent: unknown; completed: boolean; + id: string; name: string; resultType: string; input: Rec; resultContent: unknown; completed: boolean; } { const action = isRec(item.action) ? item.action : {}; - const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`; const completed = item.status !== "failed"; - if (item.fetch_result !== undefined) { - const input = { url: typeof action.url === "string" ? action.url : "" }; - return { id, name: "web_fetch", resultType: "web_fetch_tool_result", input, resultContent: item.fetch_result, completed }; + const { use, result } = isRec(item.anthropic_blocks) ? item.anthropic_blocks : {}; + if (isRec(use) && isRec(result) && typeof use.id === "string" && typeof use.name === "string" && typeof result.type === "string") { + return { id: use.id, name: use.name, resultType: result.type, input: isRec(use.input) ? use.input : {}, resultContent: result.content, completed }; } + const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`; const queries = Array.isArray(action.queries) ? action.queries.filter((q): q is string => typeof q === "string" && q.length > 0) : []; diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index d5ddc7cab40..45af2c896cc 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -28,7 +28,7 @@ * completed-response effects belong to the caller, which folds the same events with * `buildResponseJSON` at the terminal through `beforeTerminal`. */ -import type { AdapterEvent, OcxMessagePhase, OcxProviderContinuationState, OcxUsage } from "../../types"; +import type { AdapterEvent, AnthropicServerBlocks, OcxMessagePhase, OcxProviderContinuationState, OcxUsage } from "../../types"; import { normalizeDeclaredToolName } from "../../types"; import { coerceIntegerToolArguments } from "../../lib/tool-argument-integers"; import { classifyError, isCyberPolicyCode, type OcxErrorPayload } from "../../lib/errors"; @@ -61,7 +61,7 @@ export interface ClientWebSearch { status: "completed" | "failed"; queries: string[]; sources: { url: string; title?: string }[]; - fetch?: { url: string; result: unknown }; + anthropic?: AnthropicServerBlocks; } /** @@ -412,9 +412,9 @@ export function encodeAdapterEventStream( budget.closeCall(call.callId); currentToolCall = null; }; - const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], fetch?: ClientWebSearch["fetch"]) => { + const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], anthropic?: AnthropicServerBlocks) => { if (!currentWebSearch) return; - writer.webSearchDone({ itemId: currentWebSearch.itemId, status, queries, sources: sources ?? [], ...(fetch ? { fetch } : {}) }); + writer.webSearchDone({ itemId: currentWebSearch.itemId, status, queries, sources: sources ?? [], ...(anthropic ? { anthropic } : {}) }); currentWebSearch = null; }; const appendToolArgs = (call: OpenToolCall, fragment: string) => { @@ -689,7 +689,7 @@ export function encodeAdapterEventStream( if (currentWebSearch) closeCurrentWebSearch("completed", []); currentWebSearch = { itemId: `ws_${uuid()}`, eventId: event.id }; } - closeCurrentWebSearch(event.status ?? "completed", event.queries, safeWebSearchSources(event.sources), event.fetch); + closeCurrentWebSearch(event.status ?? "completed", event.queries, safeWebSearchSources(event.sources), event.anthropic); break; } case "done": { diff --git a/src/protocols/encoders/messages.ts b/src/protocols/encoders/messages.ts index 9aee92aee3b..9eee75fdb15 100644 --- a/src/protocols/encoders/messages.ts +++ b/src/protocols/encoders/messages.ts @@ -345,9 +345,9 @@ function createAnthropicMessageWriter( type: "web_search_call", id: search.itemId, status: search.status, - action: webSearchAction(search.queries, search.fetch), + action: webSearchAction(search.queries, search.anthropic), ...(search.sources.length > 0 ? { sources: search.sources } : {}), - ...(search.fetch ? { fetch_result: search.fetch.result } : {}), + ...(search.anthropic ? { anthropic_blocks: search.anthropic } : {}), }); const toolIndex = blockIndex++; emit("content_block_start", { diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 0e7479b4fcd..1c7de5ec0d0 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -35,7 +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 { declaredAnthropicServerTools, replayedAnthropicServerBlocks } 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"; @@ -1431,6 +1431,7 @@ async function handleClaudeMessagesWithBudget( nativeReasoningMint, // Out of body like the replay above: the core hands these to an Anthropic destination only. anthropicServerTools: declaredAnthropicServerTools(anthropicBody), + anthropicServerReplay: replayedAnthropicServerBlocks(anthropicBody), claudeGoAffinity: { sessionLane: claudeGoSessionLane }, claudeNativeSessionId, stripClaudeMainAuthForNoncanonicalForward: true, diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index daac9b1cebe..3ee37545491 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -177,6 +177,8 @@ export interface HandleResponsesOptions { nativeReasoningMint?: { owner?: NativeReasoningOwner }; /** The Claude client's typed `web_search_*` / `web_fetch_*` declarations; kept only for `inboundWire: "anthropic"`. */ anthropicServerTools?: readonly Record[]; + /** The Claude client's replayable server-tool exchanges (`replayedAnthropicServerBlocks`); same scope. */ + anthropicServerReplay?: ReadonlyMap[]>; /** 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 1ffbb23a125..a2fb7c068af 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -474,6 +474,7 @@ export async function prepareResponsesRequest( parsed._nativeReasoningReplay = options.nativeReasoningReplay; parsed._nativeReasoningMint = options.nativeReasoningMint; parsed._anthropicServerTools = options.anthropicServerTools; + parsed._anthropicServerReplay = options.anthropicServerReplay; } 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 9b29aae01c4..e901594e401 100644 --- a/src/server/responses/response-effects.ts +++ b/src/server/responses/response-effects.ts @@ -90,6 +90,7 @@ export function createResponsesEffects( delete parsed.context.tools; delete parsed._webSearch; delete parsed._anthropicServerTools; + delete parsed._anthropicServerReplay; 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.ts b/src/types.ts index 2abde60e2af..f94998ed276 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,6 +61,7 @@ export type { OcxProviderContinuationOwner, OcxProviderContinuationState, AdapterEvent, + AnthropicServerBlocks, OcxUrlCitation, OcxUsage, } from "./types/request"; diff --git a/src/types/request.ts b/src/types/request.ts index 2c2475282ef..91442321476 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -67,6 +67,11 @@ export interface OcxParsedRequest { * them, and only for api.anthropic.com; every other destination never sees them. */ _anthropicServerTools?: readonly Record[]; + /** + * Completed server-tool exchanges from the Claude client's history, keyed by the block that + * followed them (`call:` / `text:`). Same audience as `_anthropicServerTools`. + */ + _anthropicServerReplay?: ReadonlyMap[]>; /** * 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 @@ -404,6 +409,9 @@ export interface OcxProviderContinuationState { [provider: string]: Record | undefined; } +/** One Anthropic server-side call as the API returned it: `server_tool_use` + its `*_tool_result`. */ +export interface AnthropicServerBlocks { use: Record; result: Record } + export type AdapterEvent = | { type: "heartbeat"; replayUnsafe?: true; preflightReady?: true } | { type: "text_delta"; text: string; phase?: OcxMessagePhase; @@ -433,8 +441,8 @@ export type AdapterEvent = | { type: "web_search_call_begin"; id: string } | { type: "web_search_call_end"; id: string; queries: string[]; status?: "completed" | "failed"; sources?: OcxUrlCitation[]; - /** Anthropic's server-side web_fetch (adapters/anthropic.ts): the opened page and its `web_fetch_tool_result` content, verbatim. */ - fetch?: { url: string; result: unknown }; + /** Anthropic's server-side call (adapters/anthropic.ts): its `server_tool_use` and result block, verbatim. */ + anthropic?: AnthropicServerBlocks; } | { type: "done"; diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index a46bf6bc6f2..6ed41f89f1b 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 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/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. `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) and 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. `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 b5d336e0c32..d0e3d1c1c48 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. 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. A streamed `web_fetch_tool_result` opens and closes the same cell with `fetch` set (the fetched URL and Anthropic's result content, verbatim); the bridge records it as an `open_page` `web_search_call` carrying `fetch_result`, and the Claude outbound renders it back as `server_tool_use` `web_fetch` + that `web_fetch_tool_result`, not counted in `web_search_requests`. Citations are not relayed. `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. | +| `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` 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 streamed input and the result block, verbatim. 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; a pair without `encrypted_content`, or with no such block after it, stays dropped. A malformed `anthropic_blocks` 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. | | `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 75e45c572cf..09641e9a440 100644 --- a/tests/claude-integration/claude-translated-server-tools.test.ts +++ b/tests/claude-integration/claude-translated-server-tools.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createAnthropicAdapter } from "../../src/adapters/anthropic"; +import { webSearchPairFromItem } from "../../src/claude/outbound"; import { handleClaudeMessages } from "../../src/server/claude-messages"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; @@ -249,3 +250,73 @@ describe("anthropic adapter: typed web tools beside function tools", () => { expect(tools.map(t => t.type ?? "function")).toEqual(["function", "web_fetch_20250910"]); }); }); + +/** The client's content blocks rebuilt from its SSE, as Claude Code stores them for the next turn. */ +function clientContent(clientSse: string): Record[] { + const blocks: Record[] = []; + const inputs: string[] = []; + for (const f of clientFrames(clientSse)) { + const i = f.index as number; + const delta = f.delta as Record | undefined; + if (f.type === "content_block_start") blocks[i] = { ...(f.content_block as Record) }; + else if (delta?.type === "input_json_delta") inputs[i] = (inputs[i] ?? "") + delta.partial_json; + else if (delta?.type === "text_delta") blocks[i]!.text = String(blocks[i]!.text) + delta.text; + } + return blocks.map((b, i) => inputs[i] ? { ...b, input: JSON.parse(inputs[i]!) } : b); +} + +describe("a server-tool exchange the client replays reaches Anthropic in place", () => { + const followUp = (assistant: unknown[], next: unknown = "More?") => [ + { role: "user", content: "What did Bun release?" }, { role: "assistant", content: assistant }, { role: "user", content: next }, + ]; + + test("web_search pair before text: original srvtoolu_ id and encrypted_content, only to api.anthropic.com", async () => { + const turn1 = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", followUp(turn1)); + const content = (wire.messages as { content: Record[] }[])[1]!.content; + expect(content.slice(0, 2)).toEqual([ + { type: "server_tool_use", id: "srvtoolu_1", name: "web_search", input: { query: "bun release" } }, + SEARCH_TURN[5]!.content_block as Record, + ]); + expect(content[2]).toMatchObject({ type: "text", text: "Bun shipped." }); + expect(content).toHaveLength(3); + for (const [baseUrl, target] of [["https://compat.example.com", "anth"], ["https://api.anthropic.com", "gpt"]] as const) { + const other = JSON.stringify((await claudeTurn(baseUrl, OK_TURN, target, followUp(turn1))).wire); + expect(other).not.toContain("srvtoolu_1"); + expect(other).toContain("Bun shipped."); + } + }); + + test("web_fetch pair before a client tool_use keeps its place ahead of the tool_use", async () => { + const turn1 = clientContent((await claudeTurn("https://api.anthropic.com", fetchTurn(FETCH_RESULT))).clientSse); + const toolUse = { type: "tool_use", id: "toolu_1", name: "Read", input: {} }; + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", + followUp([...turn1.slice(0, 2), toolUse], [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }])); + const content = (wire.messages as { content: Record[] }[])[1]!.content; + expect(content).toEqual([ + { type: "server_tool_use", id: "srvtoolu_f", name: "web_fetch", input: { url: "https://bun.sh/blog" } }, + { type: "web_fetch_tool_result", tool_use_id: "srvtoolu_f", content: FETCH_RESULT }, + toolUse, + ]); + }); + + test("a pair between thinking blocks stays between them", async () => { + const [use, result] = (await claudeTurn("https://api.anthropic.com", SEARCH_TURN).then(r => clientContent(r.clientSse))).slice(0, 2); + const thinking = (signature: string) => ({ type: "thinking", thinking: `t ${signature}`, signature }); + const redacted = { type: "redacted_thinking", data: "RedactedPayload0123456789" }; + const second = [{ ...use, id: "srvtoolu_2" }, { ...result, tool_use_id: "srvtoolu_2" }]; + const assistant = [thinking("SigOne0123456789abcdef"), use, result, thinking("SigTwo0123456789abcdef"), ...second, redacted]; + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", followUp([...assistant, { type: "text", text: "Bun shipped." }])); + const content = (wire.messages as { content: Record[] }[])[1]!.content; + expect(content.map(b => b.id ?? b.tool_use_id ?? b.type)).toEqual([ + "thinking", "srvtoolu_1", "srvtoolu_1", "thinking", "srvtoolu_2", "srvtoolu_2", "redacted_thinking", "text", + ]); + }); +}); + +test("a malformed anthropic_blocks falls back to the rebuilt pair", () => { + for (const anthropic_blocks of [{}, { use: {}, result: {} }, { use: { id: 1, name: "web_search" }, result: { type: "web_search_tool_result" } }]) { + const pair = webSearchPairFromItem({ type: "web_search_call", id: "ws_1", action: { type: "search", query: "q" }, anthropic_blocks }); + expect(pair).toMatchObject({ id: "ws_1", name: "web_search", resultType: "web_search_tool_result", input: { query: "q" } }); + } +}); From 038f89e88d1b51d224eab927b6ae01fe32031319 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 11:24:44 +0900 Subject: [PATCH 04/17] fix(claude): keep the caller's 1h cache TTL on the translated lane (carry #7176) Carried from #7176. Co-authored-by: Vadym O --- .../docs/reference/configuration/providers.md | 2 +- scripts/test-layout/layout.json | 2 +- src/claude/inbound.ts | 33 ++++ src/server/claude-messages.ts | 8 +- .../claude-translated-cache-ttl.test.ts | 152 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 6 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 tests/claude-integration/claude-translated-cache-ttl.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 289a9e5ae1d..52eb062d154 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -100,7 +100,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `maxUpstreamBodyBytes?` | `number` | `0` | Opt-in ceiling, in bytes, on a serialized native Responses **passthrough** body. `0` or omitted disables it — no limit is inferred for any destination. When set, a built body above the ceiling is refused locally before the send: streaming turns receive a terminal `response.failed` / `context_length_exceeded` so the client compacts instead of resending, and non-streaming turns receive a `413` naming the size, the number of embedded `input_image` items, and roughly how many megabytes of image data they represent. Checked at every build and rebuild point, including OAuth-refresh replay and alternate-account retry. Translated adapter paths are not covered. There is deliberately no default: the only measured ceiling here belongs to the WebSocket transport, which already falls back to HTTP for oversized turns, so a default would refuse requests that currently succeed. Set it when your gateway has a known request-size limit and you would rather see an actionable local error than an opaque upstream failure. | | `maxInboundBodyBytes?` | `number` | `0` | Opt-in ceiling, in bytes, on a decompressed **inbound** data-plane request body — the mirror of `maxUpstreamBodyBytes` above. `0` or omitted keeps the built-in 256 MiB default. Raise it when a large-context session can no longer compact: Codex replays the whole history to the compaction model, so on the 922k-token opt-in window the compaction request is itself the one that crosses the limit, and the session is stuck at the only operation that would have shrunk it. Clamped to 1 MiB–512 MiB. The ceiling is not negotiable: the reader materializes the body several times over (wire bytes, decoded bytes, the decoded string, and the parsed object graph), so peak memory is a multiple of whatever is admitted, and an unbounded value would be a memory exhaustion lever. Applies to `/v1/responses`, `/v1/responses/compact`, `/v1/chat/completions`, and `/v1/messages`. The listener's accept size is fixed when the proxy binds, so a change takes effect on restart. A body above the limit is refused locally with HTTP 413 and `code: "inbound_body_too_large"`, which is deliberately distinct from the `context_length_exceeded` 413 a provider size refusal produces. | | `modelCacheTtlMs?` | `number` | `300000` | Freshness window for the per-provider `/models` cache. | -| `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. | +| `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. When unset, a `/v1/messages` request on a translated route whose own `cache_control` breakpoints all ask for `ttl: "1h"` is treated as `"long"`; a request that mixes 1h and 5m breakpoints keeps the 5-minute default, and an explicit value always wins. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Optional proactive OAuth refresh and Codex-account warmup policy. | Across these strategies, each account's effective threshold is its `codexAccountAutoSwitchThresholds` entry when present, otherwise global `autoSwitchThreshold`. A zero override disables only proactive usage switching; startup binding, hard-lock, cooldown, entitlement checks, and failure recovery remain enforced. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 991296504a6..815da3a246b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -411,7 +411,7 @@ "claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-token-footer.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", - "claude-inbound.test.ts": "claude-integration", "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-native-reasoning-continuity.test.ts": "claude-integration", "claude-native-reasoning-provenance.test.ts": "claude-integration", + "claude-inbound.test.ts": "claude-integration", "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-native-reasoning-continuity.test.ts": "claude-integration", "claude-native-reasoning-provenance.test.ts": "claude-integration", "claude-translated-cache-ttl.test.ts": "claude-integration", "claude-output-schema-strict.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server", "claude-intercept-local-ca.test.ts": "claude-integration", diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 478be44037e..84335049477 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -387,6 +387,39 @@ function canonicalJson(value: unknown): string { /** Provenance of the generated prompt_cache_key (never serialized into the wire body). */ export type ClaudeCacheKeySource = "metadata" | "system" | null; +/** + * The translated lane places its own prompt-cache breakpoints, so the caller's are dropped; + * their TTL is the one thing worth keeping. When every caller breakpoint asks for `ttl: "1h"` + * (Claude Code on a subscription marks them so) this maps to `cacheRetention: "long"`. A mixed + * caller (1h prefix, 5m tail) stays on the default: "long" also makes the moving per-turn + * breakpoint 1h, which bills writes the caller asked to keep at the 5m rate at the 1h rate. + * Read out of body: the Responses body has no field for it. + */ +export function callerCacheRetention(raw: unknown): "long" | undefined { + if (!isRec(raw)) return undefined; + // Top-level automatic caching is a breakpoint too, and content blocks nest (tool_result, + // search_result, document sources), so every block container is walked. Tool inputs and + // schemas are caller data, not blocks: a `cache_control` key there is not a breakpoint. + const ttls: unknown[] = []; + const visit = (node: unknown, depth: number): void => { + if (depth > 16) return; + if (Array.isArray(node)) { for (const item of node) visit(item, depth + 1); return; } + if (!isRec(node)) return; + if (isRec(node.cache_control)) ttls.push(node.cache_control.ttl); + for (const [key, value] of Object.entries(node)) { + if (key !== "cache_control" && key !== "input" && key !== "input_schema" + && (Array.isArray(value) || isRec(value))) visit(value, depth + 1); + } + }; + if (isRec(raw.cache_control)) ttls.push(raw.cache_control.ttl); + visit(raw.system, 0); + visit(raw.tools, 0); + visit(raw.messages, 0); + return ttls.length > 0 && ttls.every(ttl => ttl === "1h") + ? "long" + : undefined; +} + export interface ClaudeInboundTranslation { body: Rec; cacheKeySource: ClaudeCacheKeySource; diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 1c7de5ec0d0..767782e9aa6 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -28,7 +28,7 @@ import { planMessagesAntigravityDirect, type MessagesDirectPlan, } from "./messages-antigravity-direct"; -import { AnthropicRequestError, DesktopModelMappingUnavailableError, extractOcxEffortDirective, extractOcxRouteDirective, nativeAnthropicProjection, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; +import { AnthropicRequestError, callerCacheRetention, DesktopModelMappingUnavailableError, extractOcxEffortDirective, extractOcxRouteDirective, nativeAnthropicProjection, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; import { nativeReasoningTag, type NativeReasoningOwner } from "../responses/reasoning-replay-cache"; import { isKnownDesktop3pModelId, resolveDesktop3pAlias } from "../claude/desktop-3p"; import { resolveAlias, claudeCodeNativeAlias, legacyAliasForNative } from "../claude/alias"; @@ -1410,7 +1410,11 @@ async function handleClaudeMessagesWithBudget( // BEFORE translation (the translated Anthropic stream has no response.completed // frame, so tapping it records a bogus 502 with no usage/cache detail). const finalizeNativeLog = createFinalRequestLog(logIds, logCtx).finish; - const upstream = await handleResponses(internalReq, buildClaudeReplayConfig(config), logCtx, { + const upstream = await handleResponses(internalReq, { + ...buildClaudeReplayConfig(config), + // Operator config wins; otherwise the caller's own 1h breakpoints keep 1h here. + cacheRetention: config.cacheRetention ?? callerCacheRetention(anthropicBody), + }, logCtx, { // Routing keeps Claude-only sidecar overrides; admission policy must follow the live owner. codexAuthPolicy: config, ...(logIds?.admission ? { admission: logIds.admission } : {}), diff --git a/tests/claude-integration/claude-translated-cache-ttl.test.ts b/tests/claude-integration/claude-translated-cache-ttl.test.ts new file mode 100644 index 00000000000..8976f14eb4d --- /dev/null +++ b/tests/claude-integration/claude-translated-cache-ttl.test.ts @@ -0,0 +1,152 @@ +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 { handleClaudeMessages } from "../../src/server/claude-messages"; +import { callerCacheRetention } from "../../src/claude/inbound"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// A combo route declines the native Messages lane, so the anthropic adapter places its own +// breakpoints. Their TTL must follow the caller's own `ttl: "1h"` unless the operator set one. +const MODEL = "claude-opus-5-5"; +let previousHome: string | undefined; +let home = ""; +let releaseSpend: (() => void) | undefined; + +beforeAll(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-cache-ttl-")); + 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); +}); + +async function wireTtls( + callerTtl: "1h" | undefined, + cacheRetention?: "short" | "long", + messageTtl: "1h" | "5m" | "unset" = callerTtl ?? "unset", + topLevelTtl?: "1h" | "5m" | "unset", + nestedToolResultTtl?: "1h" | "5m", +): Promise { + const wires: string[] = []; + const fetch = (async (_input: unknown, init?: RequestInit) => { + wires.push(String(init?.body)); + const frames = [ + { 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" }, + ]; + return new Response(frames.map(f => `event: ${f.type}\ndata: ${JSON.stringify(f)}\n\n`).join(""), + { headers: { "content-type": "text/event-stream" } }); + }) as typeof globalThis.fetch; + const config = { + port: 0, defaultProvider: "anth", + ...(cacheRetention ? { cacheRetention } : {}), + claudeCode: { nativePassthrough: false }, + providers: { + anth: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", 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: [{ provider: "anth", model: MODEL }, { provider: "gpt", model: "g" }] } }, + } as unknown as OcxConfig; + const cc = { type: "ephemeral", ...(callerTtl ? { ttl: callerTtl } : {}) }; + const messageCc = { type: "ephemeral", ...(messageTtl === "unset" ? {} : { ttl: messageTtl }) }; + 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, + ...(topLevelTtl ? { cache_control: { type: "ephemeral", ...(topLevelTtl === "unset" ? {} : { ttl: topLevelTtl }) } } : {}), + system: [{ type: "text", text: "You are a careful assistant.", cache_control: cc }], + tools: [{ name: "Read", description: "read", input_schema: { type: "object", properties: {} } }], + messages: [ + { role: "user", content: "First." }, + ...(nestedToolResultTtl + ? [ + { role: "assistant", content: [{ type: "tool_use", id: "toolu_1", name: "Read", input: {} }] }, + { role: "user", content: [ + { type: "tool_result", tool_use_id: "toolu_1", content: [ + { type: "text", text: "file body", cache_control: { type: "ephemeral", ttl: nestedToolResultTtl } }, + ] }, + { type: "text", text: "Continue.", cache_control: messageCc }, + ] }, + ] + : [ + { role: "assistant", content: "Done." }, + { role: "user", content: [{ type: "text", text: "Continue.", cache_control: messageCc }] }, + ]), + ], + }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + expect(wires).toHaveLength(1); + const ttls: unknown[] = []; + JSON.parse(wires[0]!, (key, value) => { + if (key === "cache_control") ttls.push(value.ttl); + return value; + }); + expect(ttls.length).toBeGreaterThan(0); + return ttls; +} + +describe("translated Claude lane cache TTL", () => { + test("caller ttl 1h keeps every adapter breakpoint at 1h", async () => { + expect(new Set(await wireTtls("1h"))).toEqual(new Set(["1h"])); + }); + + test("caller without ttl keeps the 5m default", async () => { + expect(new Set(await wireTtls(undefined))).toEqual(new Set([undefined])); + }); + + test("explicit operator cacheRetention wins over the caller hint", async () => { + expect(new Set(await wireTtls("1h", "short"))).toEqual(new Set([undefined])); + }); + + test("a caller mixing a 1h prefix with a 5m tail is not upgraded to 1h everywhere", async () => { + expect(new Set(await wireTtls("1h", undefined, "5m"))).toEqual(new Set([undefined])); + expect(new Set(await wireTtls("1h", undefined, "unset"))).toEqual(new Set([undefined])); + // Top-level automatic caching counts as a caller breakpoint. + expect(new Set(await wireTtls("1h", undefined, "1h", "unset"))).toEqual(new Set([undefined])); + expect(new Set(await wireTtls("1h", undefined, "1h", "5m"))).toEqual(new Set([undefined])); + expect(new Set(await wireTtls("1h", undefined, "1h", "1h"))).toEqual(new Set(["1h"])); + // Breakpoints nested in a tool_result's content count too. + expect(new Set(await wireTtls("1h", undefined, "1h", undefined, "5m"))).toEqual(new Set([undefined])); + expect(new Set(await wireTtls("1h", undefined, "1h", undefined, "1h"))).toEqual(new Set(["1h"])); + }); +}); + +describe("callerCacheRetention walks every block container", () => { + const h1 = { type: "ephemeral", ttl: "1h" }; + const m5 = { type: "ephemeral", ttl: "5m" }; + const body = (nested: unknown) => ({ + system: [{ type: "text", text: "s", cache_control: h1 }], + messages: [{ role: "user", content: [{ type: "tool_result", tool_use_id: "t", content: [nested] }] }], + }); + + test("a 5m breakpoint deep inside search_result or document content keeps the default", () => { + expect(callerCacheRetention(body({ type: "search_result", source: "s", title: "t", + content: [{ type: "text", text: "x", cache_control: m5 }] }))).toBeUndefined(); + expect(callerCacheRetention(body({ type: "document", + source: { type: "content", content: [{ type: "text", text: "x", cache_control: m5 }] } }))).toBeUndefined(); + expect(callerCacheRetention(body({ type: "search_result", source: "s", title: "t", + content: [{ type: "text", text: "x", cache_control: h1 }] }))).toBe("long"); + }); + + test("a cache_control key inside tool input or schema is caller data, not a breakpoint", () => { + expect(callerCacheRetention({ + tools: [{ name: "T", input_schema: { type: "object", properties: { cache_control: h1 } } }], + messages: [{ role: "assistant", content: [{ type: "tool_use", id: "t", name: "T", input: { cache_control: h1 } }] }], + })).toBeUndefined(); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d3a054d5dd8..0ca73040907 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -334,7 +334,7 @@ "claude-first-party-union.test.ts": "claude-integration", "claude-desktop-system-proxy.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", "claude-config-home-isolation.test.ts": "claude-integration", "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-token-footer.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", "claude-inbound.test.ts": "claude-integration", - "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-native-reasoning-continuity.test.ts": "claude-integration", "claude-native-reasoning-provenance.test.ts": "claude-integration", + "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-native-reasoning-continuity.test.ts": "claude-integration", "claude-native-reasoning-provenance.test.ts": "claude-integration", "claude-translated-cache-ttl.test.ts": "claude-integration", "claude-output-schema-strict.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server", "claude-intercept-local-ca.test.ts": "claude-integration", "claude-intercept-local-ca-files.test.ts": "claude-integration", From 9365c266b8cc47fdf04f5c750f95bdcc240ee640 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 11:24:58 +0900 Subject: [PATCH 05/17] fix(claude): surface a declared client refusal as its 4xx, not a retryable 502 (carry #7177) Carried from #7177. Co-authored-by: Vadym O --- .../content/docs/reference/management-api.md | 9 +- scripts/test-layout/layout.json | 1 + src/lib/errors.ts | 22 +++- src/server/claude-messages.ts | 13 +- src/server/relay.ts | 2 +- src/server/request-log-terminal-status.ts | 2 +- structure/transports/responses-wire-shapes.md | 2 +- .../claude-client-refusal-status.test.ts | 116 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 9 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 tests/claude-integration/claude-client-refusal-status.test.ts diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 3651cadf52d..465ed84f734 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -290,10 +290,11 @@ upstream model evidence remains absent rather than being inferred from the reque Failed request rows can include bounded `upstreamErrorType`, `upstreamErrorCode`, and `upstreamRequestId` diagnostics. On the Codex upstream WebSocket path, a bare error followed by -EOF retains recognized rate-limit or overload codes in the synthesized failed terminal. -Request logs record these as 429 for `rate_limit_exceeded` / `rate_limit_error`, or 503 for -`server_is_overloaded` / `overloaded_error`. A recognized type without a code uses the same -status. On the streaming path, an explicit unknown code records the 5xx status the Codex +EOF retains recognized rate-limit, overload, or client-refusal codes in the synthesized failed terminal. +Request logs record these as 429 for `rate_limit_exceeded` / `rate_limit_error`, 503 for +`server_is_overloaded` / `overloaded_error`, and 400/401/403/404 for `invalid_request_error` / +`authentication_error` / `permission_error` / `not_found_error`. A recognized type without a code +uses the same status. On the streaming path, an explicit unknown code records the 5xx status the Codex WebSocket wrapper states as a top-level integer `status` or `status_code` (one of the two, not both); otherwise, and for buffered JSON callers, it remains a generic 502 failure. The client terminal keeps `upstream_server_error` for unknown codes either way. Bare-error status is provisional: diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 815da3a246b..9ca1359faa1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -369,6 +369,7 @@ "cl01-openai-chat-review-regressions.test.ts": "routing", "cl01-review-regressions.test.ts": "routing", "claude-529-mapping.test.ts": "claude-integration", + "claude-client-refusal-status.test.ts": "claude-integration", "claude-agent-startup-sync.test.ts": "claude-integration", "claude-agents-inject-client.test.ts": "claude-integration", "claude-agents-inject.test.ts": "claude-integration", diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 9de95942dfe..14793fa92ec 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -650,10 +650,22 @@ export function adapterFailureFromMessage(message: string): { httpStatus: number return { httpStatus, error }; } +/** A declared client refusal: retrying the identical request cannot change the verdict. */ +const CLIENT_ERROR_STATUS: Record = { + invalid_request_error: 400, + authentication_error: 401, + permission_error: 403, + not_found_error: 404, +}; + /** Narrow structured classes carried through a synthesized bare-error terminal. */ export function recognizedUpstreamError(error: { type?: unknown; code?: unknown }): - { type: string; code: string; httpStatus: 429 | 503 } | undefined { + { type: string; code: string; httpStatus: 400 | 401 | 403 | 404 | 429 | 503 } | undefined { const code = typeof error.code === "string" ? error.code : undefined; + const clientClass = code ?? (typeof error.type === "string" ? error.type : undefined); + if (clientClass !== undefined && Object.hasOwn(CLIENT_ERROR_STATUS, clientClass)) { + return { type: clientClass, code: clientClass, httpStatus: CLIENT_ERROR_STATUS[clientClass] }; + } // An explicit unknown code must not gain a recognized class from its type or copy. if (code !== undefined && !["rate_limit_exceeded", "rate_limit_error", "server_is_overloaded", "overloaded_error"].includes(code)) return undefined; if (code === "rate_limit_exceeded" || code === "rate_limit_error" @@ -680,8 +692,13 @@ export function httpStatusFromTerminalError(error: { if (isCyberPolicyCode(error.code) || (error.message ? isCyberPolicyMessage(error.message) : false)) { return 400; } + const message = error.message ?? ""; const recognized = recognizedUpstreamError(error); - if (recognized) return recognized.httpStatus; + // Client-closed messages often arrive as invalid_request_error after classifyError, so a + // recognized 400 yields to the client-closed check below; every other class keeps its order. + if (recognized && !(recognized.httpStatus === 400 && message && isClientClosedMessage(message))) { + return recognized.httpStatus; + } if (error.type === "rate_limit_error" || error.code === "rate_limit_exceeded") return 429; if (error.type === "authentication_error" || error.code === "invalid_api_key") return 401; if ( @@ -693,7 +710,6 @@ export function httpStatusFromTerminalError(error: { if (error.type === "server_error" && error.code === "server_is_overloaded") return 503; // Client-closed messages often arrive as invalid_request_error after classifyError; check message // before treating every invalid_request_error as HTTP 400. - const message = error.message ?? ""; if (message && isClientClosedMessage(message)) return 499; if (error.type === "invalid_request_error") return 400; if (error.type === "proxy_error") return 500; diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 767782e9aa6..d7816980598 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -48,7 +48,7 @@ import { REPLAY_REFUSED_STATUS, UPSTREAM_RESET_REPLAY_REFUSED_CODE, } from "../lib/upstream-retry"; -import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors"; +import { isClientClosedMessage, recognizedUpstreamError, UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors"; import { resolveClientRetryAfter } from "../lib/retry-after"; import { anthropicRateLimitHeaders } from "./anthropic-rate-limit-headers"; import { upstreamMessagesRequestIdHeaders } from "./messages-response-headers"; @@ -1568,8 +1568,17 @@ async function handleClaudeMessagesWithBudget( "translation_buffer_limit", ); } + // A declared client refusal keeps its 4xx: as a 502 Claude Code retries a verdict that cannot + // change. Server classes and rate limits keep the 502 this path has always answered. A 400 + // worded as a client close answers the 499 httpStatusFromTerminalError logs for it. + const refusalStatus = recognizedUpstreamError(translatedError ?? {})?.httpStatus; return new Response(JSON.stringify(message), { - status: isError ? (translatedError?.code === "context_length_exceeded" ? 400 : 502) : 200, + status: isError + ? translatedError?.code === "context_length_exceeded" ? 400 + : refusalStatus !== undefined && refusalStatus < 429 + ? refusalStatus === 400 && isClientClosedMessage(String(translatedError?.message ?? "")) ? 499 : refusalStatus + : 502 + : 200, headers: { "Content-Type": "application/json" }, }); } diff --git a/src/server/relay.ts b/src/server/relay.ts index 8659c25d1f1..133d11d3ae0 100644 --- a/src/server/relay.ts +++ b/src/server/relay.ts @@ -170,7 +170,7 @@ export function failedTailFrame(encoder: TextEncoder, err: unknown, maskCredenti * its fatal set (codex-rs/codex-api/src/sse/responses.rs:423-450), so stamping * `upstream_server_error` on a refusal delivered it as a retryable disconnect * and drove the reconnect loop in #5176. Without a refusal code the terminal - * carries only recognized rate-limit/overload classes; unknown codes stay generic. + * carries only recognized client-refusal/rate-limit/overload classes; unknown codes stay generic. */ export function upstreamErrorTailFrame( encoder: TextEncoder, diff --git a/src/server/request-log-terminal-status.ts b/src/server/request-log-terminal-status.ts index 38486d4bc01..80174c82084 100644 --- a/src/server/request-log-terminal-status.ts +++ b/src/server/request-log-terminal-status.ts @@ -67,7 +67,7 @@ const KNOWN_UPSTREAM_ERROR_TYPES: ReadonlySet = new Set([ "RESOURCE_EXHAUSTED", "INTERNAL", "UNAVAILABLE", "DEADLINE_EXCEEDED", ]); -/** Bare top-level error codes worth keeping: the classes recognizedUpstreamError maps, plus server_error. */ +/** Bare top-level error codes worth keeping: the rate-limit and overload classes recognizedUpstreamError maps, plus server_error. */ const KNOWN_BARE_ERROR_CODES: ReadonlySet = new Set([ "rate_limit_exceeded", "rate_limit_error", "server_is_overloaded", "overloaded_error", "server_error", ]); diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 8709593b69a..e88250a6285 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -397,7 +397,7 @@ Native passthrough SSE has TWO shapes, selected per request in The shared `createSseInspector` factory in `relay.ts` retains the complete inspection side effects, including the #44 late-terminal semantics. -Both client readers retain a bounded, redacted message and the first structured refusal code from a bare upstream `error`. EOF synthesis also retains narrowly recognized rate-limit codes (`rate_limit_exceeded`, `rate_limit_error`) and overload codes (`server_is_overloaded`, `overloaded_error`); recognized types apply only without an explicit code. +Both client readers retain a bounded, redacted message and the first structured refusal code from a bare upstream `error`. EOF synthesis also retains narrowly recognized rate-limit codes (`rate_limit_exceeded`, `rate_limit_error`) overload codes (`server_is_overloaded`, `overloaded_error`), and the client-refusal classes `invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error` (400/401/403/404, the code repeating the class); recognized types apply only without an explicit code. At EOF without a real terminal they synthesize `response.failed` rather than `adapter_eof`; a code without a message still produces a terminal. Codex retries codes outside its fatal set, so code and message follow the same candidate precedence; recognized refusal copy is used only when the event has no code. A read failure after refusal reports that refusal (#5176). diff --git a/tests/claude-integration/claude-client-refusal-status.test.ts b/tests/claude-integration/claude-client-refusal-status.test.ts new file mode 100644 index 00000000000..67da06fc2df --- /dev/null +++ b/tests/claude-integration/claude-client-refusal-status.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { httpStatusFromTerminalError } from "../../src/lib/errors"; +import { startServer } from "../../src/server"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// A 200 SSE that the upstream ends with a bare `error` frame declaring a client-class type +// (no refusal code). It used to be stamped `upstream_error` and answered as a retryable 502, +// so Claude Code retried a request the upstream had already refused. + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-claude-refusal-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-claude-refusal-")); + process.env.OPENCODEX_HOME = testDir; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) removeTreeWithRetry(testDir); +}); + +async function send( + errorType: string, + stream: boolean, + message = `upstream says ${errorType}`, +): Promise<{ status: number; calls: number; text: string }> { + let calls = 0; + const frames = 'data: {"type":"response.in_progress"}\n\n' + + `data: ${JSON.stringify({ type: "error", error: { type: errorType, message } })}\n\n`; + const upstream = Bun.serve({ + port: 0, + fetch() { + calls++; + return new Response(frames, { headers: { "content-type": "text/event-stream" } }); + }, + }); + saveConfig({ + port: 0, + defaultProvider: "native", + providers: { + native: { + adapter: "openai-responses", + baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`, + authMode: "forward", + allowPrivateNetwork: true, + }, + }, + } as OcxConfig); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/messages", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "native/gpt-test", max_tokens: 64, stream, messages: [{ role: "user", content: "hi" }] }), + }); + return { status: response.status, calls, text: await response.text() }; + } finally { + await server.stop(true); + await upstream.stop(true); + } +} + +test.each([ + ["invalid_request_error", 400], + ["authentication_error", 401], + ["permission_error", 403], + ["not_found_error", 404], +])("non-streaming: a declared %s answers %i, not a retryable 502", async (errorType, status) => { + const result = await send(errorType, false); + expect(result.status).toBe(status); + expect(JSON.parse(result.text).error).toMatchObject({ type: errorType, message: `upstream says ${errorType}` }); + expect(result.calls).toBe(1); +}, 15_000); + +test.each(["invalid_request_error", "permission_error"])( + "streaming: a declared %s reaches the client as that type, not a retryable overload", + async (errorType) => { + const result = await send(errorType, true); + expect(result.status).toBe(200); + const data = JSON.parse(result.text.split("event: error\ndata: ")[1]!.split("\n")[0]!); + expect(data.error.type).toBe(errorType); + }, + 15_000, +); + +test("server-class overload keeps the 502 it had", async () => { + const result = await send("overloaded_error", false); + expect(result.status).toBe(502); +}, 15_000); + +test("non-streaming: an invalid_request_error worded as a client close answers 499, as it is logged", async () => { + const result = await send("invalid_request_error", false, "client closed request during web-search"); + expect(result.status).toBe(499); + expect((await send("permission_error", false, "client closed request")).status).toBe(403); +}, 15_000); + +test("a codeless invalid_request_error worded as a client close stays 499", () => { + expect(httpStatusFromTerminalError({ type: "invalid_request_error", message: "client closed request" })).toBe(499); + expect(httpStatusFromTerminalError({ type: "invalid_request_error", message: "bad shape" })).toBe(400); + // Only the recognized 400 yields to client-closed wording; other classes keep their dev order. + expect(httpStatusFromTerminalError({ type: "permission_error", message: "client closed request" })).toBe(403); + expect(httpStatusFromTerminalError({ type: "rate_limit_error", message: "client closed request" })).toBe(429); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0ca73040907..ef86f228a45 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -304,7 +304,7 @@ "citation-markers.test.ts": "responses", "visualization-directives.test.ts": "responses", "cl01-claude-outbound-review-regressions.test.ts": "routing", "cl01-openai-chat-review-regressions.test.ts": "routing", "cl01-review-regressions.test.ts": "routing", - "claude-529-mapping.test.ts": "claude-integration", "claude-agent-startup-sync.test.ts": "claude-integration", + "claude-529-mapping.test.ts": "claude-integration", "claude-client-refusal-status.test.ts": "claude-integration", "claude-agent-startup-sync.test.ts": "claude-integration", "claude-agents-inject-client.test.ts": "claude-integration", "claude-agents-inject.test.ts": "claude-integration", "claude-alias.test.ts": "claude-integration", "claude-auth-detect.test.ts": "claude-integration", "claude-auth-mode.test.ts": "claude-integration", "claude-authmode-migration.test.ts": "claude-integration", From 6c352c939101b93c5c5da78c1a8e92d9859c130c Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 11:25:04 +0900 Subject: [PATCH 06/17] fix(claude): keep the caller's max_tokens cap on non-canonical Responses gateways (carry #7178) Carried from #7178. Co-authored-by: Vadym O --- src/server/claude-messages.ts | 8 +++-- .../claude-messages-endpoint.test.ts | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index d7816980598..a0b1cda1c11 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -1126,8 +1126,9 @@ async function handleClaudeMessagesWithBudget( let clientEncoder: ClientEncoderOption | undefined; // Native ChatGPT passthrough (openai-responses forward) accepts only Codex-shaped - // bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens", - // verified live 2026-07-11). Strip them for that route; routed providers keep them. + // bodies: it 400s on sampling params (verified live 2026-07-11). Every openai-responses + // route drops temperature/top_p/stop/user below; max_output_tokens is left to + // stripUnsupportedForwardParams (canonical-forward.ts), which drops it only for that backend. let settledRoute: RouteResult | undefined; /** * Which replayed thinking fields the send that actually happened will serialize. @@ -1194,7 +1195,8 @@ async function handleClaudeMessagesWithBudget( clientEncoder = { protocol: "messages", stream, model: requestedModel, inputTokenFloor: claudeRequestTokenFloor() }; } if (route.provider.adapter === "openai-responses") { - delete internalBody.max_output_tokens; + // max_output_tokens is the caller's spend cap: the adapter strips it only for the canonical + // ChatGPT backend that rejects it (#5753), so other Responses gateways keep the cap. delete internalBody.temperature; delete internalBody.top_p; delete internalBody.stop; diff --git a/tests/claude-integration/claude-messages-endpoint.test.ts b/tests/claude-integration/claude-messages-endpoint.test.ts index 1663a564e8a..0f83a49d0ee 100644 --- a/tests/claude-integration/claude-messages-endpoint.test.ts +++ b/tests/claude-integration/claude-messages-endpoint.test.ts @@ -724,6 +724,41 @@ test("native openai-responses route carries prompt_cache_key + synthesized sessi } }); +test("non-canonical openai-responses Claude route keeps the caller's max_tokens cap", async () => { + let body: Record | undefined; + const upstream = Bun.serve({ + port: 0, + async fetch(req) { + body = await req.json() as Record; + return new Response([ + `event: response.output_text.delta\ndata: ${JSON.stringify({ delta: "Hello" })}\n\n`, + `event: response.completed\ndata: ${JSON.stringify({ response: { status: "completed", usage: { input_tokens: 10, output_tokens: 2 } } })}\n\n`, + ].join(""), { headers: { "Content-Type": "text/event-stream" } }); + }, + }); + saveConfig({ + port: 0, + defaultProvider: "gateway", + providers: { + gateway: { adapter: "openai-responses", baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`, apiKey: "k", allowPrivateNetwork: true }, + }, + } as OcxConfig); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/messages", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gateway/gpt-test", max_tokens: 128, messages: [{ role: "user", content: "hi" }] }), + }); + expect(response.status).toBe(200); + await response.text(); + expect(body?.max_output_tokens).toBe(128); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + test("native openai-responses Claude route logs cyber terminals as 400 cyber_policy", async () => { clearRequestLogsForTests(); const upstream = Bun.serve({ From d27c017a2b677e1d0f40b281fa33b9bc31a627c7 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 11:25:14 +0900 Subject: [PATCH 07/17] fix(web-search): meter every sidecar iteration in the usage ledger, not only the last (carry #7179) Carried from #7179. Co-authored-by: Vadym O --- scripts/test-layout/layout.json | 1 + src/web-search/loop.ts | 26 ++++- structure/runtime.md | 2 +- tests/fixtures/test-layout-expected.json | 3 +- .../web-search-loop-iteration-usage.test.ts | 107 ++++++++++++++++++ 5 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 tests/web-search/web-search-loop-iteration-usage.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9ca1359faa1..87bff34db1d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -76,6 +76,7 @@ "owner-registry-acl.test.ts": "config", "minimax-combo-image-input.test.ts": "providers", "web-search-forced-declaration.test.ts": "web-search", + "web-search-loop-iteration-usage.test.ts": "web-search", "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 79d6c291f7c..e1fe62a8a24 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -7,6 +7,7 @@ import type { AttemptRecoveryKind } from "../usage/log"; import { isTruncatedStopReason } from "../responses/truncated-stop-reason"; import { bridgeToResponsesSSE } from "../bridge"; import { guardEmptyToolCatalogStream, type ClientToolCatalogRestriction } from "../lib/empty-tool-catalog"; +import { mergeUsage } from "../server/responses/empty-completion-guard"; import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor"; import { runAnthropicWebSearch } from "./anthropic-executor"; import { runXaiWebSearch, type XaiSearchOptions } from "./xai-executor"; @@ -144,6 +145,18 @@ async function* replay(events: AdapterEvent[]): AsyncGenerator { for (const e of events) yield e; } +function terminalUsage(events: AdapterEvent[]): OcxUsage | undefined { + for (const e of events) if (e.type === "done" || e.type === "incomplete") return e.usage; + return undefined; +} + +/** Hidden iterations add counters only: their checkpoint and raw wire usage are not the final terminal's. */ +function countersOnly(usage: OcxUsage | undefined): OcxUsage | undefined { + if (!usage) return undefined; + const { contextTotalTokens: _context, rawUsage: _raw, ...counters } = usage; + return counters; +} + /** * Collect the thinking block that preceded a web_search call in this iteration's events, so the * replayed assistant turn can carry it. Anthropic extended thinking REQUIRES the assistant @@ -413,6 +426,12 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise event.type))], })); + hiddenUsage = mergeUsage(hiddenUsage, terminalEvent.usage); if (!split.hasMalformedToolCall && !split.hasRealToolCall && emptyAnswerRetries === 0) { emptyAnswerRetries++; console.warn("[web-search-loop] empty forced answer — retrying once without tools"); @@ -972,6 +992,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise deps.onUsage?.(mergeUsage(countersOnly(hiddenUsage), usage)) } : {}), ...(deps.onCompletedResponse ? { onCompletedResponse: deps.onCompletedResponse } : {}), }, ); diff --git a/structure/runtime.md b/structure/runtime.md index dc720c31f82..457286c6056 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -398,7 +398,7 @@ Automatic Codex pool selection and account status share the [plan exclusion cont ### Empty forced search answers -Both search loops keep `web_search` declared after the search budget is exhausted. Further calls receive a paired limit-reached result without another physical search; at most `maxSearches + 3` model iterations run before a terminal error. Ordinary caller tools and cancellation still end the loop. Empty-answer recovery alone removes all tools. `src/web-search/loop.ts` makes at most one extra answer attempt after a clean forced-answer terminal with no visible output or tool call, only while an iteration remains. The recovery has no tools and reuses gathered search results. Malformed calls fail before refusal/truncation passthrough, and well-formed recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage. `src/web-search/run-turn-loop.ts` shares this recovery and checks the ceiling before dispatch so it cannot start an unused request; below the search cap, `emptyCompletionRetry` permits one identical empty-answer retry per request with current tools and results. Errors and invalid terminals never authorize another search. Batched results from one call share one 8,000-character budget in `src/web-search/format-result.ts`, planned per query before serialization (#6621): every query keeps its label and status, shortened answers and unlisted sources carry markers with their original counts outside the untrusted boundary, structured output stays one valid JSON document, and a batch whose fixed parts alone overflow is condensed to status lines with a per-status count of any query that still did not fit. +Both search loops keep `web_search` declared after the search budget is exhausted. Further calls receive a paired limit-reached result without another physical search; at most `maxSearches + 3` model iterations run before a terminal error. Ordinary caller tools and cancellation still end the loop. Empty-answer recovery alone removes all tools. `src/web-search/loop.ts` makes at most one extra answer attempt after a clean forced-answer terminal with no visible output or tool call, only while an iteration remains. The recovery has no tools and reuses gathered search results. Malformed calls fail before refusal/truncation passthrough, and well-formed recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage. Every iteration re-sends the full context, so `src/web-search/loop.ts` adds the usage of each iteration whose terminal the client never sees (search-calling passes and the discarded empty answer) to what `onUsage` reports for the request log, including on an in-stream or translator-budget error; token and cache counts add, while `contextTotalTokens` and `rawUsage` come only from the final terminal, never an earlier iteration's. The client-facing terminal keeps the last iteration's usage, because Codex and Claude Code size the context window from it (`tests/web-search/web-search-loop-iteration-usage.test.ts`). `src/web-search/run-turn-loop.ts` shares this recovery and checks the ceiling before dispatch so it cannot start an unused request; below the search cap, `emptyCompletionRetry` permits one identical empty-answer retry per request with current tools and results. Errors and invalid terminals never authorize another search. Batched results from one call share one 8,000-character budget in `src/web-search/format-result.ts`, planned per query before serialization (#6621): every query keeps its label and status, shortened answers and unlisted sources carry markers with their original counts outside the untrusted boundary, structured output stays one valid JSON document, and a batch whose fixed parts alone overflow is condensed to status lines with a per-status count of any query that still did not fit. `src/web-search/loop.ts` uses `src/web-search/header-deadline.ts` to suspend the cumulative response-header deadline while each physical send awaits local pacing admission. Ordinary fetches, adapter executors, reset retries and pool-key rotations retain the remaining budget after admission; same-target 429 backoff still clears and restarts that budget. The default remains 200,000 ms. Client cancellation remains linked during queued admission and after final headers, and the existing pacing slot wrapper retains leases through body completion or cancellation. Streaming retry heartbeats and body inactivity deadlines keep their existing ownership. `tests/web-search/web-search-pacing-deadline.test.ts` covers queued success, real header stalls, queued cancellation, retry admission and cumulative rotation time. OpenAI sidecar 429 replays run only when their backoff fits the remaining sidecar deadline; otherwise the original 429 remains the routing-health outcome rather than becoming a timeout. Reset recovery and 429 replays share one three-send budget per search, so the two layers cannot multiply physical sends. Direct vision/search helpers and the alpha/search relay retain the resolver's live [stored-account credit policy](codex-account-controls.md#stored-account-authentication-policy) through physical sends and retries; a local hold prevents the send without becoming an upstream connection failure. ## Scoped provider quota for Combo selection diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ef86f228a45..4115cb43ec2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -126,7 +126,8 @@ "cli-combo-partial-update.test.ts": "cli", "desktop-app-server-shim.test.ts": "clients", "desktop-app-server-shim-launcher.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", "owner-registry-acl.test.ts": "config", "minimax-combo-image-input.test.ts": "providers", - "web-search-forced-declaration.test.ts": "web-search", "claude-devin-output-order.test.ts": "claude-integration", + "web-search-forced-declaration.test.ts": "web-search", "web-search-loop-iteration-usage.test.ts": "web-search", + "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", "codex-credits-settings.test.ts": "codex-integration", "codex-credits-probes.test.ts": "codex-integration", "codex-credits-after-limit.test.ts": "codex-integration", diff --git a/tests/web-search/web-search-loop-iteration-usage.test.ts b/tests/web-search/web-search-loop-iteration-usage.test.ts new file mode 100644 index 00000000000..115257677c8 --- /dev/null +++ b/tests/web-search/web-search-loop-iteration-usage.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import type { ProviderAdapter } from "../../src/adapters/base"; +import type { AdapterEvent, OcxParsedRequest, OcxUsage } from "../../src/types"; +import { runWithWebSearch } from "../../src/web-search/loop"; +import { buildWebSearchTool } from "../../src/web-search/synthetic-tool"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +// Every routed-model iteration re-sends the full context, so the usage ledger (onUsage) must get the +// sum of all of them. The client-facing terminal keeps the last iteration's usage: Codex and Claude +// Code size the context window from it, and a sum would trigger early auto-compaction. +const usage = (input: number, output: number, extra: Partial = {}): OcxUsage => + ({ inputTokens: input, outputTokens: output, totalTokens: input + output, ...extra }); +const search = (id: string, u: OcxUsage): AdapterEvent[] => [ + { type: "tool_call_start", id, name: "web_search" }, + { type: "tool_call_delta", arguments: JSON.stringify({ query: id }) }, + { type: "tool_call_end" }, { type: "done", usage: u }, +]; +const answer = (u: OcxUsage): AdapterEvent[] => [{ type: "text_delta", text: "answer" }, { type: "done", usage: u }]; + +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; }); + +async function drive(passes: AdapterEvent[][], refuseFrom = Infinity) { + spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ results: [] })); + const reported: (OcxUsage | undefined)[] = []; + let events: AdapterEvent[] = []; + let iteration = 0; + const adapter: ProviderAdapter = { + name: "fixture", + buildRequest() { + events = passes[iteration++]!; + return { url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }; + }, + fetchResponse: async () => iteration > refuseFrom ? new Response("refused", { status: 500 }) : new Response("wire"), + async *parseStream() { yield* events; }, + }; + const parsed: OcxParsedRequest = { + modelId: "fixture", stream: true, options: {}, context: { messages: [], tools: [buildWebSearchTool()] }, + }; + const response = await runWithWebSearch({ + parsed, adapter, backend: "exa", exaApiKey: "fixture", hostedTool: { type: "web_search" }, + settings: { model: "fixture", reasoning: "low", timeoutMs: 1000 }, maxSearches: 1, + incomingMeta: { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, + onUsage: u => reported.push(u), + }); + const text = await response.text(); + const terminal = text.split("\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6))) + .find(frame => frame.type === "response.completed" || frame.type === "response.failed"); + return { reported, text, clientUsage: terminal?.response?.usage }; +} + +describe("web-search loop iteration usage", () => { + test("search passes are added to the final answer's usage", async () => { + const { reported, text, clientUsage } = await drive([ + search("first", usage(1000, 50, { cachedInputTokens: 0, cacheCreationInputTokens: 900, contextTotalTokens: 1050 })), + answer(usage(1400, 80, { cachedInputTokens: 900, cacheReadInputTokens: 900, contextTotalTokens: 1480 })), + ]); + expect(text).toContain("response.completed"); + expect(reported).toHaveLength(1); + expect(reported[0]).toMatchObject({ + inputTokens: 2400, outputTokens: 130, totalTokens: 2530, + cachedInputTokens: 900, cacheReadInputTokens: 900, cacheCreationInputTokens: 900, + // An absolute context checkpoint is the last iteration's, never a sum. + contextTotalTokens: 1480, + }); + // Client wire derives input from the last checkpoint: 1480 - 80. + expect(clientUsage).toMatchObject({ input_tokens: 1400, output_tokens: 80, total_tokens: 1480 }); + }); + + test("the empty forced answer that was retried is counted too", async () => { + const { reported, text, clientUsage } = await drive([ + search("first", usage(1000, 50)), + [{ type: "done", usage: usage(1400, 5) }], + answer(usage(1500, 80)), + ]); + expect(text).toContain("response.completed"); + expect(reported).toEqual([expect.objectContaining({ inputTokens: 3900, outputTokens: 135, totalTokens: 4035 })]); + expect(clientUsage).toMatchObject({ input_tokens: 1500, output_tokens: 80, total_tokens: 1580 }); + }); + + test("an in-stream failure after a search still reports the iterations already sent", async () => { + const { reported, text, clientUsage } = await drive([search("first", usage(1000, 50)), answer(usage(1, 1))], 1); + expect(text).toContain("response.failed"); + expect(clientUsage).toBeFalsy(); + expect(reported).toEqual([expect.objectContaining({ inputTokens: 1000, outputTokens: 50 })]); + }); + + test("a translator-budget failure after a search still reports the iterations already sent", async () => { + const { reported, text } = await drive([ + search("first", usage(1000, 50)), + [{ type: "error", status: 502, code: "translation_buffer_limit", message: "over budget" }], + ]); + expect(text).toContain("translation_buffer_limit"); + expect(reported).toEqual([expect.objectContaining({ inputTokens: 1000, outputTokens: 50 })]); + }); + + test("an earlier iteration's checkpoint and raw usage never stand in for the final terminal's", async () => { + const { reported } = await drive([ + search("first", usage(1000, 50, { contextTotalTokens: 1050, rawUsage: { iteration: 1 } })), + answer(usage(1400, 80)), + ]); + expect(reported).toHaveLength(1); + expect(reported[0]).toMatchObject({ inputTokens: 2400, outputTokens: 130 }); + expect(reported[0]?.contextTotalTokens).toBeUndefined(); + expect(reported[0]?.rawUsage).toBeUndefined(); + }); +}); From 87206742203c6ce5859566ed5b4e895cd2f3f4a3 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 11:25:33 +0900 Subject: [PATCH 08/17] fix(loops): meter the usage an adapter error terminal carries (carry #7199) Carried from #7199. Co-authored-by: Vadym O --- scripts/test-layout/layout.json | 5 +-- src/images/loop.ts | 3 ++ src/web-search/loop.ts | 2 + src/web-search/progress-stream.ts | 5 ++- structure/runtime.md | 2 +- tests/fixtures/test-layout-expected.json | 7 ++- tests/images/loop.test.ts | 35 +++++++++++++++ .../web-search-loop-error-usage.test.ts | 44 +++++++++++++++++++ 8 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 tests/web-search/web-search-loop-error-usage.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 87bff34db1d..de81b234cb2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -77,6 +77,7 @@ "minimax-combo-image-input.test.ts": "providers", "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", "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", @@ -370,7 +371,6 @@ "cl01-openai-chat-review-regressions.test.ts": "routing", "cl01-review-regressions.test.ts": "routing", "claude-529-mapping.test.ts": "claude-integration", - "claude-client-refusal-status.test.ts": "claude-integration", "claude-agent-startup-sync.test.ts": "claude-integration", "claude-agents-inject-client.test.ts": "claude-integration", "claude-agents-inject.test.ts": "claude-integration", @@ -413,7 +413,7 @@ "claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-token-footer.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", - "claude-inbound.test.ts": "claude-integration", "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-native-reasoning-continuity.test.ts": "claude-integration", "claude-native-reasoning-provenance.test.ts": "claude-integration", "claude-translated-cache-ttl.test.ts": "claude-integration", + "claude-inbound.test.ts": "claude-integration", "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-native-reasoning-continuity.test.ts": "claude-integration", "claude-native-reasoning-provenance.test.ts": "claude-integration", "claude-output-schema-strict.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server", "claude-intercept-local-ca.test.ts": "claude-integration", @@ -442,7 +442,6 @@ "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/images/loop.ts b/src/images/loop.ts index cc33a556bae..093272305b3 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -575,6 +575,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise e.type === "error"); if (errorEvent && errorEvent.type === "error") { + hiddenUsage = addUsage(hiddenUsage, errorEvent.usage); if (errorEvent.code === "translation_buffer_limit") { throw new TranslatorBudgetExceededError("retained_collectors", TRANSLATOR_MAX_TURN_BYTES); } @@ -778,6 +779,8 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise