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 |
| `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
56 changes: 54 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 {
}
}

/** 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";
}
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 call, until its web_search_tool_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,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 } : {}) }] : []),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
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 };
Expand Down Expand Up @@ -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
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
3 changes: 3 additions & 0 deletions src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/server/responses/core-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ export interface HandleResponsesOptions {
clientDeclaredEmptyToolCatalog?: boolean;
nativeReasoningReplay?: ReadonlyMap<string, string>;
nativeReasoningMint?: { owner?: NativeReasoningOwner };
/** The Claude client's typed `web_search_*` / `web_fetch_*` declarations; kept only for `inboundWire: "anthropic"`. */
anthropicServerTools?: readonly Record<string, unknown>[];
/** 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. */
Expand Down
1 change: 1 addition & 0 deletions src/server/responses/request-prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/server/responses/response-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/types/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ export interface OcxParsedRequest {
_nativeReasoningReplay?: ReadonlyMap<string, string>;
_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<string, unknown>[];
/**
* 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
Expand Down
2 changes: 1 addition & 1 deletion structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading
Loading