Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -962,7 +962,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: <tool_name>` 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 |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `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` |
Expand Down
62 changes: 33 additions & 29 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,13 +522,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<string, unknown> {
try {
const value = (JSON.parse(json) as Record<string, unknown> | null)?.[key];
return typeof value === "string" ? value : "";
const input = JSON.parse(json) as unknown;
return isAnthropicRecord(input) ? input : {};
} catch {
return "";
return {};
}
}

Expand Down Expand Up @@ -735,7 +735,16 @@ function orphanToolResultContent(msg: OcxToolResultMessage): string | unknown[]
function messagesToAnthropicFormat(
parsed: OcxParsedRequest,
toolNames: { toWire: (name: string) => string },
serverReplay?: ReadonlyMap<string, readonly Record<string, unknown>[]>,
): { system: string | undefined; messages: unknown[] } {
// The client's server-tool exchanges go back ahead of the block they preceded. Their anchors are
// unique in the caller's history (replayedAnthropicServerBlocks drops repeated ones); each is used once.
const pendingReplay = new Map(serverReplay);
const replayed = (anchor: string) => {
const blocks = pendingReplay.get(anchor) ?? [];
pendingReplay.delete(anchor);
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.
Expand Down Expand Up @@ -789,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;
Expand All @@ -806,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).
Expand Down Expand Up @@ -998,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
Expand Down Expand Up @@ -1336,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<string, unknown>,
},
};
serverSearch = undefined;
Expand Down
8 changes: 6 additions & 2 deletions src/bridge/internal.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
AdapterEvent,
AnthropicServerBlocks,
OcxMessagePhase,
OcxProviderContinuationState,
OcxProviderOpaqueToolCallMetadata,
Expand Down Expand Up @@ -186,9 +187,12 @@ export function adapterFailureFromEvent(event: Extract<AdapterEvent, { type: "er
* This fixes items created from here on. History recorded before it is repaired at the
* replay boundary by `backfillWebSearchQueries()` in the Responses adapter.
*/
export function webSearchAction(queries: string[], fetch?: { url: string }): Record<string, unknown> {
export function webSearchAction(queries: string[], anthropic?: AnthropicServerBlocks): Record<string, unknown> {
// 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] };
}
Expand Down
4 changes: 2 additions & 2 deletions src/bridge/response-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,9 +547,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) {
Expand Down
13 changes: 7 additions & 6 deletions src/bridge/sse.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { anthropicProgressItems } from "../responses/anthropic-progress";
import type {
AdapterEvent,
AnthropicServerBlocks,
OcxMessagePhase,
OcxProviderContinuationState,
OcxProviderOpaqueToolCallMetadata,
Expand Down Expand Up @@ -685,15 +686,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);
Expand Down Expand Up @@ -1231,7 +1232,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) {
Expand Down
44 changes: 44 additions & 0 deletions src/claude/inbound-content-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,50 @@ 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:<tool_use id>`, `text:<text>`, `thinking:<signature>` or
* `redacted:<data>`, all of which survive translation. An anchor that occurs more than once in the
* assistant history (string content included) cannot name one owner, so its run stays dropped,
* as it was before replay existed. 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<string, Rec[]> | undefined {
if (!isRec(raw) || !Array.isArray(raw.messages)) return undefined;
const out = new Map<string, Rec[]>();
const seen = new Map<string, number>();
const count = (anchor: string) => seen.set(anchor, (seen.get(anchor) ?? 0) + 1);
for (const message of raw.messages) {
if (!isRec(message) || message.role !== "assistant") continue;
if (typeof message.content === "string") count(`text:${message.content}`);
if (!Array.isArray(message.content)) continue;
let run: Rec[] = [];
for (const block of message.content.filter(isRec)) {
if (block.type === "server_tool_use" || block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result") {
// The caller's breakpoints are dropped on this lane, as for the declared tools above.
run.push({ ...block, cache_control: undefined });
continue;
}
const anchor = 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;
if (anchor) count(anchor);
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 (anchor && replayable) out.set(anchor, run);
run = [];
}
}
for (const anchor of out.keys()) if ((seen.get(anchor) ?? 0) > 1) out.delete(anchor);
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;
Expand Down
16 changes: 11 additions & 5 deletions src/claude/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,16 +223,22 @@ 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";
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()}`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// An item emitted before `anthropic_blocks` carried a server web_fetch as `fetch_result`.
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 };
Expand Down
Loading
Loading