Skip to content

support Anthropic server-tool passthrough (web_search/web_fetch) and tool_choice dispatch#661

Merged
mcowger merged 3 commits into
mainfrom
fix/websearch-passthrough-loss
Jul 4, 2026
Merged

support Anthropic server-tool passthrough (web_search/web_fetch) and tool_choice dispatch#661
mcowger merged 3 commits into
mainfrom
fix/websearch-passthrough-loss

Conversation

@mcowger

@mcowger mcowger commented Jul 4, 2026

Copy link
Copy Markdown
Owner

User description

Summary

  • Capture and pass through Anthropic server-tool content blocks (server_tool_use, web_search_tool_result, web_fetch_tool_result) that pi-ai's parser silently drops, covering all current web_search_*/web_fetch_* tool-type versions.
  • Fix tool_choice being sent to Anthropic in OpenAI's shape ({type:"function",function:{name}}) instead of Anthropic's own shape ({type:"tool",name}), which caused a 400 invalid_request_error and provider cooldown on the "cc" (Claude Code OAuth) provider.

Details

Server-tool block passthrough (918a7e04)
pi-ai's Anthropic response parser only recognizes text/thinking/redacted_thinking/tool_use blocks, silently dropping Anthropic's built-in server-side tool blocks. This lost web_search/web_fetch results on the client-facing response.

  • New builtin-tools.ts: shared allowlist of Anthropic built-in tool-type strings (all web_search_*/web_fetch_* versions) and their response block types.
  • anthropic-to-context.ts: splits built-in tool declarations out of context.tools (pi-ai's Tool type can't represent them), carried out-of-band for re-injection into the outgoing payload.
  • fetch-tap.ts: reconstructs server-tool content blocks from raw upstream bytes (streaming + non-streaming), keyed by request ID.
  • context-to-anthropic.ts: splices captured blocks back into both the non-streaming response and streaming SSE frames.

tool_choice shape fix (dba8b0d4)
All inbound parsers normalize tool_choice to OpenAI's shape (pi-ai's OpenAI provider expects that verbatim), but pi-ai's Anthropic provider doesn't convert it back — it assumes objects are already Anthropic-shaped. An Anthropic-originated tool_choice:{type:"tool",name} request got normalized to OpenAI shape inbound, then round-tripped unconverted back out to Anthropic's API, which rejected it. Added toAnthropicToolChoice() in pi-ai-executor.ts, applied once the resolved dispatch provider is known.

Both fixes are scoped to inference-v2 only (v1 shares no code with v2).

Testing

  • bun run typecheck — clean
  • bun run test — 2322 tests pass (full suite via pre-commit hook), including new regression coverage for both fixes

Description

  • Restore Anthropic server-tool blocks (server_tool_use, web_search_tool_result, web_fetch_tool_result) dropped by pi-ai's parser

  • Fix tool_choice being sent to Anthropic in OpenAI's shape instead of Anthropic's {type:"tool",name} shape

  • Add builtin-tools.ts allowlist and fetch-tap.ts raw-byte reconstruction for server-tool blocks

  • Add comprehensive tests for server-tool block splicing and tool_choice conversion


mcowger added 2 commits July 4, 2026 04:45
pi-ai's Anthropic parser silently drops server_tool_use /
web_search_tool_result / web_fetch_tool_result content blocks, since it
only recognizes text/thinking/redacted_thinking/tool_use. This lost
web_search and web_fetch built-in tool results on the client-facing
response.

- Add builtin-tools.ts as the shared allowlist of Anthropic built-in
  tool-type strings (all web_search_* and web_fetch_* versions) and
  their response block types, used by both the inbound parser and the
  raw-response tap.
- anthropic-to-context.ts: split built-in server-side tool
  declarations out of context.tools (pi-ai's Tool type can't represent
  them) and carry them out-of-band as builtinTools for re-injection
  into the outgoing payload.
- fetch-tap.ts: reconstruct server-tool content blocks directly from
  the raw upstream bytes (both non-streaming JSON and streaming SSE),
  keyed by requestId, for consumption exactly once by the response
  serializer.
- context-to-anthropic.ts: splice captured blocks back into both the
  non-streaming response and the streaming SSE frame sequence.
- index.ts: wire requestId/builtinTools through the beta messages
  route.
All inbound parsers (anthropic/openai/responses-to-context.ts)
normalize tool_choice to OpenAI's shape (string, or
{type:"function", function:{name}}), since pi-ai's own OpenAI
provider expects exactly that shape verbatim. But pi-ai's Anthropic
provider does not convert it back — it only wraps bare strings and
otherwise forwards objects as-is, assuming they're already
Anthropic-shaped ({type:"tool", name}).

