Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -19,6 +35,11 @@ function zeroUsage() {
};
}

afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});

function makeMessage(overrides: Partial<AssistantMessage> = {}): AssistantMessage {
return {
role: 'assistant',
Expand Down Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
}
44 changes: 44 additions & 0 deletions packages/backend/src/inference-v2/anthropic/builtin-tools.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading