diff --git a/packages/backend/src/inference-v2/anthropic/__tests__/anthropic-to-context.test.ts b/packages/backend/src/inference-v2/anthropic/__tests__/anthropic-to-context.test.ts index bcf0f5c5..cd89f3a7 100644 --- a/packages/backend/src/inference-v2/anthropic/__tests__/anthropic-to-context.test.ts +++ b/packages/backend/src/inference-v2/anthropic/__tests__/anthropic-to-context.test.ts @@ -211,6 +211,46 @@ describe('anthropicRequestToContext', () => { expect(result.context.tools![0]!.name).toBe('calculator'); expect(result.toolsDefined).toBe(1); }); + + it('splits Anthropic builtin tools (web_search_20250305) out of context.tools', () => { + const result = anthropicRequestToContext({ + model: 'claude-opus-4-6', + messages: [{ role: 'user', content: 'Hi' }], + tools: [ + { + name: 'calculator', + description: 'Compute', + input_schema: { type: 'object', properties: { x: { type: 'number' } } }, + }, + { type: 'web_search_20250305', name: 'web_search', max_uses: 5 }, + ], + }); + expect(result.context.tools).toHaveLength(1); + expect(result.context.tools![0]!.name).toBe('calculator'); + expect(result.builtinTools).toEqual([ + { type: 'web_search_20250305', name: 'web_search', max_uses: 5 }, + ]); + expect(result.toolsDefined).toBe(2); + }); + + it('omits context.tools entirely when only builtin tools are present', () => { + const result = anthropicRequestToContext({ + model: 'claude-opus-4-6', + messages: [{ role: 'user', content: 'Hi' }], + tools: [{ type: 'web_search_20250305', name: 'web_search' }], + }); + expect(result.context.tools).toBeUndefined(); + expect(result.builtinTools).toEqual([{ type: 'web_search_20250305', name: 'web_search' }]); + expect(result.toolsDefined).toBe(1); + }); + + it('returns an empty builtinTools array when no tools are present', () => { + const result = anthropicRequestToContext({ + model: 'claude-opus-4-6', + messages: [{ role: 'user', content: 'Hi' }], + }); + expect(result.builtinTools).toEqual([]); + }); }); describe('reasoning effort', () => { diff --git a/packages/backend/src/inference-v2/anthropic/__tests__/context-to-anthropic.test.ts b/packages/backend/src/inference-v2/anthropic/__tests__/context-to-anthropic.test.ts index f0ac44bd..a3274bfe 100644 --- a/packages/backend/src/inference-v2/anthropic/__tests__/context-to-anthropic.test.ts +++ b/packages/backend/src/inference-v2/anthropic/__tests__/context-to-anthropic.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import type { AssistantMessage, AssistantMessageEvent } from '@earendil-works/pi-ai'; import { messageToAnthropicResponse, @@ -8,6 +8,22 @@ import { // @earendil-works/pi-ai and utils/logger are globally mocked in test/vitest.setup.ts +// For the server-tool-block splicing tests below: context-to-anthropic.ts +// consumes captured blocks from fetch-tap.ts's module-private map, which is +// only populated via the real fetch interception pipeline (no test-only +// seed export exists). So those tests stub `fetch`, reset modules, and +// dynamically import fetch-tap + context-to-anthropic together so both +// share the same fresh module instance and map. +async function loadWithMockedFetch(mockFetch: typeof fetch) { + vi.stubGlobal('fetch', mockFetch); + vi.resetModules(); + const fetchTap = await import('../../shared/fetch-tap'); + const executor = await import('../../shared/pi-ai-executor'); + const contextToAnthropic = await import('../context-to-anthropic'); + fetchTap.installFetchTap(); + return { fetchTap, contextToAnthropic, debugRequestIdStorage: executor.debugRequestIdStorage }; +} + function zeroUsage() { return { input: 0, @@ -19,6 +35,11 @@ function zeroUsage() { }; } +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); +}); + function makeMessage(overrides: Partial = {}): AssistantMessage { return { role: 'assistant', @@ -451,3 +472,110 @@ describe('eventToAnthropicSSE', () => { } }); }); + +describe('server-tool block splicing (web_search etc.)', () => { + it('messageToAnthropicResponse appends captured server_tool_use/web_search_tool_result blocks after tool_use', async () => { + const body = JSON.stringify({ + content: [ + { type: 'server_tool_use', id: 'srvtoolu_1', name: 'web_search', input: { query: 'q' } }, + { + type: 'web_search_tool_result', + tool_use_id: 'srvtoolu_1', + content: [{ type: 'web_search_result', title: 't', url: 'https://example.com' }], + }, + ], + }); + const mockFetch = vi.fn( + async () => + new Response(body, { + status: 200, + headers: { 'content-type': 'application/json', 'content-length': String(body.length) }, + }) + ); + const { fetchTap, contextToAnthropic, debugRequestIdStorage } = await loadWithMockedFetch( + mockFetch as any + ); + + const requestId = 'req-splice-1'; + fetchTap.watchForServerToolBlocks(requestId); + await debugRequestIdStorage.run(requestId, () => fetch('https://upstream.example/x')); + + const msg = makeMessage({ + stopReason: 'toolUse', + content: [{ type: 'toolCall', id: 'toolu_1', name: 'search', arguments: { q: 'hi' } } as any], + }); + const result = contextToAnthropic.messageToAnthropicResponse( + msg, + 'claude-opus-4-6', + undefined, + requestId + ); + + expect(result.content).toHaveLength(3); + expect(result.content[0]).toMatchObject({ type: 'tool_use', id: 'toolu_1' }); + expect(result.content[1]).toMatchObject({ type: 'server_tool_use', id: 'srvtoolu_1' }); + expect(result.content[2]).toMatchObject({ + type: 'web_search_tool_result', + tool_use_id: 'srvtoolu_1', + }); + }); + + it('messageToAnthropicResponse omits extra blocks when no requestId is passed', () => { + const msg = makeMessage({ content: [{ type: 'text', text: 'Hello!' }] }); + const result = messageToAnthropicResponse(msg, 'claude-opus-4-6'); + expect(result.content).toHaveLength(1); + }); + + it('eventToAnthropicSSE emits extra content_block_start/stop frames for spliced blocks before message_delta on done', async () => { + const body = JSON.stringify({ + content: [{ type: 'server_tool_use', id: 'srvtoolu_2', name: 'web_search', input: {} }], + }); + const mockFetch = vi.fn( + async () => + new Response(body, { + status: 200, + headers: { 'content-type': 'application/json', 'content-length': String(body.length) }, + }) + ); + const { fetchTap, contextToAnthropic, debugRequestIdStorage } = await loadWithMockedFetch( + mockFetch as any + ); + + const requestId = 'req-splice-2'; + fetchTap.watchForServerToolBlocks(requestId); + await debugRequestIdStorage.run(requestId, () => fetch('https://upstream.example/x')); + + const state = contextToAnthropic.makeAnthropicChunkSerialiserState( + 'claude-opus-4-6', + requestId + ); + contextToAnthropic.eventToAnthropicSSE( + { type: 'start', partial: { content: [], usage: zeroUsage() } } as any, + state + ); + const message = makeMessage({ stopReason: 'stop' }); + const frames = contextToAnthropic.eventToAnthropicSSE( + { type: 'done', reason: 'stop', message } as any, + state + ); + + const allFrames = frames.join('\n---\n'); + const blockStartIndex = allFrames.indexOf('server_tool_use'); + const deltaIndex = allFrames.indexOf('message_delta'); + expect(blockStartIndex).toBeGreaterThan(-1); + expect(deltaIndex).toBeGreaterThan(-1); + expect(blockStartIndex).toBeLessThan(deltaIndex); + expect(allFrames).toContain('content_block_stop'); + }); + + it('eventToAnthropicSSE does not emit extra frames when no requestId is set on state', () => { + const state = makeAnthropicChunkSerialiserState('claude-opus-4-6'); + eventToAnthropicSSE( + { type: 'start', partial: { content: [], usage: zeroUsage() } } as any, + state + ); + const message = makeMessage({ stopReason: 'stop' }); + const frames = eventToAnthropicSSE({ type: 'done', reason: 'stop', message } as any, state); + expect(frames.join('\n')).not.toContain('server_tool_use'); + }); +}); diff --git a/packages/backend/src/inference-v2/anthropic/anthropic-to-context.ts b/packages/backend/src/inference-v2/anthropic/anthropic-to-context.ts index 55ee308d..a5d1ec67 100644 --- a/packages/backend/src/inference-v2/anthropic/anthropic-to-context.ts +++ b/packages/backend/src/inference-v2/anthropic/anthropic-to-context.ts @@ -35,6 +35,7 @@ import type { Tool, } from '@earendil-works/pi-ai'; import { jsonSchemaToTypeBox } from '../../transformers/oauth/type-mappers'; +import { isAnthropicBuiltinTool } from './builtin-tools'; import type { ReasoningIntent } from '../shared/reasoning'; import { budgetToEffort, normalizeVisibility } from '../shared/reasoning'; import type { GenerationIntent } from '../shared/generation'; @@ -50,6 +51,15 @@ export interface AnthropicToContextResult { toolChoice?: unknown; toolsDefined: number; messageCount: number; + /** + * Anthropic built-in server-side tool declarations (e.g. web_search_20250305), + * extracted verbatim from the request body. These have no `input_schema` and + * are not representable as pi-ai `Tool`s (which only model client-side + * function tools), so they're carried out-of-band here and re-injected into + * the outgoing payload by the executor's onPayload hook. See + * ANTHROPIC_BUILTIN_TOOL_TYPES in ./builtin-tools. + */ + builtinTools: any[]; } // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -80,12 +90,22 @@ function parseUserContentBlock(block: any): TextContent | ImageContent | null { return null; // unknown / skipped block type } -function parseTools(tools: any[]): Tool[] { - return tools.map((t) => ({ - name: t.name ?? '', - description: t.description ?? '', - parameters: jsonSchemaToTypeBox(t.input_schema ?? {}), - })); +/** Splits inbound tools into pi-ai-representable function tools and raw builtin tool objects. */ +function parseTools(tools: any[]): { tools: Tool[]; builtinTools: any[] } { + const functionTools: Tool[] = []; + const builtinTools: any[] = []; + for (const t of tools) { + if (isAnthropicBuiltinTool(t)) { + builtinTools.push(t); + continue; + } + functionTools.push({ + name: t.name ?? '', + description: t.description ?? '', + parameters: jsonSchemaToTypeBox(t.input_schema ?? {}), + }); + } + return { tools: functionTools, builtinTools }; } function mapToolChoice(raw: any): unknown | undefined { @@ -245,8 +265,12 @@ export function anthropicRequestToContext(body: any): AnthropicToContextResult { } // ── Tools ───────────────────────────────────────────────────────────────── - const tools = - Array.isArray(body.tools) && body.tools.length > 0 ? parseTools(body.tools) : undefined; + const parsedTools = + Array.isArray(body.tools) && body.tools.length > 0 + ? parseTools(body.tools) + : { tools: [], builtinTools: [] }; + const tools = parsedTools.tools.length > 0 ? parsedTools.tools : undefined; + const builtinTools = parsedTools.builtinTools; const context: Context = { systemPrompt, @@ -314,7 +338,8 @@ export function anthropicRequestToContext(body: any): AnthropicToContextResult { generationIntent, streaming: body.stream === true, toolChoice: mapToolChoice(body.tool_choice), - toolsDefined: tools?.length ?? 0, + toolsDefined: (tools?.length ?? 0) + builtinTools.length, messageCount, + builtinTools, }; } diff --git a/packages/backend/src/inference-v2/anthropic/builtin-tools.ts b/packages/backend/src/inference-v2/anthropic/builtin-tools.ts new file mode 100644 index 00000000..132dfbeb --- /dev/null +++ b/packages/backend/src/inference-v2/anthropic/builtin-tools.ts @@ -0,0 +1,44 @@ +/** + * Anthropic built-in (server-side) tool vocabulary, shared across the + * inference-v2 beta path's inbound parser (anthropic-to-context.ts) and the + * raw-response tap (shared/fetch-tap.ts) so the request-side tool-type + * allowlist and the response-side block-type allowlist stay in lockstep — add + * a new Anthropic server tool here once and both sides pick it up. + * + * Intentionally NOT shared with the v1 transformer path + * (transformers/anthropic/tool-mapper.ts), which keeps its own copy: v1 and v2 + * share no code. + */ + +/** + * Tool `type` values Anthropic treats as built-in server-side tools. These + * have no `input_schema` and are not representable as pi-ai function tools, so + * anthropic-to-context.ts splits them out and pi-ai-executor.ts re-injects + * them verbatim into the outgoing payload. + */ +export const ANTHROPIC_BUILTIN_TOOL_TYPES = new Set([ + 'web_search_20250305', + 'web_search_20260209', // adds dynamic filtering (runs via code execution) + 'web_search_20260318', // adds response_inclusion + 'web_fetch_20250910', + 'web_fetch_20260209', // adds dynamic filtering (runs via code execution) + 'web_fetch_20260309', // adds use_cache + 'web_fetch_20260318', // adds response_inclusion +]); + +/** + * Response content-block `type` values emitted by those built-in tools. pi-ai's + * Anthropic parser drops these, so fetch-tap.ts reconstructs them from the raw + * upstream bytes. `server_tool_use` is shared by both web_search and web_fetch + * calls; each tool has its own result block type. + */ +export const ANTHROPIC_SERVER_TOOL_BLOCK_TYPES = new Set([ + 'server_tool_use', + 'web_search_tool_result', + 'web_fetch_tool_result', +]); + +/** True when `t` is an Anthropic built-in server-side tool declaration. */ +export function isAnthropicBuiltinTool(t: any): boolean { + return !!t && typeof t === 'object' && ANTHROPIC_BUILTIN_TOOL_TYPES.has(t.type); +} diff --git a/packages/backend/src/inference-v2/anthropic/context-to-anthropic.ts b/packages/backend/src/inference-v2/anthropic/context-to-anthropic.ts index e9d377c1..6a5f1dce 100644 --- a/packages/backend/src/inference-v2/anthropic/context-to-anthropic.ts +++ b/packages/backend/src/inference-v2/anthropic/context-to-anthropic.ts @@ -28,6 +28,7 @@ import type { ToolCall, } from '@earendil-works/pi-ai'; import { isPlaceholderThinkingSignature } from '../shared/pi-ai-utils'; +import { consumeServerToolBlocks } from '../shared/fetch-tap'; // ─── Anthropic wire types ───────────────────────────────────────────────────── @@ -41,7 +42,15 @@ export interface AnthropicUsage { export type AnthropicContentBlock = | { type: 'text'; text: string } | { type: 'thinking'; thinking: string; signature?: string } - | { type: 'tool_use'; id: string; name: string; input: Record }; + | { type: 'tool_use'; id: string; name: string; input: Record } + // Anthropic built-in server-side tool blocks (e.g. web_search_20250305, + // web_fetch_20250910). pi-ai's parser drops these entirely — see + // fetch-tap.ts's watchForServerToolBlocks()/consumeServerToolBlocks(), + // which reconstruct them from the raw upstream bytes for splicing in here + // verbatim. + | { type: 'server_tool_use'; [key: string]: unknown } + | { type: 'web_search_tool_result'; [key: string]: unknown } + | { type: 'web_fetch_tool_result'; [key: string]: unknown }; export interface AnthropicMessage { id: string; @@ -121,9 +130,15 @@ function buildDeltaUsage(u: Usage) { export function messageToAnthropicResponse( message: AssistantMessage, modelAlias: string, - messageId?: string + messageId?: string, + requestId?: string ): AnthropicMessage { const id = messageId ?? makeMessageId(); + // Blocks pi-ai's parser dropped (server_tool_use / web_search_tool_result), + // reconstructed by fetch-tap.ts from the raw upstream bytes. Appended after + // tool_use blocks rather than interleaved in original wire order — exact + // ordering fidelity was explicitly out of scope for this fix. + const extraBlocks: AnthropicContentBlock[] = requestId ? consumeServerToolBlocks(requestId) : []; // Error / aborted: surface errorMessage as a text block if (message.stopReason === 'error' || message.stopReason === 'aborted') { @@ -167,7 +182,7 @@ export function messageToAnthropicResponse( type: 'message', role: 'assistant', model: modelAlias, - content: [...thinkingBlocks, ...textBlocks, ...toolBlocks], + content: [...thinkingBlocks, ...textBlocks, ...toolBlocks, ...extraBlocks], stop_reason: mapStopReason(message.stopReason), stop_sequence: null, usage: mapUsage(message.usage), @@ -180,6 +195,7 @@ export function messageToAnthropicResponse( * Mutable state threaded through `eventToAnthropicSSE` calls for one stream. */ export interface AnthropicChunkSerialiserState { + requestId?: string; messageId: string; model: string; /** Next block index to assign */ @@ -205,8 +221,12 @@ export interface AnthropicChunkSerialiserState { pendingStart: boolean; } -export function makeAnthropicChunkSerialiserState(model: string): AnthropicChunkSerialiserState { +export function makeAnthropicChunkSerialiserState( + model: string, + requestId?: string +): AnthropicChunkSerialiserState { return { + requestId, messageId: makeMessageId(), model, nextBlockIndex: 0, @@ -342,6 +362,29 @@ export function eventToAnthropicSSE( ); }; + /** + * Emit any captured server-tool blocks (web_search etc.) as their own + * content_block_start/stop frame pairs. Called just before message_delta + * on 'done'/'error' — these blocks only become available once the whole + * response has been captured, so they can't stream incrementally on this + * path; they land as a batch at the end instead of interleaved with text. + */ + const emitExtraServerToolBlocks = () => { + if (!state.requestId) return; + const extraBlocks = consumeServerToolBlocks(state.requestId); + for (const block of extraBlocks) { + const index = state.nextBlockIndex++; + frames.push( + sseEvent('content_block_start', { + type: 'content_block_start', + index, + content_block: block, + }) + ); + frames.push(sseEvent('content_block_stop', { type: 'content_block_stop', index })); + } + }; + // ── Event dispatch ──────────────────────────────────────────────────────── switch (event.type) { @@ -440,6 +483,7 @@ export function eventToAnthropicSSE( case 'done': { ensureStarted(); closeCurrentBlock(); + emitExtraServerToolBlocks(); frames.push( sseEvent('message_delta', { type: 'message_delta', @@ -457,6 +501,7 @@ export function eventToAnthropicSSE( case 'error': { ensureStarted(); closeCurrentBlock(); + emitExtraServerToolBlocks(); frames.push( sseEvent('message_delta', { type: 'message_delta', diff --git a/packages/backend/src/inference-v2/index.ts b/packages/backend/src/inference-v2/index.ts index 054258b6..d4f67201 100644 --- a/packages/backend/src/inference-v2/index.ts +++ b/packages/backend/src/inference-v2/index.ts @@ -397,7 +397,7 @@ export async function handleBetaMessages( const parsed = anthropicRequestToContext(body); // ── Serialiser state ───────────────────────────────────────────────── - const chunkState = makeAnthropicChunkSerialiserState(modelAlias); + const chunkState = makeAnthropicChunkSerialiserState(modelAlias, requestId); // ── Execute ────────────────────────────────────────────────────────── const result = await runPiAiExecutor({ @@ -414,10 +414,11 @@ export async function handleBetaMessages( signal, toolsDefined: parsed.toolsDefined, messageCount: parsed.messageCount, + builtinTools: parsed.builtinTools, onSuccess: async () => { // Stage 2: no-op }, - serializeMessage: (msg) => messageToAnthropicResponse(msg, modelAlias, requestId), + serializeMessage: (msg) => messageToAnthropicResponse(msg, modelAlias, requestId, requestId), serializeChunks: (event) => eventToAnthropicSSE(event, chunkState), }); diff --git a/packages/backend/src/inference-v2/shared/__tests__/fetch-tap.test.ts b/packages/backend/src/inference-v2/shared/__tests__/fetch-tap.test.ts new file mode 100644 index 00000000..b076a0e0 --- /dev/null +++ b/packages/backend/src/inference-v2/shared/__tests__/fetch-tap.test.ts @@ -0,0 +1,340 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// @earendil-works/pi-ai and utils/logger are globally mocked in test/vitest.setup.ts + +// fetch-tap.ts captures `globalThis.fetch` as `originalFetch` at module-load +// time, so each test stubs the global first, then re-imports the module +// fresh (via resetModules) so the stub is what gets captured. +async function loadFetchTapWithMockedFetch(mockFetch: typeof fetch) { + vi.stubGlobal('fetch', mockFetch); + vi.resetModules(); + const fetchTap = await import('../fetch-tap'); + const executor = await import('../pi-ai-executor'); + fetchTap.installFetchTap(); + return { fetchTap, debugRequestIdStorage: executor.debugRequestIdStorage }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +describe('fetch-tap server-tool block capture', () => { + describe('non-streaming', () => { + it('captures server_tool_use and web_search_tool_result blocks from a JSON response', async () => { + const body = JSON.stringify({ + type: 'message', + content: [ + { type: 'text', text: 'Here is what I found.' }, + { type: 'server_tool_use', id: 'srvtoolu_1', name: 'web_search', input: { query: 'q' } }, + { + type: 'web_search_tool_result', + tool_use_id: 'srvtoolu_1', + content: [{ type: 'web_search_result', title: 't', url: 'https://example.com' }], + }, + ], + }); + const mockFetch = vi.fn( + async () => + new Response(body, { + status: 200, + headers: { 'content-type': 'application/json', 'content-length': String(body.length) }, + }) + ); + const { fetchTap, debugRequestIdStorage } = await loadFetchTapWithMockedFetch( + mockFetch as any + ); + + const requestId = 'req-1'; + fetchTap.watchForServerToolBlocks(requestId); + + await debugRequestIdStorage.run(requestId, () => fetch('https://upstream.example/x')); + + const blocks = fetchTap.consumeServerToolBlocks(requestId); + expect(blocks).toHaveLength(2); + expect(blocks[0]).toMatchObject({ type: 'server_tool_use', id: 'srvtoolu_1' }); + expect(blocks[1]).toMatchObject({ + type: 'web_search_tool_result', + tool_use_id: 'srvtoolu_1', + }); + }); + + it('consumeServerToolBlocks clears captured state (one-shot)', async () => { + const body = JSON.stringify({ + content: [{ type: 'server_tool_use', id: 's1', name: 'web_search', input: {} }], + }); + const mockFetch = vi.fn( + async () => + new Response(body, { + status: 200, + headers: { 'content-type': 'application/json', 'content-length': String(body.length) }, + }) + ); + const { fetchTap, debugRequestIdStorage } = await loadFetchTapWithMockedFetch( + mockFetch as any + ); + + const requestId = 'req-2'; + fetchTap.watchForServerToolBlocks(requestId); + await debugRequestIdStorage.run(requestId, () => fetch('https://upstream.example/x')); + + expect(fetchTap.consumeServerToolBlocks(requestId)).toHaveLength(1); + expect(fetchTap.consumeServerToolBlocks(requestId)).toHaveLength(0); + }); + + it('does not capture anything when the request was never armed via watchForServerToolBlocks', async () => { + const body = JSON.stringify({ + content: [{ type: 'server_tool_use', id: 's1', name: 'web_search', input: {} }], + }); + const mockFetch = vi.fn( + async () => + new Response(body, { + status: 200, + headers: { 'content-type': 'application/json', 'content-length': String(body.length) }, + }) + ); + const { fetchTap, debugRequestIdStorage } = await loadFetchTapWithMockedFetch( + mockFetch as any + ); + + const requestId = 'req-3'; + await debugRequestIdStorage.run(requestId, () => fetch('https://upstream.example/x')); + + expect(fetchTap.consumeServerToolBlocks(requestId)).toHaveLength(0); + }); + }); + + describe('streaming', () => { + function sseStreamFromEvents(events: Array<{ event: string; data: unknown }>): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const { event, data } of events) { + controller.enqueue( + encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + ); + } + controller.close(); + }, + }); + } + + it('reconstructs a server_tool_use block from content_block_start/delta/stop SSE events', async () => { + const stream = sseStreamFromEvents([ + { + event: 'content_block_start', + data: { + type: 'content_block_start', + index: 0, + content_block: { + type: 'server_tool_use', + id: 'srvtoolu_1', + name: 'web_search', + input: {}, + }, + }, + }, + { + event: 'content_block_delta', + data: { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{"query":' }, + }, + }, + { + event: 'content_block_delta', + data: { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '"weather today"}' }, + }, + }, + { event: 'content_block_stop', data: { type: 'content_block_stop', index: 0 } }, + ]); + const mockFetch = vi.fn( + async () => + new Response(stream, { + status: 200, + headers: { 'content-type': 'text/event-stream', 'transfer-encoding': 'chunked' }, + }) + ); + const { fetchTap, debugRequestIdStorage } = await loadFetchTapWithMockedFetch( + mockFetch as any + ); + + const requestId = 'req-stream-1'; + fetchTap.watchForServerToolBlocks(requestId); + + const response = await debugRequestIdStorage.run(requestId, () => + fetch('https://upstream.example/x') + ); + // Drain the (tapped) body so transform()/flush() actually run — mirrors + // pi-ai reading the stream downstream. + const reader = response.body!.getReader(); + // eslint-disable-next-line no-constant-condition + while (true) { + const { done } = await reader.read(); + if (done) break; + } + + const blocks = fetchTap.consumeServerToolBlocks(requestId); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: 'server_tool_use', + id: 'srvtoolu_1', + name: 'web_search', + }); + expect(blocks[0]!.input).toEqual({ query: 'weather today' }); + }); + + it('handles CRLF-framed events split across chunk boundaries', async () => { + // Real Anthropic uses LF, but an intermediary proxy may re-frame with + // CRLF; eventsource-parser must still recognize event boundaries. Also + // split each event mid-way across two chunks to exercise cross-chunk + // buffering. + const encoder = new TextEncoder(); + const rawEvents = [ + `event: content_block_start\r\ndata: ${JSON.stringify({ + type: 'content_block_start', + index: 0, + content_block: { type: 'web_search_tool_result', tool_use_id: 's1', content: [] }, + })}\r\n\r\n`, + `event: content_block_stop\r\ndata: ${JSON.stringify({ + type: 'content_block_stop', + index: 0, + })}\r\n\r\n`, + ]; + const joined = rawEvents.join(''); + const mid = Math.floor(joined.length / 2); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(joined.slice(0, mid))); + controller.enqueue(encoder.encode(joined.slice(mid))); + controller.close(); + }, + }); + const mockFetch = vi.fn( + async () => + new Response(stream, { + status: 200, + headers: { 'content-type': 'text/event-stream', 'transfer-encoding': 'chunked' }, + }) + ); + const { fetchTap, debugRequestIdStorage } = await loadFetchTapWithMockedFetch( + mockFetch as any + ); + + const requestId = 'req-stream-crlf'; + fetchTap.watchForServerToolBlocks(requestId); + + const response = await debugRequestIdStorage.run(requestId, () => + fetch('https://upstream.example/x') + ); + const reader = response.body!.getReader(); + // eslint-disable-next-line no-constant-condition + while (true) { + const { done } = await reader.read(); + if (done) break; + } + + const blocks = fetchTap.consumeServerToolBlocks(requestId); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ type: 'web_search_tool_result', tool_use_id: 's1' }); + }); + + it('finalizes a block left open by a truncated stream via the defensive flush path', async () => { + const stream = sseStreamFromEvents([ + { + event: 'content_block_start', + data: { + type: 'content_block_start', + index: 0, + content_block: { type: 'web_search_tool_result', tool_use_id: 's1', content: [] }, + }, + }, + // Stream ends (EOF) without a content_block_stop for index 0. + ]); + const mockFetch = vi.fn( + async () => + new Response(stream, { + status: 200, + headers: { 'content-type': 'text/event-stream', 'transfer-encoding': 'chunked' }, + }) + ); + const { fetchTap, debugRequestIdStorage } = await loadFetchTapWithMockedFetch( + mockFetch as any + ); + + const requestId = 'req-stream-2'; + fetchTap.watchForServerToolBlocks(requestId); + + const response = await debugRequestIdStorage.run(requestId, () => + fetch('https://upstream.example/x') + ); + const reader = response.body!.getReader(); + // eslint-disable-next-line no-constant-condition + while (true) { + const { done } = await reader.read(); + if (done) break; + } + + const blocks = fetchTap.consumeServerToolBlocks(requestId); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ type: 'web_search_tool_result', tool_use_id: 's1' }); + }); + + it('does not enqueue extra bytes into the passed-through body', async () => { + const stream = sseStreamFromEvents([ + { + event: 'content_block_start', + data: { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }, + }, + { + event: 'content_block_delta', + data: { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'hi' }, + }, + }, + ]); + const mockFetch = vi.fn( + async () => + new Response(stream, { + status: 200, + headers: { 'content-type': 'text/event-stream', 'transfer-encoding': 'chunked' }, + }) + ); + const { fetchTap, debugRequestIdStorage } = await loadFetchTapWithMockedFetch( + mockFetch as any + ); + + const requestId = 'req-stream-3'; + fetchTap.watchForServerToolBlocks(requestId); + + const response = await debugRequestIdStorage.run(requestId, () => + fetch('https://upstream.example/x') + ); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let full = ''; + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + full += decoder.decode(value); + } + + expect(full).toContain('text_delta'); + expect(full).toContain('hi'); + // No server-tool blocks were present, so nothing captured, and the + // pass-through body is untouched (not duplicated/corrupted). + expect(fetchTap.consumeServerToolBlocks(requestId)).toHaveLength(0); + }); + }); +}); diff --git a/packages/backend/src/inference-v2/shared/__tests__/pi-ai-executor.test.ts b/packages/backend/src/inference-v2/shared/__tests__/pi-ai-executor.test.ts index 214d96b1..6ae5d32c 100644 --- a/packages/backend/src/inference-v2/shared/__tests__/pi-ai-executor.test.ts +++ b/packages/backend/src/inference-v2/shared/__tests__/pi-ai-executor.test.ts @@ -1068,6 +1068,144 @@ describe('runPiAiExecutor with OAuth and Claude Masking', () => { expect(result.response).toBeDefined(); expect((result.response as any).content[0].text).toBe('hello oauth'); }); + + it('injects builtinTools into the outgoing payload.tools via onPayload', async () => { + registerSpy(OAuthAuthManager.getInstance(), 'getApiKey').mockResolvedValue('mock-oauth-key'); + + let capturedPayload: any; + vi.mocked(piAi.complete).mockImplementation((async ( + _model: any, + _context: any, + options: any + ) => { + capturedPayload = await options.onPayload?.({ + model: 'claude-3-5-sonnet-20241022', + tools: [{ name: 'calculator', description: 'Compute', parameters: {} }], + }); + return { + role: 'assistant', + content: [{ type: 'text', text: 'hello oauth' }], + provider: 'anthropic', + model: 'claude-3-5-sonnet-20241022', + usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + stopReason: 'stop', + timestamp: Date.now(), + } as any; + }) as any); + + const usageStorage = createUsageStorage(); + await runPiAiExecutor({ + requestId: 'req-builtin-tools', + incomingApiType: 'messages', + modelAlias: 'claude-3-5-sonnet-20241022', + context: { messages: [] } as any, + generationIntent: { reasoning: { source: 'client' } } as any, + streaming: false, + request: { + body: {}, + keyName: 'beta-key', + attribution: 'opencode', + ip: '127.0.0.1', + } as any, + usageStorage, + builtinTools: [{ type: 'web_search_20250305', name: 'web_search' }], + serializeMessage: (msg) => msg as any, + serializeChunks: () => [], + }); + + expect(capturedPayload.tools).toEqual([ + { name: 'calculator', description: 'Compute', parameters: {} }, + { type: 'web_search_20250305', name: 'web_search' }, + ]); + }); + + // Regression for trace 6689d862-69e6-40e7-a4b3-cb949fa3ecaf / + // eb4b54e5-853c-4ae9-8416-cfef6a33dde7: anthropic-to-context.ts normalizes + // inbound Anthropic `tool_choice: { type: "tool", name }` to OpenAI's + // `{ type: "function", function: { name } }` shape (the shape pi-ai's own + // OpenAI provider expects verbatim). But pi-ai's Anthropic provider does + // NOT convert it back — it forwards non-string toolChoice objects as-is — + // so the OpenAI-shaped object reached Anthropic's API and was rejected + // with a 400 invalid_request_error ("tool_choice: Input tag 'function' ... + // does not match any of the expected tags: 'auto', 'any', 'tool', 'none'"). + it('converts OpenAI-shaped tool_choice back to Anthropic shape when dispatching to an Anthropic provider', async () => { + registerSpy(OAuthAuthManager.getInstance(), 'getApiKey').mockResolvedValue('mock-oauth-key'); + + vi.mocked(piAi.complete).mockResolvedValue({ + role: 'assistant', + content: [{ type: 'text', text: 'hello oauth' }], + provider: 'anthropic', + model: 'claude-3-5-sonnet-20241022', + usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + stopReason: 'stop', + timestamp: Date.now(), + } as any); + + const usageStorage = createUsageStorage(); + await runPiAiExecutor({ + requestId: 'req-tool-choice', + incomingApiType: 'messages', + modelAlias: 'claude-3-5-sonnet-20241022', + context: { messages: [] } as any, + generationIntent: { reasoning: { source: 'client' } } as any, + toolChoice: { type: 'function', function: { name: 'web_search' } }, + streaming: false, + request: { + body: {}, + keyName: 'beta-key', + attribution: 'opencode', + ip: '127.0.0.1', + } as any, + usageStorage, + serializeMessage: (msg) => msg as any, + serializeChunks: () => [], + }); + + const [, , callOptions] = vi.mocked(piAi.complete).mock.calls[0]!; + expect((callOptions as any).toolChoice).toEqual({ type: 'tool', name: 'web_search' }); + }); + + // PR review follow-up: a malformed inbound tool_choice missing + // function.name must not be converted into an Anthropic tool_choice with + // no name (JSON.stringify would silently drop the undefined key, + // producing `{"type":"tool"}`, which Anthropic also rejects) — forward + // the original, still-invalid shape unchanged instead. + it('forwards a malformed tool_choice unchanged instead of manufacturing a nameless Anthropic tool_choice', async () => { + registerSpy(OAuthAuthManager.getInstance(), 'getApiKey').mockResolvedValue('mock-oauth-key'); + + vi.mocked(piAi.complete).mockResolvedValue({ + role: 'assistant', + content: [{ type: 'text', text: 'hello oauth' }], + provider: 'anthropic', + model: 'claude-3-5-sonnet-20241022', + usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + stopReason: 'stop', + timestamp: Date.now(), + } as any); + + const usageStorage = createUsageStorage(); + await runPiAiExecutor({ + requestId: 'req-tool-choice-malformed', + incomingApiType: 'messages', + modelAlias: 'claude-3-5-sonnet-20241022', + context: { messages: [] } as any, + generationIntent: { reasoning: { source: 'client' } } as any, + toolChoice: { type: 'function', function: {} }, + streaming: false, + request: { + body: {}, + keyName: 'beta-key', + attribution: 'opencode', + ip: '127.0.0.1', + } as any, + usageStorage, + serializeMessage: (msg) => msg as any, + serializeChunks: () => [], + }); + + const [, , callOptions] = vi.mocked(piAi.complete).mock.calls[0]!; + expect((callOptions as any).toolChoice).toEqual({ type: 'function', function: {} }); + }); }); describe('runPiAiExecutor Claude-masking streaming tool-rename reversal (regression)', () => { diff --git a/packages/backend/src/inference-v2/shared/fetch-tap.ts b/packages/backend/src/inference-v2/shared/fetch-tap.ts index 663d88db..2d8d6aaf 100644 --- a/packages/backend/src/inference-v2/shared/fetch-tap.ts +++ b/packages/backend/src/inference-v2/shared/fetch-tap.ts @@ -13,10 +13,39 @@ * - For non-streaming: clone + buffer the full response body. * - For streaming: wrap via TransformStream (NOT tee() — avoids upstream backpressure). * - installFetchTap() is idempotent — safe to call multiple times. + * + * Server-tool block capture (web_search etc.): + * + * pi-ai's Anthropic response parser only recognises `text`, `thinking`, + * `redacted_thinking`, and `tool_use` content blocks — it silently drops + * `server_tool_use` / `web_search_tool_result` (Anthropic's built-in + * server-side tool blocks, e.g. web search). Since pi-ai never surfaces them + * on AssistantMessage, this tap also reconstructs them directly from the raw + * upstream bytes so the beta Anthropic response serialiser can splice them + * back into the client-facing response. See pi-ai-executor.ts's onPayload + * for where `watchForServerToolBlocks()` is armed, and + * context-to-anthropic.ts for where `consumeServerToolBlocks()` is spliced in. + * + * Streaming capture correctness: extraction happens inside the + * TransformStream's `transform()` callback (per chunk), NOT in `flush()`. + * Bytes must pass through `transform()` before the downstream reader + * (pi-ai) can observe them, so by construction our extraction of a given + * `content_block_stop` always completes before pi-ai's own parser could + * possibly react to that same byte — no race. `flush()` only handles the + * defensive case of a block left open by a truncated stream. + * + * Non-streaming capture correctness: unlike the debug-only clone (which is + * fire-and-forget since it doesn't gate anything), the server-tool-block + * clone is awaited before tappedFetch returns the Response, so extraction + * is guaranteed to finish before pi-ai's caller (runPiAiExecutor) reads the + * body. This adds latency only to requests that actually armed the watch + * (i.e. sent an Anthropic builtin tool). */ +import { createParser, type EventSourceMessage } from 'eventsource-parser'; import { DebugManager } from '../../services/debug-manager'; import { debugRequestIdStorage } from './pi-ai-executor'; +import { ANTHROPIC_SERVER_TOOL_BLOCK_TYPES } from '../anthropic/builtin-tools'; let installed = false; const originalFetch = globalThis.fetch; @@ -38,6 +67,145 @@ export function consumeTtfb(requestId: string): number | null { return v; } +// ─── Server-tool block capture ───────────────────────────────────────────── + +/** RequestIds whose next upstream response should be scanned for server tool blocks. */ +const serverToolWatchSet = new Set(); + +/** Captured blocks per requestId, consumed once by the response serialiser. */ +const serverToolBlocksMap = new Map(); + +/** + * 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; +} + +/** Extracts server tool blocks from a non-streaming Anthropic JSON response body. */ +function extractServerToolBlocksFromJson(text: string): any[] { + try { + const parsed = JSON.parse(text); + const content = Array.isArray(parsed?.content) ? parsed.content : []; + return content.filter((b: any) => b && ANTHROPIC_SERVER_TOOL_BLOCK_TYPES.has(b.type)); + } catch { + return []; + } +} + +interface ServerToolBlockEntry { + block: Record; + partialJson: string; +} + +interface ServerToolBlockAccumulator { + requestId: string; + decoder: TextDecoder; + /** Handles SSE framing (event boundaries, CRLF, multi-line data). */ + parser: ReturnType; + /** Blocks currently open (content_block_start seen, content_block_stop not yet seen), by SSE index. */ + active: Map; +} + +function createServerToolBlockAccumulator(requestId: string): ServerToolBlockAccumulator { + const acc: ServerToolBlockAccumulator = { + requestId, + decoder: new TextDecoder(), + parser: undefined as unknown as ReturnType, + active: new Map(), + }; + acc.parser = createParser({ + onEvent: (event: EventSourceMessage) => processServerToolSseEvent(acc, event), + }); + return acc; +} + +/** + * Finalizes a block and appends it to `serverToolBlocksMap` immediately — + * NOT batched until stream end. This must happen synchronously within the + * same `transform()` call that observed `content_block_stop`, which by + * construction runs strictly before pi-ai's downstream reader can observe + * that (or any later) chunk. Deferring this to `flush()` would race pi-ai's + * own parser: `flush()` only fires at full-stream EOF, but pi-ai can emit + * its `done` event as soon as it reads the chunk containing `message_stop`, + * which is earlier. + */ +function finalizeServerToolBlock( + acc: ServerToolBlockAccumulator, + entry: ServerToolBlockEntry +): void { + if (entry.partialJson) { + try { + entry.block.input = JSON.parse(entry.partialJson); + } catch { + // Keep whatever `input`/`content` content_block_start already provided. + } + } + const existing = serverToolBlocksMap.get(acc.requestId) ?? []; + existing.push(entry.block); + serverToolBlocksMap.set(acc.requestId, existing); +} + +/** + * Handles one parsed SSE event. `createParser` invokes this synchronously + * during `feed()`, so a `content_block_stop`'s finalize lands in + * serverToolBlocksMap within the same `transform()` call that fed its bytes — + * preserving the streaming-correctness guarantee in the module doc. + */ +function processServerToolSseEvent( + acc: ServerToolBlockAccumulator, + event: EventSourceMessage +): void { + let evt: any; + try { + evt = JSON.parse(event.data); + } catch { + return; + } + + if (evt.type === 'content_block_start') { + const cb = evt.content_block; + if (cb && ANTHROPIC_SERVER_TOOL_BLOCK_TYPES.has(cb.type)) { + acc.active.set(evt.index, { block: { ...cb }, partialJson: '' }); + } + } else if (evt.type === 'content_block_delta') { + const entry = acc.active.get(evt.index); + if (entry && evt.delta?.type === 'input_json_delta') { + entry.partialJson += evt.delta.partial_json ?? ''; + } + } else if (evt.type === 'content_block_stop') { + const entry = acc.active.get(evt.index); + if (entry) { + finalizeServerToolBlock(acc, entry); + acc.active.delete(evt.index); + } + } +} + +/** Feeds one raw chunk into the SSE parser; complete events fire synchronously via onEvent. */ +function feedServerToolBlockAccumulator(acc: ServerToolBlockAccumulator, chunk: Uint8Array): void { + acc.parser.feed(acc.decoder.decode(chunk, { stream: true })); +} + +/** Defensive: finalize any block left open by a truncated/aborted stream. */ +function flushServerToolBlockAccumulator(acc: ServerToolBlockAccumulator): void { + for (const entry of acc.active.values()) { + finalizeServerToolBlock(acc, entry); + } + acc.active.clear(); +} + export function installFetchTap(): void { if (installed) return; installed = true; @@ -60,14 +228,19 @@ export function installFetchTap(): void { ttfbMap.set(requestId, ttfb); const debug = DebugManager.getInstance(); - if (!debug.isEnabled() && !debug.isEnabledForKey(requestId)) return response; + const debugEnabled = debug.isEnabled() || debug.isEnabledForKey(requestId); + const watchingServerTools = serverToolWatchSet.has(requestId); - // Capture response status and headers - const responseHeaders: Record = {}; - response.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); - debug.addResponseMeta(requestId, response.status, responseHeaders); + if (!debugEnabled && !watchingServerTools) return response; + + if (debugEnabled) { + // Capture response status and headers + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + debug.addResponseMeta(requestId, response.status, responseHeaders); + } // Determine if the response is streaming (Transfer-Encoding: chunked or no Content-Length) const isStream = @@ -79,42 +252,73 @@ export function installFetchTap(): void { if (!response.body) return response; if (!isStream) { - // Non-streaming: clone and buffer + // Non-streaming: clone and buffer. When server-tool capture is armed, + // extraction is awaited before returning so it completes before the + // executor reads the (separate, un-cloned) body it gets back. const cloned = response.clone(); - cloned - .text() - .then((text) => { - debug.addRawResponse(requestId, text); - debug.addReconstructedRawResponse(requestId, text); - }) - .catch(() => { + const textPromise = cloned.text(); + if (debugEnabled) { + textPromise + .then((text) => { + debug.addRawResponse(requestId, text); + debug.addReconstructedRawResponse(requestId, text); + }) + .catch(() => { + /* non-fatal */ + }); + } + if (watchingServerTools) { + try { + const text = await textPromise; + const blocks = extractServerToolBlocksFromJson(text); + if (blocks.length > 0) serverToolBlocksMap.set(requestId, blocks); + } catch { /* non-fatal */ - }); + } + } return response; } // Streaming: wrap body in a TransformStream to copy chunks without backpressure const chunks: Uint8Array[] = []; + const serverToolAcc = watchingServerTools ? createServerToolBlockAccumulator(requestId) : null; const ts = new TransformStream({ transform(chunk, controller) { - chunks.push(chunk); + if (debugEnabled) chunks.push(chunk); + if (serverToolAcc) { + try { + // Finalizes any completed blocks into serverToolBlocksMap synchronously, + // strictly before this chunk (or any later one) reaches pi-ai's reader. + feedServerToolBlockAccumulator(serverToolAcc, chunk); + } catch { + /* non-fatal */ + } + } controller.enqueue(chunk); }, flush() { - // On stream completion, decode the accumulated chunks and record - try { - const full = new TextDecoder().decode( - chunks.reduce((acc, c) => { - const merged = new Uint8Array(acc.length + c.length); - merged.set(acc); - merged.set(c, acc.length); - return merged; - }, new Uint8Array()) - ); - debug.addRawResponse(requestId, full); - debug.addReconstructedRawResponse(requestId, full); - } catch { - /* non-fatal */ + if (debugEnabled) { + // On stream completion, decode the accumulated chunks and record + try { + const full = new TextDecoder().decode( + chunks.reduce((acc, c) => { + const merged = new Uint8Array(acc.length + c.length); + merged.set(acc); + merged.set(c, acc.length); + return merged; + }, new Uint8Array()) + ); + debug.addRawResponse(requestId, full); + debug.addReconstructedRawResponse(requestId, full); + } catch { + /* non-fatal */ + } + } + // Defensive only: finalizes any block left open by a truncated/aborted + // stream. In the normal case all blocks were already finalized by + // transform() above, since content_block_stop always precedes stream EOF. + if (serverToolAcc) { + flushServerToolBlockAccumulator(serverToolAcc); } }, }); diff --git a/packages/backend/src/inference-v2/shared/pi-ai-executor.ts b/packages/backend/src/inference-v2/shared/pi-ai-executor.ts index 34286c48..f095518f 100644 --- a/packages/backend/src/inference-v2/shared/pi-ai-executor.ts +++ b/packages/backend/src/inference-v2/shared/pi-ai-executor.ts @@ -61,7 +61,7 @@ import { import { OAuthAuthManager } from '../../services/oauth-auth-manager'; import type { GenerationIntent } from './generation'; import { splitReasoningSuffix } from './reasoning'; -import { consumeTtfb } from './fetch-tap'; +import { consumeTtfb, consumeServerToolBlocks, watchForServerToolBlocks } from './fetch-tap'; import { extractPiAiErrorMessage } from '../../transformers/oauth/type-mappers'; import { applyClaudeCodeMasking, @@ -111,6 +111,15 @@ export interface PiAiExecutorInput { toolsDefined?: number; /** Number of non-system messages (forwarded from parser result) */ messageCount?: number; + /** + * Anthropic built-in server-side tool declarations (e.g. web_search_20250305) + * extracted verbatim by the inbound parser — see anthropic-to-context.ts. + * pi-ai's Tool type can't represent these (no `type` field, always emits a + * function-tool schema), so they're re-injected directly into the outgoing + * payload here, bypassing pi-ai's tool serialization for just these entries. + * Only applied when the resolved pi-ai provider is 'anthropic'. + */ + builtinTools?: any[]; /** Converts a final AssistantMessage to the wire-format response object */ serializeMessage: (msg: AssistantMessage) => TResponse; /** Converts one AssistantMessageEvent to zero or more SSE/NDJSON frame strings */ @@ -445,6 +454,44 @@ function buildUsageFromMessage( }; } +// ─── tool_choice provider-shape conversion ─────────────────────────────────── + +/** + * Every inbound parser (openai-to-context.ts, responses-to-context.ts, + * anthropic-to-context.ts) normalizes `tool_choice` to OpenAI's shape — + * a string (`"auto"`/`"none"`/`"required"`) or `{ type: "function", function: + * { name } }` — since pi-ai's own OpenAI/Chat-Completions provider expects + * exactly that shape verbatim (see `@earendil-works/pi-ai` + * `api/openai-completions.js`). But pi-ai's Anthropic provider does NOT + * convert `options.toolChoice` for us — `api/anthropic-messages.js` only + * wraps bare strings in `{ type: ... }` and otherwise forwards objects + * as-is, assuming they're already Anthropic-shaped + * (`{ type: "tool", name }` / `{ type: "auto" | "any" | "none" }`). Left + * unconverted, the OpenAI-shaped object round-trips straight through to + * Anthropic's API, which rejects `type: "function"` with a 400 + * invalid_request_error — this must be converted here, right before + * dispatch, once the resolved provider is known. + */ +function toAnthropicToolChoice(choice: unknown): unknown { + if (choice == null) return choice; + if (typeof choice === 'string') { + // OpenAI's "required" has no literal Anthropic equivalent; Anthropic's + // closest semantic match is "any" (force some tool call). + return choice === 'required' ? 'any' : choice; + } + if (typeof choice === 'object' && (choice as any).type === 'function') { + const name = (choice as any).function?.name; + // Malformed inbound tool_choice (missing function.name) — fall through + // to forwarding it as-is rather than manufacturing an Anthropic + // tool_choice with no name, which Anthropic would reject anyway (JSON. + // stringify drops the undefined key entirely). + if (typeof name === 'string' && name.length > 0) { + return { type: 'tool', name }; + } + } + return choice; +} + // ─── Assistant provenance alignment (signature replay) ────────────────────── /** @@ -542,6 +589,7 @@ export async function runPiAiExecutor( onSuccess, toolsDefined, messageCount, + builtinTools, serializeMessage, serializeChunks, } = input; @@ -926,9 +974,12 @@ export async function runPiAiExecutor( // tool-fingerprint/registry.ts. let toolRenamePairs: RenamePair[] = []; + const dispatchToolChoice = + piAiProvider === 'anthropic' ? toAnthropicToolChoice(toolChoice) : toolChoice; + const callOptions: ProviderStreamOptions = { ...generationOpts, - ...(toolChoice != null ? { toolChoice } : {}), + ...(dispatchToolChoice != null ? { toolChoice: dispatchToolChoice } : {}), ...(parallelToolCalls != null ? { parallelToolCalls } : {}), apiKey, headers: baseHeaders, @@ -955,6 +1006,18 @@ export async function runPiAiExecutor( (piModel as any).__toolRenamePairs = toolRenamePairs; } + // Re-inject Anthropic builtin server-side tools (e.g. web_search_20250305) + // that anthropicRequestToContext() split out because pi-ai's Tool type + // can't represent them. This is independent of the masking branch above + // — it must also apply on the OAuth-without-masking and apiKey paths. + if (piAiProvider === 'anthropic' && builtinTools && builtinTools.length > 0) { + const payloadObj: any = + typeof finalPayload === 'string' ? JSON.parse(finalPayload) : finalPayload; + payloadObj.tools = [...(payloadObj.tools ?? []), ...builtinTools]; + finalPayload = payloadObj; + watchForServerToolBlocks(requestId); + } + const payloadStr = typeof finalPayload === 'string' ? finalPayload : JSON.stringify(finalPayload); logger.debug(`[pi-ai-executor] FULL-OUTGOING-PAYLOAD ${payloadStr}`); @@ -1153,8 +1216,11 @@ export async function runPiAiExecutor( } catch (err: any) { const effectiveErr = attemptTimeout.isTimedOut() ? buildTimeoutError() : err; - // Clean up any TTFB entry that wasn't consumed (non-streaming error path) + // Clean up any TTFB / server-tool-block entries that weren't consumed + // (non-streaming error path) — this attempt's serializeMessage() never + // ran, so nothing else would clear them. consumeTtfb(requestId); + consumeServerToolBlocks(requestId); if (signal?.aborted) { concurrency.release(route.provider, route.model); @@ -1558,5 +1624,9 @@ async function* buildSSEGenerator(p: SSEGeneratorParams): AsyncGenerator } finally { doRelease(); debug.flush(requestId); + // Defensive: if the generator threw before a 'done'/'error' event ever + // reached serializeChunks (e.g. abort/timeout), consumeServerToolBlocks() + // inside eventToAnthropicSSE never ran — clear here so nothing leaks. + consumeServerToolBlocks(requestId); } }