An Anthropic-originated request with tool_choice:{type:"tool",name}
therefore got normalized to OpenAI shape on the way in, then
round-tripped straight back out to Anthropic's own API unconverted,
which rejected it with a 400 invalid_request_error on tool_choice's
type field.

Add toAnthropicToolChoice() in pi-ai-executor.ts and apply it at the
single callOptions construction site, once the resolved dispatch
provider is known, mirroring the existing provider-conditional
patterns already there (builtin tools, headers).
@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Memory Leak

serverToolWatchSet is only cleared by consumeServerToolBlocks. If a request arms the watch via watchForServerToolBlocks but the response is never tapped (e.g. requestId is absent from debugRequestIdStorage so the tap returns early at if (!requestId) return response;), or if consumeServerToolBlocks is never called because the response serialiser path is skipped, the requestId remains in serverToolWatchSet indefinitely. Over time this unbounded set leaks memory. A realistic trigger is an upstream fetch that happens outside the debugRequestIdStorage.run() context, or an early-return code path in the executor that bypasses serializeMessage/serializeChunks.

export function watchForServerToolBlocks(requestId: string): void {
  serverToolWatchSet.add(requestId);
}

/** Consumes (and clears) any server tool blocks captured for this request. */
export function consumeServerToolBlocks(requestId: string): any[] {
  const blocks = serverToolBlocksMap.get(requestId) ?? [];
  serverToolBlocksMap.delete(requestId);
  serverToolWatchSet.delete(requestId);
  return blocks;
}
Memory Leak

serverToolBlocksMap is only cleared by consumeServerToolBlocks. If the executor arms the watch and the upstream returns server-tool blocks, but the response serialiser never calls consumeServerToolBlocks (e.g. an exception is thrown after the fetch completes but before serialisation, on a code path not covered by the existing cleanup in the catch block or buildSSEGenerator's finally), the captured blocks array remains in the map indefinitely. This is an unbounded leak keyed by requestId.

const serverToolBlocksMap = new Map<string, any[]>();

/**
 * Arms server-tool-block capture for this request. Called by the executor's
 * onPayload hook when the outgoing payload carries an Anthropic builtin tool
 * (e.g. web_search_20250305). Idempotent — safe to call once per attempt in
 * a failover loop.
 */
export function watchForServerToolBlocks(requestId: string): void {
  serverToolWatchSet.add(requestId);
}

/** Consumes (and clears) any server tool blocks captured for this request. */
export function consumeServerToolBlocks(requestId: string): any[] {
  const blocks = serverToolBlocksMap.get(requestId) ?? [];
  serverToolBlocksMap.delete(requestId);
  serverToolWatchSet.delete(requestId);
  return blocks;
}
Possible Issue

toAnthropicToolChoice converts OpenAI's { type: "function", function: { name } } to { type: "tool", name }, but if function.name is undefined (e.g. malformed inbound tool_choice), it produces { type: "tool", name: undefined }. Anthropic's API will reject a tool_choice with type: "tool" but no name with a 400 error. This is a narrow edge case triggered only by malformed inbound requests, but it converts a previously-passed-through invalid shape into an explicitly invalid Anthropic shape.

if (typeof choice === 'object' && (choice as any).type === 'function') {
  return { type: 'tool', name: (choice as any).function?.name };
}

Comment thread packages/backend/src/inference-v2/shared/fetch-tap.ts
Comment thread packages/backend/src/inference-v2/shared/fetch-tap.ts
Comment thread packages/backend/src/inference-v2/shared/pi-ai-executor.ts
@mcowger mcowger changed the title fix(inference-v2): restore Anthropic server-tool passthrough (web_search/web_fetch) and tool_choice dispatch support Anthropic server-tool passthrough (web_search/web_fetch) and tool_choice dispatch Jul 4, 2026
PR review follow-up: a malformed inbound tool_choice
({type:"function", function:{}}) previously converted to
{type:"tool", name: undefined}. JSON.stringify silently drops the
undefined key, so Anthropic would receive {"type":"tool"} with no
name and reject it with a different, less useful error than before.
Forward the original (still-invalid) shape unchanged when name is
missing instead of manufacturing an incomplete Anthropic shape.
@mcowger mcowger merged commit a8ef3ce into main Jul 4, 2026
2 checks passed
@mcowger mcowger deleted the fix/websearch-passthrough-loss branch July 4, 2026 05:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant