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 @@ -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: <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"}`) |
| 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` |
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
75 changes: 73 additions & 2 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,16 @@ 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 {
try {
const value = (JSON.parse(json) as Record<string, unknown> | null)?.[key];
return typeof value === "string" ? value : "";
} catch {
return "";
}
}

function anthropicKeyUsesBearer(provider: OcxProviderConfig): boolean {
return provider.apiKeyTransport === "bearer";
}
Expand Down Expand Up @@ -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<string, unknown> {
const stripped = stripResponsesOnlyEncryptedMarker(schema);
const obj = stripped && typeof stripped === "object" && !Array.isArray(stripped)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 / web_fetch call, until its result block. */
let serverSearch: { id: string; json: string; streaming: boolean } | undefined;

const emitDone = function* (): Generator<AdapterEvent> {
if (emittedDone) return;
Expand Down Expand Up @@ -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") {
Expand All @@ -1303,6 +1329,46 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
budget.openCall(currentToolCallId);
yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName };
}
if (block.type === "server_tool_use" && (block.name === "web_search" || block.name === "web_fetch") && typeof block.id === "string") {
// 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 };
}
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: 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"
? [{ 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,
},
};
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 };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -1346,10 +1412,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: an input past 4 KiB loses its label (query / url), never the call.
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
Expand Down
4 changes: 3 additions & 1 deletion src/bridge/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,9 @@ 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[]): Record<string, unknown> {
export function webSearchAction(queries: string[], fetch?: { url: string }): Record<string, unknown> {
// 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] };
}
Expand Down
3 changes: 2 additions & 1 deletion src/bridge/response-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 6 additions & 3 deletions src/bridge/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions src/claude/inbound-content-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
30 changes: 19 additions & 11 deletions src/claude/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,18 +223,27 @@ 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)
: [];
const query = typeof action.query === "string" ? action.query : "";
const input = sanitizeWebSearchInput(
queries.length > 1 ? { queries } : { query: queries[0] ?? query },
);
const completed = item.status !== "failed";
let resultContent: unknown;
if (completed) {
const hits: Rec[] = [];
Expand All @@ -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 };
}

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 4 additions & 3 deletions src/protocols/encoders/adapter-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface ClientWebSearch {
status: "completed" | "failed";
queries: string[];
sources: { url: string; title?: string }[];
fetch?: { url: string; result: unknown };
}

/**
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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": {
Expand Down
Loading
Loading