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..f911dac5798 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.slice(0, 4096 - serverSearch.json.length); } 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..2ceec94a827 --- /dev/null +++ b/tests/claude-integration/claude-translated-server-tools.test.ts @@ -0,0 +1,216 @@ +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 single input fragment past 4 KiB is not retained beyond the cap", async () => { + const query = "q".repeat(5000); + const { clientSse } = await claudeTurn("https://api.anthropic.com", [ + ...SEARCH_TURN.slice(0, 2), + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify({ query }) } }, + ...SEARCH_TURN.slice(4), + ]); + expect(blockStarts(clientSse).map(b => b.type)).toEqual(["server_tool_use", "web_search_tool_result", "text"]); + expect(clientSse).not.toContain(query); + }); + + test("a replayed search pair without encrypted_content never reaches Anthropic", async () => { + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", [ + { role: "user", content: "What did Bun release?" }, + { 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",