From 75a2fcd3de0ea9c91f360f2fa841c2c872f8a5c9 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Fri, 18 Sep 2026 00:44:05 +0800 Subject: [PATCH 1/9] refactor(proxy): rewrite server tools around the declared tool type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classification matched a server tool by name, and `normalizeToolName` strips case and separators — so `WebSearch`, the ordinary function Claude Code declares and resolves itself, compared equal to `web_search`, the provider-executed server tool. The proxy answered Claude Code's own calls, the `tool_use` block it was waiting for never arrived, and the turn ended with an answer invented from memory. Only the declared type can tell them apart. Anthropic sends a dated type (`web_search_20250305`), the Responses API sends `web_search_preview`, and a client's own function is declared as `function` or with no type at all. Classification now reads the type and never the name, and both translators preserve it through to the request that goes upstream. That removes the ambiguity the loop existed to paper over. A request either declares a server tool — in which case one search runs and upstream is asked once more, without the tools, to write the answer — or it does not, and every tool call goes back to the client untouched. So `web-search-loop.ts` and the `server-tool/` module go away, replaced by `server-tools/`, and each route drives the turn itself instead of it hanging off the shared chat chokepoint. The private `x-codebuddy2api-server-tool` marker goes with them: it was the mechanism that carried the conflated identity, and nothing reads it any more. Also excludes `.worktrees/**` from vitest, which was collecting a whole stale copy of the suite out of every git worktree. --- lib/server/proxy/anthropic.ts | 112 +- lib/server/proxy/anthropic/request.ts | 59 +- lib/server/proxy/anthropic/response.ts | 66 +- lib/server/proxy/anthropic/stream.ts | 212 +- lib/server/proxy/codebuddy.ts | 252 +- lib/server/proxy/codebuddy/server-tools.ts | 239 - lib/server/proxy/codebuddy/types.ts | 15 - lib/server/proxy/image-generation.ts | 21 +- lib/server/proxy/responses.ts | 89 +- lib/server/proxy/responses/event-stream.ts | 276 +- lib/server/proxy/responses/payload.ts | 2 +- lib/server/proxy/responses/stream.ts | 2 +- lib/server/proxy/responses/tools.ts | 22 +- lib/server/proxy/responses/types.ts | 15 +- lib/server/proxy/server-tool/classify.ts | 243 - lib/server/proxy/server-tool/execution.ts | 146 - lib/server/proxy/server-tool/stream.ts | 230 - lib/server/proxy/server-tool/turns.ts | 205 - lib/server/proxy/server-tool/types.ts | 129 - .../{server-tool => server-tools}/args.ts | 0 lib/server/proxy/server-tools/classify.ts | 281 + lib/server/proxy/server-tools/execute.ts | 101 + lib/server/proxy/server-tools/index.ts | 41 + .../{server-tool => server-tools}/payload.ts | 49 +- .../{server-tool => server-tools}/sse.ts | 0 lib/server/proxy/server-tools/turn.ts | 377 + lib/server/proxy/server-tools/types.ts | 143 + lib/server/proxy/web-search-loop.ts | 924 --- lib/server/search/tool.ts | 49 +- lib/server/shared/sse.ts | 12 + tests/server/image-generation.test.ts | 2 +- tests/server/search-providers.test.ts | 2304 +++++++ tests/server/server-tools.test.ts | 2577 ++----- tests/server/units.test.ts | 9 +- tests/server/web-search.test.ts | 6114 +---------------- vitest.config.ts | 4 + 36 files changed, 4637 insertions(+), 10685 deletions(-) delete mode 100644 lib/server/proxy/codebuddy/server-tools.ts delete mode 100644 lib/server/proxy/server-tool/classify.ts delete mode 100644 lib/server/proxy/server-tool/execution.ts delete mode 100644 lib/server/proxy/server-tool/stream.ts delete mode 100644 lib/server/proxy/server-tool/turns.ts delete mode 100644 lib/server/proxy/server-tool/types.ts rename lib/server/proxy/{server-tool => server-tools}/args.ts (100%) create mode 100644 lib/server/proxy/server-tools/classify.ts create mode 100644 lib/server/proxy/server-tools/execute.ts create mode 100644 lib/server/proxy/server-tools/index.ts rename lib/server/proxy/{server-tool => server-tools}/payload.ts (58%) rename lib/server/proxy/{server-tool => server-tools}/sse.ts (100%) create mode 100644 lib/server/proxy/server-tools/turn.ts create mode 100644 lib/server/proxy/server-tools/types.ts delete mode 100644 lib/server/proxy/web-search-loop.ts create mode 100644 tests/server/search-providers.test.ts diff --git a/lib/server/proxy/anthropic.ts b/lib/server/proxy/anthropic.ts index 5e26861..c7dc180 100644 --- a/lib/server/proxy/anthropic.ts +++ b/lib/server/proxy/anthropic.ts @@ -1,15 +1,13 @@ import type { NextRequest } from 'next/server'; import type { DebugTrace } from '../domain/debug'; +import { withCodeBuddyToken } from '../search/token'; import { anthropicErrorType, createAnthropicError, getUpstreamErrorMessage, } from './anthropic/errors'; -import { - buildChatRequestBody, - shouldBridgeAnthropicServerTools, -} from './anthropic/request'; +import { buildChatRequestBody } from './anthropic/request'; import { mapOpenAIResponseToAnthropic } from './anthropic/response'; import { createAnthropicServerToolEventStream, @@ -19,8 +17,17 @@ import type { AnthropicMessagesRequestBody, OpenAIChatResponse, } from './anthropic/types'; -import { proxyChatCompletions, type ChatRequestBody } from './codebuddy'; -import { getServerToolExecutions, getServerToolTurns } from './web-search-loop'; +import { + proxyChatCompletions, + resolveProxyContext, + type ChatRequestBody, + type ProxyContext, +} from './codebuddy'; +import { + hasExecutableServerTool, + prepareServerToolTurn, + runServerToolTurn, +} from './server-tools'; // --------------------------------------------------------------------------- // Main handler @@ -37,23 +44,83 @@ export const handleMessagesRequest = async ( try { const chatBody = await buildChatRequestBody(body); + const model = String(chatBody.model ?? 'unknown'); + + // Classified on the translated tools: the translator keeps a + // provider-executed declaration's type, so `web_search_20250305` is still + // recognisable here, while the client's own `WebSearch` has become an + // ordinary function and is left alone. + const prepared = await prepareServerToolTurn(chatBody.tools); + const rewrite = prepared?.rewrite ?? null; - if (body.stream && (await shouldBridgeAnthropicServerTools(body.tools))) { - return createAnthropicServerToolEventStream( + // The declarations have to be rewritten even when nothing is executed: + // upstream has no server tools, so leaving `web_search_20250305` in the + // request would send a shape it rejects. A declaration the proxy is not + // running becomes an ordinary function, and the call that comes back goes + // to the client. + const upstreamTools = rewrite + ? rewrite.tools + : ((chatBody.tools as unknown[] | undefined) ?? undefined); + + const callUpstream = + (context?: ProxyContext) => + (turnBody: ChatRequestBody, stream: boolean): Promise => + proxyChatCompletions( + request, + { ...turnBody, tools: upstreamTools, stream }, + context, + debugTrace, + '/v1/messages', + ); + + if (rewrite && prepared && hasExecutableServerTool(rewrite.executable)) { + const { fetchProvider, searchProvider } = prepared.providers; + + // Resolved here rather than inside the call so the CodeBuddy backends can + // be scoped to this request's credential: they call the agent-tool + // endpoints with the same token the model call used. + const context = await resolveProxyContext( request, - chatBody, - String(chatBody.model ?? 'unknown'), - debugTrace, + typeof chatBody.model === 'string' ? chatBody.model : undefined, + ); + + const runTurn = () => + withCodeBuddyToken( + () => Promise.resolve(context.auth.bearerToken), + () => + runServerToolTurn({ + body: { ...chatBody, tools: rewrite.tools } as ChatRequestBody, + callUpstream: callUpstream(context), + fetchProvider, + rewrite, + searchProvider, + stream: Boolean(body.stream), + }), + ); + + if (body.stream) { + return createAnthropicServerToolEventStream({ model, runTurn }); + } + + const { executions, preamble, response } = await runTurn(); + + if (!response.ok) { + return createAnthropicError( + response.status, + await getUpstreamErrorMessage(response), + ); + } + + const payload = (await response.json()) as OpenAIChatResponse; + + return Response.json( + mapOpenAIResponseToAnthropic(payload, model, executions, preamble), ); } - const upstreamResponse = await proxyChatCompletions( - request, + const upstreamResponse = await callUpstream()( chatBody as ChatRequestBody, - undefined, - debugTrace, - '/v1/messages', - { findingsAsStructuredBlocks: true }, + Boolean(body.stream), ); if (!upstreamResponse.ok) { @@ -63,22 +130,13 @@ export const handleMessagesRequest = async ( ); } - const model = String(chatBody.model ?? 'unknown'); - const serverToolExecutions = getServerToolExecutions(upstreamResponse); - // Carried beside the response rather than inside it: the OpenAI-shaped - // payload the loop emits must stay protocol-clean for chat-completions - // clients, so this file reads the grouping off the response itself. - const turns = getServerToolTurns(upstreamResponse); - if (body.stream) { return mapOpenAIStreamToAnthropicSSE(upstreamResponse, model); } const payload = (await upstreamResponse.json()) as OpenAIChatResponse; - return Response.json( - mapOpenAIResponseToAnthropic(payload, model, serverToolExecutions, turns), - ); + return Response.json(mapOpenAIResponseToAnthropic(payload, model)); } catch (error) { return createAnthropicError( 500, diff --git a/lib/server/proxy/anthropic/request.ts b/lib/server/proxy/anthropic/request.ts index d4fb6ce..b38b4ae 100644 --- a/lib/server/proxy/anthropic/request.ts +++ b/lib/server/proxy/anthropic/request.ts @@ -1,15 +1,8 @@ -import { - getDefaultModel, - isWebFetchEnabled, - isWebSearchEnabled, -} from '../../domain/config'; +import { getDefaultModel } from '../../domain/config'; import { stringifyContent } from '../../shared/content'; import { - markServerTool, normalizeToolName, - WEB_FETCH_TOOL_NAME, WEB_FETCH_TOOL_TYPE_PREFIX, - WEB_SEARCH_TOOL_NAME, WEB_SEARCH_TOOL_TYPE_PREFIX, } from '../../search/tool'; import { @@ -330,6 +323,19 @@ export const mapAnthropicMessagesToChat = ( return result; }; +/** + * Translates Anthropic tool declarations into the chat shape upstream takes. + * + * A provider-executed declaration — `web_search_20250305`, + * `web_fetch_20250910` — keeps its declared type rather than being flattened to + * `function`. That type is the only thing distinguishing a server tool from the + * client's own function, and Claude Code relies on the difference: it declares + * `WebSearch` as an ordinary function and resolves it itself, so a translation + * that collapsed the two would hand a client-owned tool to the proxy. + * + * Nothing sends the preserved type upstream: a request carrying one is always + * rewritten before it leaves, because upstream has no server tools. + */ export const mapAnthropicToolsToChat = ( tools: AnthropicTool[] | undefined, ): unknown[] | undefined => { @@ -338,42 +344,25 @@ export const mapAnthropicToolsToChat = ( } return tools.map((tool) => { - const mapped = { - type: 'function', + const type = typeof tool.type === 'string' ? tool.type.trim() : ''; + const serverDeclared = [ + WEB_SEARCH_TOOL_TYPE_PREFIX, + WEB_FETCH_TOOL_TYPE_PREFIX, + ].some((prefix) => + normalizeToolName(type).startsWith(normalizeToolName(prefix)), + ); + + return { + type: serverDeclared ? type : 'function', function: { name: tool.name, description: tool.description, parameters: tool.input_schema, }, }; - const normalizedType = normalizeToolName(tool.type ?? ''); - const serverDeclared = [ - WEB_SEARCH_TOOL_TYPE_PREFIX, - WEB_FETCH_TOOL_TYPE_PREFIX, - ].some((prefix) => normalizedType.startsWith(normalizeToolName(prefix))); - - return serverDeclared ? markServerTool(mapped) : mapped; }); }; -export const shouldBridgeAnthropicServerTools = async ( - tools: AnthropicTool[] | undefined, -): Promise => { - const names = new Set( - (tools ?? []).map((tool) => normalizeToolName(tool.name)), - ); - const [searchEnabled, fetchEnabled] = await Promise.all([ - names.has(normalizeToolName(WEB_SEARCH_TOOL_NAME)) - ? isWebSearchEnabled() - : false, - names.has(normalizeToolName(WEB_FETCH_TOOL_NAME)) - ? isWebFetchEnabled() - : false, - ]); - - return searchEnabled || fetchEnabled; -}; - export const mapAnthropicToolChoiceToChat = (toolChoice: unknown): unknown => { if (!toolChoice || typeof toolChoice !== 'object') { return toolChoice; diff --git a/lib/server/proxy/anthropic/response.ts b/lib/server/proxy/anthropic/response.ts index d4c57a3..c69e02c 100644 --- a/lib/server/proxy/anthropic/response.ts +++ b/lib/server/proxy/anthropic/response.ts @@ -4,7 +4,7 @@ import type { OpenAIChatResponse, OpenAIUsage, } from './types'; -import type { ServerToolExecution, ServerToolTurn } from '../web-search-loop'; +import type { ServerToolExecution, ServerToolPreamble } from '../server-tools'; // --------------------------------------------------------------------------- // Response translation: OpenAI → Anthropic (non-streaming) @@ -121,38 +121,30 @@ export const buildThinkingBlock = ( }); /** - * Lays a server-tool turn out the way Anthropic does: each hop contributes its - * own thinking and text, followed by the tool blocks that hop triggered. + * Lays a server-tool turn out the way Anthropic does: what the model wrote + * before the search, the search itself, then the answer the results produced. * - * `turns` carries the per-hop grouping the OpenAI-shaped payload cannot. Under - * that protocol a multi-hop turn collapses into one `content` string and one - * `reasoning_content` string, which loses where one hop's reasoning ends and the - * next begins — so the grouping has to be recovered before it is joined, which - * is why the loop emits it alongside the strings rather than this file - * reconstructing it. - * - * Anthropic's own server tools run multiple hops inside one assistant message, - * and a client replaying that message expects `[thinking] [text] [tool_use] - * [tool_result] [thinking] [text]`. Gathering the blocks by kind instead — every - * tool ahead of all the prose — puts each search before the reasoning that asked - * for it and merges hops that were never contiguous. + * The order is the whole point. A client replays this content array as the + * assistant turn, and Anthropic's own server tools interleave — `[thinking] + * [text] [server_tool_use] [web_search_tool_result] [text]` — so gathering the + * blocks by kind instead would show every search ahead of the reasoning that + * asked for it, and put the conclusion before its evidence. */ -export const buildAnthropicTurnBlocks = ( - turns: ServerToolTurn[], +export const buildAnthropicServerToolTurnBlocks = ( + preamble: ServerToolPreamble, + executions: ServerToolExecution[], ): AnthropicContentBlock[] => { const blocks: AnthropicContentBlock[] = []; - turns.forEach((turn) => { - if (turn.reasoning) { - blocks.push(buildThinkingBlock(turn.reasoning)); - } + if (preamble.reasoning) { + blocks.push(buildThinkingBlock(preamble.reasoning)); + } - if (turn.text) { - blocks.push({ type: 'text', text: turn.text }); - } + if (preamble.text) { + blocks.push({ type: 'text', text: preamble.text }); + } - blocks.push(...buildAllAnthropicServerToolBlocks(turn.executions)); - }); + blocks.push(...buildAllAnthropicServerToolBlocks(executions)); return blocks; }; @@ -161,7 +153,7 @@ export const mapOpenAIResponseToAnthropic = ( openaiResponse: OpenAIChatResponse, model: string, serverToolExecutions: ServerToolExecution[] = [], - turns?: ServerToolTurn[], + preamble?: ServerToolPreamble, ): Record => { const choice = openaiResponse.choices?.[0]; const message = choice?.message; @@ -173,15 +165,11 @@ export const mapOpenAIResponseToAnthropic = ( const textContent = typeof message?.content === 'string' ? message.content : ''; - // With per-hop grouping the turns already hold every block in order, prose - // included. Without it — no server tool ran, or a path that never grouped the - // hops — fall back to Anthropic's own order: thinking and text first, then - // the server-tool blocks they led to. - const contentBlocks: AnthropicContentBlock[] = turns - ? buildAnthropicTurnBlocks(turns) + const contentBlocks: AnthropicContentBlock[] = preamble + ? buildAnthropicServerToolTurnBlocks(preamble, serverToolExecutions) : []; - if (!turns) { + if (!preamble) { if (reasoningText) { contentBlocks.push(buildThinkingBlock(reasoningText)); } @@ -193,6 +181,16 @@ export const mapOpenAIResponseToAnthropic = ( contentBlocks.push( ...buildAllAnthropicServerToolBlocks(serverToolExecutions), ); + } else { + // The closing half of the turn: the answer written once the results were + // in. It follows the tool blocks above rather than preceding them. + if (reasoningText) { + contentBlocks.push(buildThinkingBlock(reasoningText)); + } + + if (textContent) { + contentBlocks.push({ type: 'text', text: textContent }); + } } // Tool calls diff --git a/lib/server/proxy/anthropic/stream.ts b/lib/server/proxy/anthropic/stream.ts index 7e729fa..c12d806 100644 --- a/lib/server/proxy/anthropic/stream.ts +++ b/lib/server/proxy/anthropic/stream.ts @@ -1,13 +1,14 @@ -import type { NextRequest } from 'next/server'; - -import type { DebugTrace } from '../../domain/debug'; -import { createSseResponse } from '../../shared/sse'; +import { createSseResponse, isEventStream } from '../../shared/sse'; import { anthropicStreamErrorChunks, createStreamCloser, toUpstreamTimeoutMessage, } from '../../shared/upstream-timeout'; -import { proxyChatCompletions, type ChatRequestBody } from '../codebuddy'; +import { + type ChatCompletionPayload, + type ServerToolTurnOutcome, + synthesizeChatCompletionStream, +} from '../server-tools'; import { createAnthropicId } from './content'; import { anthropicErrorType, getUpstreamErrorMessage } from './errors'; import { @@ -22,11 +23,8 @@ import type { OpenAIUsage, StreamingToolUseState, } from './types'; -import { - getServerToolExecutions, - getServerToolStreamEvent, - type ServerToolExecution, -} from '../web-search-loop'; +import type { ServerToolExecution } from '../server-tools'; +import { getServerToolExecutions } from '../server-tools'; // --------------------------------------------------------------------------- // Response translation: OpenAI SSE → Anthropic SSE (streaming) @@ -52,7 +50,6 @@ export const mapOpenAIStreamToAnthropicSSE = ( const messageId = options?.messageId ?? createAnthropicId('msg'); const serverToolExecutions = options?.serverToolExecutions ?? getServerToolExecutions(upstreamResponse); - const serverToolUseIds = new Map(); const toolUseStates = new Map(); let nextToolIndex = 0; let started = options?.emitMessageStart === false; @@ -372,59 +369,8 @@ export const mapOpenAIStreamToAnthropicSSE = ( try { const chunk = JSON.parse(raw) as OpenAIStreamChunk; - const serverToolEvent = getServerToolStreamEvent(chunk); - - if (serverToolEvent?.phase === 'call') { - closeOpenTextBlocks(); - const index = contentBlockCount++; - const toolUseId = createAnthropicId('srvtoolu'); - serverToolUseIds.set(serverToolEvent.invocation.id, toolUseId); - enqueueEvent({ - type: 'content_block_start', - index, - content_block: { - type: 'server_tool_use', - id: toolUseId, - name: serverToolEvent.invocation.type, - input: {}, - }, - }); - enqueueEvent({ - type: 'content_block_delta', - index, - delta: { - type: 'input_json_delta', - partial_json: JSON.stringify( - serverToolEvent.invocation.input, - ), - }, - }); - enqueueEvent({ type: 'content_block_stop', index }); - continue; - } - - if (serverToolEvent?.phase === 'result') { - closeOpenTextBlocks(); - serverToolExecutions.push(serverToolEvent.execution); - const index = contentBlockCount++; - const resultBlock = buildAnthropicServerToolBlocks( - serverToolEvent.execution, - )[1]; - enqueueEvent({ - type: 'content_block_start', - index, - content_block: { - ...resultBlock, - tool_use_id: serverToolUseIds.get( - serverToolEvent.execution.id, - ), - }, - }); - enqueueEvent({ type: 'content_block_stop', index }); - continue; - } - const upstreamError = chunk as OpenAIStreamError; + if (upstreamError.error?.message) { rejectStream( upstreamError.error.message, @@ -511,12 +457,28 @@ export const mapOpenAIStreamToAnthropicSSE = ( return createSseResponse(stream, { status: 200 }); }; -export const createAnthropicServerToolEventStream = ( - request: NextRequest, - chatBody: Record, - model: string, - debugTrace?: DebugTrace, -): Response => { +/** + * Streams a turn in which a server tool runs. + * + * `message_start` goes out before the first upstream call, so the client gets + * headers and a message id immediately rather than after the search completes. + * The blocks that follow are the ones Anthropic's own server tools produce, in + * the order they produce them: whatever the model said before searching, the + * `server_tool_use` and `web_search_tool_result` pairs, then the answer the + * results produced — which arrives from upstream as an ordinary stream and is + * mapped by the normal path. + */ +export const createAnthropicServerToolEventStream = ({ + model, + runTurn, +}: { + model: string; + /** + * Runs the server-tool turn. Provided by the caller because only it knows + * which backends are configured and how to reach upstream for this request. + */ + runTurn: () => Promise; +}): Response => { const encoder = new TextEncoder(); const messageId = createAnthropicId('msg'); let activeReader: ReadableStreamDefaultReader | null = null; @@ -533,6 +495,18 @@ export const createAnthropicServerToolEventStream = ( ); }; + const emitBlock = ( + index: number, + contentBlock: Record, + ): void => { + enqueueEvent({ + content_block: contentBlock, + index, + type: 'content_block_start', + }); + enqueueEvent({ type: 'content_block_stop', index }); + }; + enqueueEvent({ type: 'message_start', message: { @@ -553,55 +527,93 @@ export const createAnthropicServerToolEventStream = ( }); const run = async (): Promise => { - const upstreamResponse = await proxyChatCompletions( - request, - chatBody as ChatRequestBody, - undefined, - debugTrace, - '/v1/messages', - // The findings reach the client as `web_search_tool_result` blocks, - // so the loop must not also fold them into the assistant text. - { emitStreamEvents: true, findingsAsStructuredBlocks: true }, - ); + const { executions, preamble, response } = await runTurn(); if (cancelled) { - await upstreamResponse.body?.cancel(); + await response.body?.cancel(); return; } - if (!upstreamResponse.ok || !upstreamResponse.body) { + let index = 0; + + // What the model wrote before it reached for the tool. Anthropic puts + // this ahead of the `server_tool_use` block, and a client replaying the + // turn expects it there. + if (preamble.reasoning) { + emitBlock(index++, { + type: 'thinking', + thinking: preamble.reasoning, + }); + } + + if (preamble.text) { + emitBlock(index++, { type: 'text', text: preamble.text }); + } + + for (const execution of executions) { + const toolUseId = createAnthropicId('srvtoolu'); + const [toolUse, result] = buildAnthropicServerToolBlocks(execution); + + enqueueEvent({ + type: 'content_block_start', + index, + content_block: { ...toolUse, id: toolUseId }, + }); + enqueueEvent({ + type: 'content_block_delta', + index, + delta: { + type: 'input_json_delta', + partial_json: JSON.stringify(execution.input), + }, + }); + enqueueEvent({ type: 'content_block_stop', index }); + index++; + + emitBlock(index++, { ...result, tool_use_id: toolUseId }); + } + + // Emitted after the blocks rather than instead of them: a search + // that already ran is work the client has paid for, and it is the + // only record of what happened when the answer never arrives. + if (!response.ok) { // A rate limit has to arrive as `rate_limit_error`, or a client that // retries on that type alone will treat an exhausted quota as a // generic failure and stop retrying — so the upstream status drives // the event type even though the envelope is already streaming and // the HTTP status cannot be changed. - const message = upstreamResponse.ok - ? 'Upstream request failed' - : await getUpstreamErrorMessage(upstreamResponse).catch( - () => 'Upstream request failed', - ); + const message = await getUpstreamErrorMessage(response).catch( + () => 'Upstream request failed', + ); enqueueEvent({ type: 'error', - error: { - type: anthropicErrorType(upstreamResponse.status), - message, - }, + error: { type: anthropicErrorType(response.status), message }, }); controller.close(); return; } - const mappedResponse = mapOpenAIStreamToAnthropicSSE( - upstreamResponse, - model, - { - emitMessageStart: false, - initialContentBlockCount: 0, - messageId, - serverToolExecutions: [], - }, - ); + // A buffered answer has to be replayed as SSE: the client asked to + // stream, and the turn spent the response reading the tool calls. + const upstream = isEventStream(response) + ? response + : synthesizeChatCompletionStream( + (await response.json()) as ChatCompletionPayload, + model, + ); + + if (cancelled) { + await upstream.body?.cancel(); + return; + } + + const mappedResponse = mapOpenAIStreamToAnthropicSSE(upstream, model, { + emitMessageStart: false, + initialContentBlockCount: index, + messageId, + serverToolExecutions: executions, + }); const reader = mappedResponse.body!.getReader(); activeReader = reader; diff --git a/lib/server/proxy/codebuddy.ts b/lib/server/proxy/codebuddy.ts index 02f3476..7d1dbde 100644 --- a/lib/server/proxy/codebuddy.ts +++ b/lib/server/proxy/codebuddy.ts @@ -14,12 +14,7 @@ import { mapResponsesPayloadToChat, mapResponsesStreamToChat, } from './codebuddy/responses-response'; -import { detectServerToolStream } from './codebuddy/server-tools'; -import { - SERVER_WEB_TOOL_NAMES, - type ChatRequestBody, - type ProxyContext, -} from './codebuddy/types'; +import type { ChatRequestBody, ProxyContext } from './codebuddy/types'; import { extractResponsesId, extractResponsesUsage, @@ -45,165 +40,27 @@ import { getCodeBuddyApiEndpoint, getDefaultModel, } from '../domain/config'; -import { withCodeBuddyToken } from '../search/token'; -import { - normalizeToolName, - WEB_FETCH_TOOL_NAME, - WEB_SEARCH_TOOL_NAME, -} from '../search/tool'; import { createErrorResponse } from '../shared/http'; import { fetchWithDeadline } from '../shared/upstream-timeout'; -import { - attachServerToolExecutions, - attachServerToolTurns, - type ChatCompletionPayload, - executeWebSearchLoop, - type ServerToolCallbacks, - synthesizeChatCompletionStream, -} from './web-search-loop'; /** - * One round trip to `/v2/chat/completions`. Split out from - * `proxyChatCompletions` so the local web search loop can re-issue the request - * with tool results appended without re-deriving auth, headers, or usage - * recording on each iteration. + * One round trip to `/v2/chat/completions`. * * Upstream is always asked to stream; `stream` only controls the shape handed * back to the caller, so it must echo what the client asked for rather than * being read off `upstreamBody`. + * + * Server tools are not handled here. The routes that support them drive this + * function themselves — they need to see the tool calls before deciding whether + * to ask again — and a request with no server tool declared never reaches the + * question. */ -const fetchChatCompletion = async ({ - body, - debugTrace, - request, - resolvedContext, - stream, - upstreamBody: providedUpstreamBody, - usageRoute, -}: { - body: ChatRequestBody; - debugTrace?: DebugTrace; - request: NextRequest; - resolvedContext: ProxyContext; - /** - * Whether the caller wants an SSE stream back. Upstream is always asked to - * stream regardless — it rejects `stream: false` with code 11101 — so this - * only chooses between passing the stream through and buffering it into a - * single JSON payload. - */ - stream: boolean; - upstreamBody?: ChatRequestBody; - usageRoute: string; -}): Promise => { - const apiEndpoint = await getCodeBuddyApiEndpoint(); - const upstreamUrl = `${apiEndpoint}/v2/chat/completions`; - const upstreamHeaders = await buildUpstreamHeaders( - request, - resolvedContext.auth, - ); - const upstreamBody = - providedUpstreamBody ?? (await buildUpstreamBody(body, resolvedContext)); - - setDebugUpstreamRequest(debugTrace, { - body: upstreamBody, - headers: headersToRecord(upstreamHeaders), - method: 'POST', - url: upstreamUrl, - }); - - const upstream = await fetchWithDeadline({ - body: JSON.stringify(upstreamBody), - headers: upstreamHeaders, - onTimeout: (error) => setDebugTraceError(debugTrace, error), - timeoutMs: await getApiFirstDeltaTimeoutMs(), - url: upstreamUrl, - }); - - if (!upstream.ok) { - return upstream.response; - } - - const upstreamResponse = enqueueUpstreamResponseSnapshot( - debugTrace, - upstream.response, - ); - - if (!upstreamResponse.ok) { - const detail = await upstreamResponse.text(); - logUpstreamFailure({ - detail, - route: '/v1/chat/completions', - status: upstreamResponse.status, - url: upstreamUrl, - }); - setDebugTraceError(debugTrace, detail); - return createErrorResponse( - upstreamResponse.status, - 'Upstream CodeBuddy request failed', - detail, - ); - } - - const contentType = upstreamResponse.headers.get('content-type') ?? ''; - - if (stream && !contentType.toLowerCase().includes('application/json')) { - return normalizeStreamingResponse({ - model: String(upstreamBody.model ?? 'unknown'), - proxyContext: resolvedContext, - route: usageRoute, - upstreamResponse, - }); - } - - // Upstream only accepts `stream: true`, so a non-streaming caller is served - // by buffering the SSE response and folding it into a single JSON payload. - if (contentType.toLowerCase().includes('application/json')) { - const payloadText = await upstreamResponse.text(); - let usage: unknown = null; - - try { - usage = (JSON.parse(payloadText) as { usage?: unknown }).usage ?? null; - } catch { - usage = null; - } - - await recordProxyUsage({ - model: String(upstreamBody.model ?? 'unknown'), - proxyContext: resolvedContext, - route: usageRoute, - usage, - }); - - return new Response(payloadText, { - status: upstreamResponse.status, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - }, - }); - } - - const aggregated = await aggregateUpstreamStream( - upstreamResponse, - String(upstreamBody.model ?? 'unknown'), - ); - - await recordProxyUsage({ - model: aggregated.model, - proxyContext: resolvedContext, - route: usageRoute, - usage: aggregated.usage, - }); - - return aggregated.response; -}; - export const proxyChatCompletions = async ( request: NextRequest, body: ChatRequestBody, context?: ProxyContext, debugTrace?: DebugTrace, usageRoute = '/v1/chat/completions', - serverToolCallbacks?: ServerToolCallbacks, ): Promise => { if (!body.messages?.length) { return createErrorResponse(400, 'messages is required'); @@ -213,100 +70,7 @@ export const proxyChatCompletions = async ( const resolvedContext = context ?? (await resolveProxyContext(request, body.model)); setDebugTraceCredential(debugTrace, resolvedContext.credentialFilename); - let upstreamBody = await buildUpstreamBody(body, resolvedContext); - - // Server-side web tools run on the chat path only. The Responses - // passthrough path forwards to CodeBuddy's own /responses endpoint, where - // re-issuing a request with a synthesized tool result would mean replaying - // the whole conversation through a different protocol for each iteration. - if (resolvedContext.preferences.upstreamProtocol === 'chat') { - const webSearch = await withCodeBuddyToken( - // The CodeBuddy backends call the agent-tool endpoints with the same - // credential as this request, so the loop is scoped to it. Resolved - // lazily: a token is only needed when a CodeBuddy backend actually runs. - () => Promise.resolve(resolvedContext.auth.bearerToken), - () => - executeWebSearchLoop({ - body: upstreamBody, - callbacks: serverToolCallbacks, - callUpstream: async (loopBody, mode) => { - const upstreamResponse = await fetchChatCompletion({ - body: loopBody, - debugTrace, - request, - resolvedContext, - // Probe the first meaningful SSE delta for streaming callers. - // Ordinary content stays live; only a server-tool call is - // buffered into a payload the execution loop can inspect. - stream: mode !== 'buffer', - // Already normalized, so pass it straight through; re-running - // buildUpstreamBody each iteration would re-apply prompt cache - // markers to the appended tool results. - upstreamBody: { ...loopBody, stream: true }, - usageRoute, - }); - - const detectedNames = - mode === 'detect-both' - ? SERVER_WEB_TOOL_NAMES - : mode === 'detect-search' - ? [normalizeToolName(WEB_SEARCH_TOOL_NAME)] - : [normalizeToolName(WEB_FETCH_TOOL_NAME)]; - - if (mode === 'stream' || mode === 'buffer') { - return upstreamResponse; - } - - return detectServerToolStream( - upstreamResponse, - String(loopBody.model ?? 'unknown'), - detectedNames, - ); - }, - detectInitialStream: Boolean(body.stream), - }), - ); - - // No tool could be executed, so the loop declined to run. Its rewritten - // `tools` still matter: unsupported server-tool declarations have been - // stripped, and continuing with them keeps the request valid upstream - // instead of forwarding a declaration it would reject. - if (webSearch && !webSearch.response) { - upstreamBody = webSearch.body; - } - - if (webSearch?.response) { - // The hop grouping rides beside the response rather than inside its - // body, so the OpenAI-shaped payload a chat client receives stays - // protocol-clean. See `attachServerToolTurns`. - const attachServerToolResult = (response: Response): Response => - attachServerToolTurns( - attachServerToolExecutions(response, webSearch.executions), - webSearch.turns, - ); - - if (!webSearch.response.ok) { - return attachServerToolResult(webSearch.response); - } - - if ( - body.stream && - !webSearch.response.headers - .get('content-type') - ?.toLowerCase() - .includes('text/event-stream') - ) { - return attachServerToolResult( - synthesizeChatCompletionStream( - (await webSearch.response.json()) as ChatCompletionPayload, - String(upstreamBody.model ?? 'unknown'), - ), - ); - } - - return attachServerToolResult(webSearch.response); - } - } + const upstreamBody = await buildUpstreamBody(body, resolvedContext); if (resolvedContext.preferences.upstreamProtocol === 'responses') { const unsupportedOptions = getUnsupportedResponsesChatOptions(body); diff --git a/lib/server/proxy/codebuddy/server-tools.ts b/lib/server/proxy/codebuddy/server-tools.ts deleted file mode 100644 index a8e6b66..0000000 --- a/lib/server/proxy/codebuddy/server-tools.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { - normalizeToolName, - WEB_FETCH_TOOL_NAME, - WEB_SEARCH_TOOL_NAME, -} from '../../search/tool'; -import { aggregateUpstreamStream } from './chat-stream'; -import { type ChatStreamChunk, type StreamProbeState } from './types'; - -export const isServerWebToolName = (name: string): boolean => { - const normalized = normalizeToolName(name); - - return ( - normalized === normalizeToolName(WEB_SEARCH_TOOL_NAME) || - normalized === normalizeToolName(WEB_FETCH_TOOL_NAME) - ); -}; - -export const mergeToolName = (previous: string, incoming: string): string => { - if (!previous || incoming.startsWith(previous)) { - return incoming; - } - - if (!incoming || previous.endsWith(incoming)) { - return previous; - } - - return previous + incoming; -}; - -export const classifyStreamFrame = ( - frame: string, - state: StreamProbeState, - serverToolNames: string[], -): 'passthrough' | 'server-tool' | null => { - const line = frame - .split('\n') - .find((segment) => segment.startsWith('data: ')); - - if (!line) { - return null; - } - - const raw = line.slice(6).trim(); - - if (!raw || raw === '[DONE]') { - return raw === '[DONE]' ? 'passthrough' : null; - } - - try { - const chunk = JSON.parse(raw) as ChatStreamChunk; - - for (const choice of chunk.choices ?? []) { - const delta = choice.delta; - const toolCalls = delta?.tool_calls ?? []; - let hasNonServerTool = false; - - for (const [position, toolCall] of toolCalls.entries()) { - const incoming = toolCall.function?.name; - - if (typeof incoming !== 'string' || !incoming) { - continue; - } - - const key = - typeof toolCall.index === 'number' - ? `index:${toolCall.index}` - : toolCall.id - ? `id:${toolCall.id}` - : `position:${position}`; - const name = mergeToolName(state.toolNames.get(key) ?? '', incoming); - const normalized = normalizeToolName(name); - - state.toolNames.set(key, name); - - if (isServerWebToolName(name) && serverToolNames.includes(normalized)) { - return 'server-tool'; - } - - if ( - normalized && - !serverToolNames.some((serverName) => - serverName.startsWith(normalized), - ) - ) { - hasNonServerTool = true; - } - } - - if ( - delta?.content || - delta?.reasoning_content || - delta?.reasoning || - hasNonServerTool || - choice.finish_reason != null - ) { - return 'passthrough'; - } - } - } catch { - return null; - } - - return null; -}; - -export const concatenateChunks = (chunks: Uint8Array[]): ArrayBuffer => { - const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); - const combined = new Uint8Array(size); - let offset = 0; - - for (const chunk of chunks) { - combined.set(chunk, offset); - offset += chunk.byteLength; - } - - return combined.buffer; -}; - -export const createResponseWithBody = ( - body: BodyInit, - response: Response, -): Response => - new Response(body, { - headers: response.headers, - status: response.status, - statusText: response.statusText, - }); - -export const createReplayStreamResponse = ({ - chunks, - reader, - response, -}: { - chunks: Uint8Array[]; - reader: ReadableStreamDefaultReader; - response: Response; -}): Response => { - let cancelled = false; - - const stream = new ReadableStream({ - start: (controller) => { - for (const chunk of chunks) { - controller.enqueue(chunk); - } - - const pump = async (): Promise => { - while (true) { - const { done, value } = await reader.read(); - - if (cancelled) { - return; - } - - if (done) { - reader.releaseLock(); - controller.close(); - return; - } - - controller.enqueue(value); - } - }; - - void pump().catch((error) => { - if (!cancelled) { - controller.error(error); - } - }); - }, - async cancel(reason): Promise { - cancelled = true; - try { - await reader.cancel(reason); - } finally { - reader.releaseLock(); - } - }, - }); - - return createResponseWithBody(stream, response); -}; - -export const detectServerToolStream = async ( - response: Response, - fallbackModel: string, - serverToolNames: string[], -): Promise => { - if (!response.body) { - return response; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - const chunks: Uint8Array[] = []; - const state: StreamProbeState = { toolNames: new Map() }; - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - - if (done) { - reader.releaseLock(); - return createResponseWithBody(concatenateChunks(chunks), response); - } - - chunks.push(value); - buffer += decoder.decode(value, { stream: true }); - const frames = buffer.split('\n\n'); - buffer = frames.pop() ?? ''; - - for (const frame of frames) { - const classification = classifyStreamFrame(frame, state, serverToolNames); - - if (classification === 'passthrough') { - return createReplayStreamResponse({ chunks, reader, response }); - } - - if (classification === 'server-tool') { - while (true) { - const remainder = await reader.read(); - - if (remainder.done) { - reader.releaseLock(); - break; - } - - chunks.push(remainder.value); - } - - return ( - await aggregateUpstreamStream( - new Response(concatenateChunks(chunks)), - fallbackModel, - ) - ).response; - } - } - } -}; diff --git a/lib/server/proxy/codebuddy/types.ts b/lib/server/proxy/codebuddy/types.ts index c79a1a9..00682e9 100644 --- a/lib/server/proxy/codebuddy/types.ts +++ b/lib/server/proxy/codebuddy/types.ts @@ -1,9 +1,3 @@ -import { - normalizeToolName, - WEB_FETCH_TOOL_NAME, - WEB_SEARCH_TOOL_NAME, -} from '../../search/tool'; - /** * Chat completions are called from browser-side clients as well as servers, so * this route's streams carry the CORS header the other protocols do not need. @@ -126,12 +120,3 @@ export interface DiscoveredModel { displayName: string; id: string; } - -export const SERVER_WEB_TOOL_NAMES = [ - normalizeToolName(WEB_SEARCH_TOOL_NAME), - normalizeToolName(WEB_FETCH_TOOL_NAME), -]; - -export interface StreamProbeState { - toolNames: Map; -} diff --git a/lib/server/proxy/image-generation.ts b/lib/server/proxy/image-generation.ts index a6d473e..5b1e19f 100644 --- a/lib/server/proxy/image-generation.ts +++ b/lib/server/proxy/image-generation.ts @@ -23,13 +23,13 @@ import { getCodeBuddyApiEndpoint } from '../domain/config'; import type { ProxyContext } from './codebuddy'; import { buildUpstreamHeaders } from './codebuddy'; import { + foldIntermediateTexts, getServerToolExecutions, - withIntermediateTurns, type ChatCompletionMessage, type ChatCompletionPayload, type ChatCompletionToolCall, type ServerToolExecution, -} from './web-search-loop'; +} from './server-tools'; export const IMAGE_GENERATION_TOOL_TYPE = 'image_generation'; @@ -489,12 +489,7 @@ export const executeImageGenerationLoop = async ({ executions, response: rebuildResponse( response, - withIntermediateTurns({ - executions: [], - payload, - reasonings: [], - texts: intermediateTexts, - }).payload, + foldIntermediateTexts(payload, intermediateTexts), ), serverToolExecutions, }; @@ -559,12 +554,10 @@ export const executeImageGenerationLoop = async ({ executions, response: rebuildResponse( lastResponse ?? new Response(null, { status: 502 }), - withIntermediateTurns({ - executions: [], - payload: clearClosingHop(lastPayload ?? {}), - reasonings: [], - texts: intermediateTexts, - }).payload, + foldIntermediateTexts( + clearClosingHop(lastPayload ?? {}), + intermediateTexts, + ), ), serverToolExecutions, }; diff --git a/lib/server/proxy/responses.ts b/lib/server/proxy/responses.ts index 0bd4641..032333f 100644 --- a/lib/server/proxy/responses.ts +++ b/lib/server/proxy/responses.ts @@ -16,6 +16,7 @@ import type { NextRequest } from 'next/server'; import { getDefaultModel } from '../domain/config'; import { getCredentialSupportedModels } from '../domain/credentials'; import type { DebugTrace } from '../domain/debug'; +import { withCodeBuddyToken } from '../search/token'; import { createErrorResponse } from '../shared/http'; import { resolveRequestAccessKey } from './auth'; import { @@ -41,7 +42,12 @@ import { } from './responses/tools'; import { prepareTranscript } from './responses/transcript'; import type { ResponsesRequestBody } from './responses/types'; -import { getServerToolExecutions } from './web-search-loop'; +import { + getServerToolExecutions, + hasExecutableServerTool, + prepareServerToolTurn, + runServerToolTurn, +} from './server-tools'; export const handleResponsesRequest = async ( request: NextRequest, @@ -165,6 +171,19 @@ export const handleResponsesRequest = async ( ); } + const translatedTools = translateResponsesToolsToChat( + prepared.defaults.tools, + ); + + // Classified on the translated tools, which keep a provider-executed + // declaration's type. A client's own function — including one named + // `web_search` — arrives as `function` and stays the client's to resolve. + const serverTools = await prepareServerToolTurn(translatedTools); + const rewrite = serverTools?.rewrite ?? null; + const willRunServerTool = Boolean( + rewrite && hasExecutableServerTool(rewrite.executable), + ); + const chatBody = { model: prepared.model, messages: [ @@ -178,13 +197,56 @@ export const handleResponsesRequest = async ( ], max_tokens: body.max_output_tokens, stream: false, - tools: translateResponsesToolsToChat(prepared.defaults.tools), + // Rewritten even when nothing will be executed: upstream has no server + // tools, so a declared type would be a shape it rejects. + tools: rewrite ? rewrite.tools : translatedTools, tool_choice: translateResponsesToolChoiceToChatWithTools( prepared.defaults.tools, prepared.defaults.tool_choice, ), }; + /** + * One hop upstream, running any server tool the model asks for on the way. + * + * Both branches are needed because the turn only exists when something is + * executable; otherwise the request goes upstream as it stands, with every + * tool call coming back to the client. + */ + const callUpstream = async ( + loopBody: Record, + stream: boolean, + ): Promise => + willRunServerTool && rewrite + ? ( + await withCodeBuddyToken( + () => Promise.resolve(proxyContext.auth.bearerToken), + () => + runServerToolTurn({ + body: loopBody as never, + callUpstream: (turnBody, turnStream) => + proxyChatCompletions( + request, + { ...turnBody, stream: turnStream } as never, + proxyContext, + debugTrace, + '/v1/responses', + ), + fetchProvider: serverTools!.providers.fetchProvider, + rewrite, + searchProvider: serverTools!.providers.searchProvider, + stream, + }), + ) + ).response + : proxyChatCompletions( + request, + { ...loopBody, stream } as never, + proxyContext, + debugTrace, + '/v1/responses', + ); + // Image generation has no chat-protocol equivalent, so the model's call is // executed here and replayed with the image folded in. Only meaningful when // the tool was actually declared; otherwise the loop returns null and the @@ -196,14 +258,9 @@ export const handleResponsesRequest = async ( serverToolExecutions, } = await executeImageGenerationLoop({ body: chatBody, - callUpstream: (loopBody) => - proxyChatCompletions( - request, - loopBody as never, - proxyContext, - debugTrace, - '/v1/responses', - ), + // Buffered so the tool call can be inspected before any delta reaches + // the client. Any server tool the hop asked for runs inside this call. + callUpstream: (loopBody) => callUpstream(loopBody, false), context: proxyContext, request, }); @@ -236,13 +293,7 @@ export const handleResponsesRequest = async ( ); } - const upstreamResponse = await proxyChatCompletions( - request, - chatBody as never, - proxyContext, - debugTrace, - '/v1/responses', - ); + const upstreamResponse = await callUpstream(chatBody, false); if (!upstreamResponse.ok) { return upstreamResponse; @@ -252,6 +303,10 @@ export const handleResponsesRequest = async ( string, unknown >; + // Read off the response rather than returned by the call: a turn rebuilds + // the response, and the image-generation loop above drives upstream itself, + // so a returned field would have to be threaded through every layer in + // between. const serverToolExecutions = getServerToolExecutions(upstreamResponse); return Response.json( diff --git a/lib/server/proxy/responses/event-stream.ts b/lib/server/proxy/responses/event-stream.ts index 0e3a3ba..cc6b11f 100644 --- a/lib/server/proxy/responses/event-stream.ts +++ b/lib/server/proxy/responses/event-stream.ts @@ -8,13 +8,8 @@ import type { NextRequest } from 'next/server'; -import { isWebFetchEnabled, isWebSearchEnabled } from '../../domain/config'; import type { DebugTrace } from '../../domain/debug'; -import { - normalizeToolName, - WEB_FETCH_TOOL_NAME, - WEB_SEARCH_TOOL_NAME, -} from '../../search/tool'; +import { withCodeBuddyToken } from '../../search/token'; import { createSseResponse, encodeDoneFrame } from '../../shared/sse'; import { proxyChatCompletions, type ProxyContext } from '../codebuddy'; import { executeImageGenerationLoop } from '../image-generation'; @@ -36,6 +31,11 @@ import type { ResponseSessionDefaults, TranscriptMessage, } from './types'; +import { + hasExecutableServerTool, + prepareServerToolTurn, + runServerToolTurn, +} from '../server-tools'; export const createResponsesEventStream = async ( request: NextRequest, @@ -48,21 +48,15 @@ export const createResponsesEventStream = async ( debugTrace?: DebugTrace, ): Promise => { const translatedTools = translateResponsesToolsToChat(defaults.tools); - const translatedToolNames = new Set( - ( - (translatedTools ?? []) as Array<{ - function: { name: string }; - }> - ).map((tool) => normalizeToolName(tool.function.name)), + + // Classified on the translated tools, which keep a provider-executed + // declaration's type. A client's own function — including one named + // `web_search` — arrives as `function` and is left to the client. + const prepared = await prepareServerToolTurn(translatedTools); + const rewrite = prepared?.rewrite ?? null; + const willRunServerTool = Boolean( + rewrite && hasExecutableServerTool(rewrite.executable), ); - const [searchEnabled, fetchEnabled] = await Promise.all([ - translatedToolNames.has(normalizeToolName(WEB_SEARCH_TOOL_NAME)) - ? isWebSearchEnabled() - : false, - translatedToolNames.has(normalizeToolName(WEB_FETCH_TOOL_NAME)) - ? isWebFetchEnabled() - : false, - ]); const chatBody = { model, @@ -74,13 +68,56 @@ export const createResponsesEventStream = async ( ], max_tokens: maxOutputTokens, stream: true, - tools: translatedTools, + // Rewritten even when nothing will be executed: upstream has no server + // tools, so a declared type would be a shape it rejects. + tools: rewrite ? rewrite.tools : translatedTools, tool_choice: translateResponsesToolChoiceToChatWithTools( defaults.tools, defaults.tool_choice, ), }; + /** + * One hop upstream, running any server tool the model asks for on the way. + * + * The image loop drives upstream itself, so the turn has to be reachable from + * here too — a hop can ask for an image and a search at once, and the search + * still has to run. + */ + const callUpstream = async ( + loopBody: Record, + stream: boolean, + ): Promise => + willRunServerTool && rewrite + ? ( + await withCodeBuddyToken( + () => Promise.resolve(proxyContext.auth.bearerToken), + () => + runServerToolTurn({ + body: loopBody as never, + callUpstream: (turnBody, turnStream) => + proxyChatCompletions( + request, + { ...turnBody, stream: turnStream } as never, + proxyContext, + debugTrace, + '/v1/responses', + ), + fetchProvider: prepared!.providers.fetchProvider, + rewrite, + searchProvider: prepared!.providers.searchProvider, + stream, + }), + ) + ).response + : proxyChatCompletions( + request, + { ...loopBody, stream } as never, + proxyContext, + debugTrace, + '/v1/responses', + ); + // Image generation is executed locally, so a streaming request has to be // buffered first to see whether the model asked for an image. Without this // the call is forwarded as an ordinary function_call the client is expected @@ -94,15 +131,10 @@ export const createResponsesEventStream = async ( await executeImageGenerationLoop({ body: chatBody, // Buffered so the tool call can be inspected before any delta reaches - // the client; the ordinary path below stays live. - callUpstream: (loopBody) => - proxyChatCompletions( - request, - { ...loopBody, stream: false } as never, - proxyContext, - debugTrace, - '/v1/responses', - ), + // the client; the ordinary path below stays live. Any server tool the + // hop asked for runs inside this call, and its lifecycle is replayed + // from `serverToolExecutions` rather than announced live. + callUpstream: (loopBody) => callUpstream(loopBody, false), context: proxyContext, request, }); @@ -126,17 +158,17 @@ export const createResponsesEventStream = async ( ); } - if (!searchEnabled && !fetchEnabled) { - const upstreamResponse = await proxyChatCompletions( - request, - chatBody as never, - proxyContext, - debugTrace, - '/v1/responses', - ); - + // Nothing local to run: the request goes upstream as it stands and every tool + // call comes back to the client. + if (!willRunServerTool) { return mapChatStreamToResponsesEventStream( - upstreamResponse, + await proxyChatCompletions( + request, + chatBody as never, + proxyContext, + debugTrace, + '/v1/responses', + ), defaults, transcript, model, @@ -183,96 +215,94 @@ export const createResponsesEventStream = async ( }); const run = async (): Promise => { - const upstreamResponse = await proxyChatCompletions( - request, - { - model, - messages: [ - ...(defaults.instructions - ? [{ role: 'system', content: defaults.instructions }] - : []), - ...normalizeTranscriptMessageToolNames( - transcript, - defaults.tools, - ), - ], - max_tokens: maxOutputTokens, - stream: true, - tools: translatedTools, - tool_choice: translateResponsesToolChoiceToChatWithTools( - defaults.tools, - defaults.tool_choice, - ), - }, - proxyContext, - debugTrace, - '/v1/responses', - { - emitStreamEvents: true, - onCall: (invocation) => { - const outputIndex = allocateOutputIndex(); - const id = `ws_${crypto.randomUUID().replaceAll('-', '')}`; - const item = { - completed: buildResponsesWebSearchCallItem( - invocation, - 'completed', - id, + const { fetchProvider, searchProvider } = prepared!.providers; + + const { response } = await withCodeBuddyToken( + () => Promise.resolve(proxyContext.auth.bearerToken), + () => + runServerToolTurn({ + body: chatBody as never, + callUpstream: (body, stream) => + proxyChatCompletions( + request, + { ...body, stream } as never, + proxyContext, + debugTrace, + '/v1/responses', ), - inProgress: buildResponsesWebSearchCallItem( - invocation, - 'in_progress', + fetchProvider, + onCall: (invocation) => { + const outputIndex = allocateOutputIndex(); + const id = `ws_${crypto.randomUUID().replaceAll('-', '')}`; + const item = { + completed: buildResponsesWebSearchCallItem( + invocation, + 'completed', + id, + ), + inProgress: buildResponsesWebSearchCallItem( + invocation, + 'in_progress', + id, + ), + outputIndex, + }; + serverToolItems.push(item); + itemsByInvocationId.set(invocation.id, item); + enqueueEvent({ + type: 'response.output_item.added', + item: item.inProgress, + output_index: outputIndex, + response_id: responseId, + }); + enqueueEvent({ + type: 'response.web_search_call.in_progress', + item_id: id, + output_index: outputIndex, + }); + enqueueEvent({ + type: 'response.web_search_call.searching', + item_id: id, + output_index: outputIndex, + }); + }, + onResult: (execution) => { + const item = itemsByInvocationId.get(execution.id); + + if (!item) { + return; + } + + const id = String(item.inProgress.id); + item.completed = buildResponsesWebSearchCallItem( + execution, + 'completed', id, - ), - outputIndex, - }; - serverToolItems.push(item); - itemsByInvocationId.set(invocation.id, item); - enqueueEvent({ - type: 'response.output_item.added', - item: item.inProgress, - output_index: outputIndex, - response_id: responseId, - }); - enqueueEvent({ - type: 'response.web_search_call.in_progress', - item_id: id, - output_index: outputIndex, - }); - enqueueEvent({ - type: 'response.web_search_call.searching', - item_id: id, - output_index: outputIndex, - }); - }, - onResult: (execution) => { - const item = itemsByInvocationId.get(execution.id)!; - const id = String(item.inProgress.id); - item.completed = buildResponsesWebSearchCallItem( - execution, - 'completed', - id, - ); - enqueueEvent({ - type: 'response.web_search_call.completed', - item_id: id, - output_index: item.outputIndex, - }); - enqueueEvent({ - type: 'response.output_item.done', - item: item.completed, - output_index: item.outputIndex, - response_id: responseId, - }); - }, - }, + ); + enqueueEvent({ + type: 'response.web_search_call.completed', + item_id: id, + output_index: item.outputIndex, + }); + enqueueEvent({ + type: 'response.output_item.done', + item: item.completed, + output_index: item.outputIndex, + response_id: responseId, + }); + }, + rewrite: rewrite!, + searchProvider, + stream: true, + }), ); if (cancelled) { - await upstreamResponse.body?.cancel(); + await response.body?.cancel(); return; } - if (!upstreamResponse.ok || !upstreamResponse.body) { + if (!response.ok) { enqueueEvent({ type: 'response.error', error: { message: 'Upstream request failed' }, @@ -283,7 +313,7 @@ export const createResponsesEventStream = async ( } const mappedResponse = mapChatStreamToResponsesEventStream( - upstreamResponse, + response, defaults, transcript, model, diff --git a/lib/server/proxy/responses/payload.ts b/lib/server/proxy/responses/payload.ts index c738d54..6bf738f 100644 --- a/lib/server/proxy/responses/payload.ts +++ b/lib/server/proxy/responses/payload.ts @@ -36,7 +36,7 @@ import type { import type { ServerToolExecution, ServerToolInvocation, -} from '../web-search-loop'; +} from '../server-tools'; export const mapChatResponseToResponsesPayload = async ( accessKeyId: string | null, diff --git a/lib/server/proxy/responses/stream.ts b/lib/server/proxy/responses/stream.ts index 1e490db..6f8543e 100644 --- a/lib/server/proxy/responses/stream.ts +++ b/lib/server/proxy/responses/stream.ts @@ -41,7 +41,7 @@ import type { StreamingToolCallState, TranscriptMessage, } from './types'; -import { getServerToolExecutions } from '../web-search-loop'; +import { getServerToolExecutions } from '../server-tools'; import { MAX_RESPONSE_SESSION_TOTAL_BYTES } from './session'; const MAX_STREAM_BUFFER_LENGTH = 1_000_000; diff --git a/lib/server/proxy/responses/tools.ts b/lib/server/proxy/responses/tools.ts index 0124d22..b8dd969 100644 --- a/lib/server/proxy/responses/tools.ts +++ b/lib/server/proxy/responses/tools.ts @@ -6,7 +6,6 @@ import { createErrorResponse } from '../../shared/http'; import { buildWebFetchToolDefinition, buildWebSearchToolDefinition, - markServerTool, normalizeToolName, WEB_FETCH_TOOL_NAME, WEB_FETCH_TOOL_TYPE_PREFIX, @@ -129,9 +128,13 @@ export const toSupportedChatTool = ( const toolType = typeof tool.type === 'string' ? tool.type : 'function'; // Server-side search and fetch carry no function schema, so the generic - // branch below drops them. Emit them as functions unconditionally and let - // the proxy loop resolve the configured backend asynchronously. SearXNG - // needs local configuration, while CodeBuddy search does not. + // branch below drops them. Emit them as functions unconditionally and let the + // route resolve the configured backend asynchronously. SearXNG needs local + // configuration, while CodeBuddy search does not. + // + // The declared type is kept, not flattened: it is the only thing that + // distinguishes a provider-executed tool from the client's own function, and + // a client's own `web_search` must stay the client's to resolve. if ( normalizeToolName(toolType).startsWith( normalizeToolName(WEB_SEARCH_TOOL_TYPE_PREFIX), @@ -144,7 +147,7 @@ export const toSupportedChatTool = ( chatName: WEB_SEARCH_TOOL_NAME, kind: 'function', originalName: WEB_SEARCH_TOOL_NAME, - serverDeclared: true, + serverType: toolType, tool: definition, }, ]; @@ -165,7 +168,7 @@ export const toSupportedChatTool = ( chatName: WEB_FETCH_TOOL_NAME, kind: 'function', originalName: WEB_FETCH_TOOL_NAME, - serverDeclared: true, + serverType: toolType, tool: definition, }, ]; @@ -181,7 +184,6 @@ export const toSupportedChatTool = ( chatName: IMAGE_GENERATION_CHAT_TOOL_NAME, kind: 'function' as const, originalName: IMAGE_GENERATION_TOOL_TYPE, - serverDeclared: true, tool: buildImageGenerationChatTool(), }, ]; @@ -389,9 +391,11 @@ export const translateResponsesToolsToChat = ( return supported.map((tool) => { return { - type: 'function', + // A provider-executed declaration keeps its declared type so it is still + // recognisable downstream. Everything else is an ordinary function, which + // upstream understands. + type: tool.serverType ?? 'function', function: tool.tool, - ...(tool.serverDeclared ? markServerTool({}) : {}), }; }); }; diff --git a/lib/server/proxy/responses/types.ts b/lib/server/proxy/responses/types.ts index 83632de..6dd1f99 100644 --- a/lib/server/proxy/responses/types.ts +++ b/lib/server/proxy/responses/types.ts @@ -31,15 +31,16 @@ export interface SupportedChatTool { namespace?: string; originalName: string; /** - * True when the client declared this as a provider-executed server tool - * (`web_search_20260209`, `web_fetch_20250910`, `web_search_preview`) rather - * than as its own function. + * The type the client declared this tool with, when it asked the *provider* + * to run it (`web_search_preview`, `web_fetch_20250910`) rather than + * declaring a function of its own. * - * Translation turns both into ordinary functions for upstream, so without this - * the proxy cannot tell them apart later — and the difference decides whether a - * tool that cannot be executed is dropped or forwarded. + * Translation keeps that type on the chat tool instead of flattening it to + * `function`, which is what lets the proxy recognise a provider-executed + * declaration after translation. A client's own function — including one + * named `web_search` — arrives as `function` and is left to the client. */ - serverDeclared?: boolean; + serverType?: string; serverLabel?: string; tool: Record; } diff --git a/lib/server/proxy/server-tool/classify.ts b/lib/server/proxy/server-tool/classify.ts deleted file mode 100644 index 8fce0ac..0000000 --- a/lib/server/proxy/server-tool/classify.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { asRecord } from '../../shared/content'; -import { - buildWebFetchToolDefinition, - buildWebSearchToolDefinition, - isMarkedServerTool, - normalizeToolName, - stripServerToolMarker, - WEB_FETCH_TOOL_NAME, - WEB_FETCH_TOOL_TYPE_PREFIX, - WEB_SEARCH_TOOL_NAME, - WEB_SEARCH_TOOL_TYPE_PREFIX, -} from '../../search/tool'; -import type { WebFetchProvider, WebSearchProvider } from '../../search/types'; -import type { ChatCompletionToolCall } from './types'; - -/** - * Recognises one server-tool declaration. - * - * Anthropic sends dated server-tool *types* (`web_search_20260209`, - * `web_fetch_20250910`), Responses sends `web_search_preview`, and a client may - * also declare a plain function tool with the bare name for its own purposes. - * All three shapes have to match, because the tool has to be swapped for a - * function upstream can actually call regardless of how it arrived. - * - * Returns two independent answers. `matches` says the declaration is one this - * proxy can serve; `serverDeclared` says it arrived as a provider-executed - * server tool rather than as the client's own function. The difference decides - * what happens when the tool cannot be executed: a server-tool declaration is - * dropped, because upstream has no idea what to do with it, whereas the - * client's own function is left exactly as sent — the client is the one that - * resolves it, and deleting it would silently remove a capability the client - * asked for. - */ -export const classifyServerTool = ( - tool: unknown, - name: string, - prefix: string, -): { matches: boolean; serverDeclared: boolean } => { - const record = asRecord(tool); - - if (!record) { - return { matches: false, serverDeclared: false }; - } - - const type = typeof record.type === 'string' ? record.type : ''; - - // A dedicated server-tool type (`web_search_20260209`, `web_fetch_20250910`, - // `web_search_preview`) is unambiguous: only a provider-executed tool is - // declared that way. The trailing date is part of the version, not the name, - // so the prefix is matched in canonical form — `WebFetch_20250910` arrives - // from upstream as readily as its snake_case spelling. - if (normalizeToolName(type).startsWith(normalizeToolName(prefix))) { - return { matches: true, serverDeclared: true }; - } - - const fn = asRecord(record.function); - const isBareName = - (typeof fn?.name === 'string' && - normalizeToolName(fn.name) === normalizeToolName(name)) || - (typeof record.name === 'string' && - normalizeToolName(record.name).startsWith(normalizeToolName(prefix))); - - // A Responses translation has already flattened the declaration into a plain - // function, so the type is gone by now; its marker is the only surviving - // evidence that the client asked for a provider-executed tool. - return { - matches: isBareName, - serverDeclared: isBareName && isMarkedServerTool(tool), - }; -}; - -export const isWebSearchTool = (tool: unknown): boolean => - classifyServerTool(tool, WEB_SEARCH_TOOL_NAME, WEB_SEARCH_TOOL_TYPE_PREFIX) - .matches; - -export const isWebFetchTool = (tool: unknown): boolean => - classifyServerTool(tool, WEB_FETCH_TOOL_NAME, WEB_FETCH_TOOL_TYPE_PREFIX) - .matches; - -export const isServerDeclaredSearchTool = (tool: unknown): boolean => - classifyServerTool(tool, WEB_SEARCH_TOOL_NAME, WEB_SEARCH_TOOL_TYPE_PREFIX) - .serverDeclared; - -export const isServerDeclaredFetchTool = (tool: unknown): boolean => - classifyServerTool(tool, WEB_FETCH_TOOL_NAME, WEB_FETCH_TOOL_TYPE_PREFIX) - .serverDeclared; - -/** - * Whether `toolCall` is a call the proxy is meant to execute. - * - * Matched in canonical form because the name comes back from the model, which - * is under no obligation to repeat the spelling it was given: upstream echoes - * `web_fetch` as `WebFetch` often enough to matter here. A miss is not a - * fallback to the client — the call leaves the loop as an unanswered - * client-owned tool, so the fetch silently never happens. - */ -export const isWebSearchToolCall = ( - toolCall: ChatCompletionToolCall, -): boolean => { - return ( - typeof toolCall.function?.name === 'string' && - normalizeToolName(toolCall.function.name) === - normalizeToolName(WEB_SEARCH_TOOL_NAME) - ); -}; - -export const isWebFetchToolCall = ( - toolCall: ChatCompletionToolCall, -): boolean => { - return ( - typeof toolCall.function?.name === 'string' && - normalizeToolName(toolCall.function.name) === - normalizeToolName(WEB_FETCH_TOOL_NAME) - ); -}; - -/** - * Swaps locally executed server-tool declarations for functions upstream can - * call. Passthrough tools keep their upstream representation. - * - * Returns `null` when no web tool is present. `executes` distinguishes a local - * backend from passthrough: the latter still strips the internal provenance - * marker, but never starts the server loop or buffers a stream. - */ -export const replaceServerTools = ({ - fetchEnabled, - fetchProvider, - searchEnabled, - searchPassthrough, - searchProvider, - tools, -}: { - fetchEnabled: boolean; - fetchProvider: WebFetchProvider | null; - searchEnabled: boolean; - searchPassthrough: boolean; - searchProvider: WebSearchProvider | null; - tools: unknown; -}): { - executes: boolean; - /** Canonical names the proxy took over, so call classification can tell its own calls from a client's. */ - ownedNames: Set; - tools: unknown[]; -} | null => { - if (!Array.isArray(tools) || !tools.length) { - return null; - } - - let matched = false; - let executes = false; - // Names the proxy is executing itself. A client may declare its own tool - // under the same name, and the loop must not answer those calls: matching - // the name is not enough to own it. - const ownedNames = new Set(); - - const rewritten = tools.flatMap((tool): unknown[] => { - if (isWebSearchTool(tool)) { - if (searchEnabled && searchProvider) { - matched = true; - executes = true; - ownedNames.add(normalizeToolName(WEB_SEARCH_TOOL_NAME)); - - return [{ type: 'function', function: buildWebSearchToolDefinition() }]; - } - - if (!isServerDeclaredSearchTool(tool)) { - return [tool]; - } - - matched = true; - return searchPassthrough ? [stripServerToolMarker(tool)] : []; - } - - if (isWebFetchTool(tool)) { - // A client-owned function of the same name wins over the backend, exactly - // as it does for search. The backend setting chooses who runs the *proxy's* - // tool; it is not a licence to take over a tool the client declared and - // resolves itself. Without this, a client that ships its own `web_fetch` - // loses it the moment a deployment picks a backend. - if (!isServerDeclaredFetchTool(tool)) { - return [tool]; - } - - if (fetchEnabled && fetchProvider) { - matched = true; - executes = true; - ownedNames.add(normalizeToolName(WEB_FETCH_TOOL_NAME)); - - return [{ type: 'function', function: buildWebFetchToolDefinition() }]; - } - - matched = true; - return [stripServerToolMarker(tool)]; - } - - // The marker is internal to this proxy, so it never reaches upstream. - return [stripServerToolMarker(tool)]; - }); - - return matched ? { executes, ownedNames, tools: rewritten } : null; -}; - -/** - * Whether the proxy is the one meant to answer this call. - * - * A call without an available backend is not a fallback to the client — it - * leaves the loop as an unanswered client-owned tool — but it must not be - * counted as locally executable either. - */ -export const isLocalServerToolCall = ({ - fetchProvider, - ownedNames, - toolCall, - searchProvider, -}: { - fetchProvider: WebFetchProvider | null; - /** - * Canonical names the proxy took over. Without it a client's own tool that - * happens to share a name — `web_fetch`, which is not a server tool in the - * Responses API — gets executed by the loop instead of handed back. - */ - ownedNames?: Set; - toolCall: ChatCompletionToolCall; - searchProvider: WebSearchProvider | null; -}): boolean => - (Boolean(searchProvider) && - isWebSearchToolCall(toolCall) && - isOwned(ownedNames, WEB_SEARCH_TOOL_NAME)) || - (Boolean(fetchProvider) && - isWebFetchToolCall(toolCall) && - isOwned(ownedNames, WEB_FETCH_TOOL_NAME)); - -/** - * Whether the proxy owns calls to `name`. - * - * `undefined` means the caller predates ownership tracking; those callers only - * ever run the proxy's own declarations, so they are unaffected by client tools - * of the same name. - */ -export const isOwned = ( - ownedNames: Set | undefined, - name: string, -): boolean => !ownedNames || ownedNames.has(normalizeToolName(name)); diff --git a/lib/server/proxy/server-tool/execution.ts b/lib/server/proxy/server-tool/execution.ts deleted file mode 100644 index 839d4c8..0000000 --- a/lib/server/proxy/server-tool/execution.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { runWebFetchResult, runWebSearchResult } from '../../search'; -import type { WebFetchProvider, WebSearchProvider } from '../../search/types'; -import { asRecord } from '../../shared/content'; -import { extractFetchQuery, extractSearchQuery } from './args'; -import { isWebFetchToolCall } from './classify'; -import { - SERVER_TOOL_STREAM_EVENT_KEY, - type ChatCompletionToolCall, - type ServerToolCallbacks, - type ServerToolExecution, - type ServerToolInvocation, - type ServerToolStreamEvent, - type ServerToolTurn, -} from './types'; - -export const getServerToolStreamEvent = ( - value: unknown, -): ServerToolStreamEvent | null => { - const record = asRecord(value); - const event = asRecord(record?.[SERVER_TOOL_STREAM_EVENT_KEY]); - - if (event?.phase === 'call' && event.invocation) { - return { - invocation: event.invocation as ServerToolInvocation, - phase: 'call', - }; - } - - if (event?.phase === 'result' && event.execution) { - return { - execution: event.execution as ServerToolExecution, - phase: 'result', - }; - } - - return null; -}; - -const serverToolExecutions = new WeakMap(); - -export const attachServerToolExecutions = ( - response: Response, - executions: ServerToolExecution[], -): Response => { - if (executions.length) { - serverToolExecutions.set(response, executions); - } - - return response; -}; - -export const getServerToolExecutions = ( - response: Response, -): ServerToolExecution[] => serverToolExecutions.get(response) ?? []; - -/** - * Per-hop grouping, kept off the wire for the same reason `executions` is: it - * is not part of the OpenAI protocol, so a `/v1/chat/completions` client must - * not see it — a strict validator can reject the extra field, and the tool - * data would otherwise be sent twice. - */ -const serverToolTurns = new WeakMap(); - -export const attachServerToolTurns = ( - response: Response, - turns: ServerToolTurn[], -): Response => { - if (turns.length) { - serverToolTurns.set(response, turns); - } - - return response; -}; - -export const getServerToolTurns = ( - response: Response, -): ServerToolTurn[] | undefined => serverToolTurns.get(response); - -export const buildServerToolInvocation = ( - toolCall: ChatCompletionToolCall, - iteration: number, - index: number, -): ServerToolInvocation => - isWebFetchToolCall(toolCall) - ? { - id: toolCall.id ?? `server_tool_${iteration}_${index}`, - input: extractFetchQuery(toolCall.function?.arguments), - type: 'web_fetch', - } - : { - id: toolCall.id ?? `server_tool_${iteration}_${index}`, - input: { query: extractSearchQuery(toolCall.function?.arguments) }, - type: 'web_search', - }; - -export const executeServerToolInvocations = async ({ - callbacks, - fetchProvider, - invocations, - searchProvider, -}: { - callbacks?: ServerToolCallbacks; - fetchProvider: WebFetchProvider | null; - invocations: ServerToolInvocation[]; - searchProvider: WebSearchProvider | null; -}): Promise< - Array<{ - content: string; - execution: ServerToolExecution; - tool_call_id: string; - }> -> => { - invocations.forEach((invocation) => callbacks?.onCall?.(invocation)); - - return await Promise.all( - invocations.map(async (invocation) => { - if (invocation.type === 'web_fetch') { - const result = await runWebFetchResult({ - provider: fetchProvider, - query: invocation.input, - }); - const execution: ServerToolExecution = { ...invocation, result }; - callbacks?.onResult?.(execution); - - return { - content: result.content, - execution, - tool_call_id: invocation.id, - }; - } - - const result = await runWebSearchResult({ - provider: searchProvider, - query: invocation.input.query, - }); - const execution: ServerToolExecution = { ...invocation, result }; - callbacks?.onResult?.(execution); - - return { - content: result.content, - execution, - tool_call_id: invocation.id, - }; - }), - ); -}; diff --git a/lib/server/proxy/server-tool/stream.ts b/lib/server/proxy/server-tool/stream.ts deleted file mode 100644 index 8233816..0000000 --- a/lib/server/proxy/server-tool/stream.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { isLocalServerToolCall } from './classify'; -import type { ChatCompletionMessage, ChatCompletionToolCall } from './types'; -import type { WebFetchProvider, WebSearchProvider } from '../../search/types'; - -export const mergeStreamingToolName = ( - previous: string, - incoming: string, -): string => { - if (!previous || incoming.startsWith(previous)) return incoming; - if (!incoming || previous.endsWith(incoming)) return previous; - return previous + incoming; -}; - -export const aggregateStreamingToolCalls = ( - deltas: ChatCompletionToolCall[], -): ChatCompletionToolCall[] => { - const calls = new Map< - string, - ChatCompletionToolCall & { - function: { arguments: string; name: string }; - } - >(); - const latestKeyByIndex = new Map(); - - deltas.forEach((delta, position) => { - const indexedKey = - typeof delta.index === 'number' - ? latestKeyByIndex.get(delta.index) - : undefined; - const key = - indexedKey ?? - (delta.id ? `id:${delta.id}` : undefined) ?? - (typeof delta.index === 'number' - ? `index:${delta.index}` - : `position:${position}`); - const current = calls.get(key) ?? { - function: { arguments: '', name: '' }, - index: delta.index, - }; - - current.id = delta.id ?? current.id; - current.index = delta.index ?? current.index; - current.type = delta.type ?? current.type; - current.function.arguments += delta.function?.arguments ?? ''; - current.function.name = mergeStreamingToolName( - current.function.name, - delta.function?.name ?? '', - ); - calls.set(key, current); - - if (typeof delta.index === 'number') { - latestKeyByIndex.set(delta.index, key); - } - }); - - return [...calls.values()]; -}; - -/** - * Result of streaming one upstream response while watching for server-tool - * calls, so a follow-up iteration can decide what happened. - * - * `localCalls` and `remainingCalls` partition the aggregated tool calls the - * way the execution loop needs them; `frames` are the frames that were held - * back because they carried tool-call deltas. - */ -export interface ServerToolProbe { - content: string; - frames: string[]; - localCalls: ChatCompletionToolCall[]; - reasoning: string; - remainingCalls: ChatCompletionToolCall[]; - role: string; - toolCalls: ChatCompletionToolCall[]; - usage: unknown; -} - -export const probeServerToolStream = async ({ - canContinue, - context, - emitRaw, - fetchProvider, - onReader, - ownedNames, - response, - searchProvider, -}: { - canContinue: () => boolean; - context: { - responseCreated: number; - responseId: string; - responseModel: string; - responseObject: string; - role: string; - usage: unknown; - }; - emitRaw: (frame: string) => void; - fetchProvider: WebFetchProvider | null; - ownedNames?: Set; - /** - * Hands the active reader to the caller's cancellation path. Without it a - * disconnect cannot interrupt a read that is already parked: the loop only - * notices the cancellation once upstream produces another chunk, which a - * stalled upstream never does. - */ - onReader?: (reader: ReadableStreamDefaultReader | null) => void; - response: Response; - searchProvider: WebSearchProvider | null; -}): Promise => { - const frames: string[] = []; - const toolCallDeltas: ChatCompletionToolCall[] = []; - const decoder = new TextDecoder(); - const reader = response.body!.getReader(); - onReader?.(reader); - let buffer = ''; - let content = ''; - let reasoning = ''; - - const inspectFrame = (frame: string): void => { - const line = frame - .split(/\r?\n/) - .find((segment) => segment.startsWith('data:')); - - if (!line) { - emitRaw(frame); - return; - } - - const raw = line.slice(5).trim(); - if (!raw) return; - if (raw === '[DONE]') { - frames.push(frame); - return; - } - - try { - const chunk = JSON.parse(raw) as { - choices?: Array<{ - delta?: ChatCompletionMessage & { - tool_calls?: ChatCompletionToolCall[]; - }; - finish_reason?: string | null; - }>; - created?: number; - id?: string; - model?: string; - object?: string; - usage?: unknown; - }; - context.responseId = chunk.id ?? context.responseId; - context.responseModel = chunk.model ?? context.responseModel; - context.responseObject = - chunk.object?.replace(/\.chunk$/, '') ?? context.responseObject; - context.responseCreated = chunk.created ?? context.responseCreated; - context.usage = chunk.usage ?? context.usage; - const choice = chunk.choices?.[0]; - const delta = choice?.delta; - context.role = delta?.role ?? context.role; - content += delta?.content ?? ''; - reasoning += delta?.reasoning_content ?? delta?.reasoning ?? ''; - - // A tool-call frame is held rather than forwarded: if the turn turns out - // to invoke a server tool, the call has to be answered locally instead - // of being handed to the client as an unresolved call. Anything else the - // delta carried — most importantly the text the model wrote before - // deciding to search — still belongs to the visible turn, so it is - // re-emitted without the tool call. - if (delta?.tool_calls?.length) { - toolCallDeltas.push(...delta.tool_calls); - frames.push(frame); - - const visibleDelta = { ...delta }; - delete visibleDelta.tool_calls; - - if (Object.keys(visibleDelta).length) { - const visible = JSON.stringify({ - ...chunk, - choices: [{ ...choice, delta: visibleDelta, finish_reason: null }], - }); - emitRaw(`data: ${visible}`); - } - return; - } - - if (choice?.finish_reason === 'tool_calls') { - frames.push(frame); - return; - } - } catch { - emitRaw(frame); - return; - } - - emitRaw(frame); - }; - - while (true) { - const chunk = await reader.read(); - if (!canContinue()) break; - if (chunk.done) break; - buffer += decoder.decode(chunk.value, { stream: true }); - const split = buffer.split(/\r?\n\r?\n/); - buffer = split.pop() ?? ''; - split.forEach(inspectFrame); - } - - if (buffer.trim()) inspectFrame(buffer); - reader.releaseLock(); - onReader?.(null); - - const toolCalls = aggregateStreamingToolCalls(toolCallDeltas); - const isLocalCall = (toolCall: ChatCompletionToolCall): boolean => - isLocalServerToolCall({ - fetchProvider, - ownedNames, - searchProvider, - toolCall, - }); - - return { - content, - frames, - localCalls: toolCalls.filter(isLocalCall), - reasoning, - remainingCalls: toolCalls.filter((toolCall) => !isLocalCall(toolCall)), - role: context.role, - toolCalls, - usage: context.usage, - }; -}; diff --git a/lib/server/proxy/server-tool/turns.ts b/lib/server/proxy/server-tool/turns.ts deleted file mode 100644 index 877b9e2..0000000 --- a/lib/server/proxy/server-tool/turns.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { asRecord, readReasoning } from '../../shared/content'; -import type { - ChatCompletionMessage, - ChatCompletionPayload, - ChatCompletionToolCall, - JsonRecord, - ServerToolExecution, - ServerToolTurn, -} from './types'; - -export const sumUsage = (accumulated: unknown, incoming: unknown): unknown => { - const left = asRecord(accumulated); - const right = asRecord(incoming); - - if (!left) { - return incoming ?? null; - } - - if (!right) { - return accumulated; - } - - const merged: JsonRecord = { ...left }; - - for (const [key, value] of Object.entries(right)) { - const previous = left[key]; - - if (typeof value === 'number' && typeof previous === 'number') { - merged[key] = previous + value; - } else if (value !== undefined) { - merged[key] = value; - } - } - - return merged; -}; - -/** - * Folds completed search results into the assistant text and re-emits the - * outstanding tool calls unchanged, so a turn that mixed search with - * client-side calls stays a valid transcript. The client sees its own calls - * come back as if upstream had returned them directly. - * - * The findings are folded only when the route has no other way to carry them. - * A route that renders them structurally passes - * `findingsAsStructuredBlocks`, and the text is left alone: the results are - * already on the wire as a result block, and a second copy in the prose is - * what the user reads as the model reciting its own search output. - */ -export const buildMixedTurnPayload = ({ - findingsAsStructuredBlocks = false, - message, - payload, - remainingCalls, - searchResults, - usage, -}: { - findingsAsStructuredBlocks?: boolean; - message: ChatCompletionMessage | undefined; - payload: ChatCompletionPayload; - remainingCalls: ChatCompletionToolCall[]; - searchResults: string[]; - usage?: unknown; -}): ChatCompletionPayload => { - const existingText = - typeof message?.content === 'string' && message.content.trim() - ? message.content.trim() - : ''; - const findings = findingsAsStructuredBlocks - ? '' - : searchResults.filter(Boolean).join('\n\n'); - const content = [existingText, findings].filter(Boolean).join('\n\n'); - - return { - ...payload, - ...(usage ? { usage } : {}), - choices: (payload.choices ?? []).map((choice, index) => - index === 0 - ? { - ...choice, - finish_reason: 'tool_calls', - message: { - ...(choice.message ?? {}), - content: content || null, - role: 'assistant', - tool_calls: remainingCalls, - }, - } - : choice, - ), - }; -}; - -/** - * Pairs each hop's reasoning and text with the calls that hop made. - * - * The three arrays are index-aligned — entry N is hop N — so zipping them back - * together is what restores the grouping a joined string cannot express. `texts` - * and `reasonings` are one entry longer than `executions`, because the closing - * hop answers instead of calling another tool. - */ -export const buildIntermediateTurns = ({ - executions, - reasonings, - texts, -}: { - executions: ServerToolExecution[][]; - reasonings: string[]; - texts: string[]; -}): ServerToolTurn[] => - Array.from( - { length: Math.max(reasonings.length, texts.length) }, - (_, index) => ({ - // Only `executions` can run short: the closing hop answers without - // calling anything, so it has an entry in the prose arrays but none here. - // The three arrays stay aligned because every hop appends to all of them. - executions: executions[index] ?? [], - reasoning: reasonings[index], - text: texts[index], - }), - ); - -/** - * Folds the text a multi-hop turn produced before its later server-tool calls - * into the payload the client receives. - * - * Only the last iteration's message is in `payload`, but a turn that searched - * more than once spoke before each search, and that text is part of the turn: - * dropping it hides the model's reasoning from the user and leaves the - * client's transcript out of step with what the model actually said. - * - * The same hops are also re-grouped into the `turns` half of the result, - * because the folded strings cannot express where one hop ends and the next - * begins. That half travels beside the response rather than inside it: the - * payload is what an OpenAI-protocol client receives, and `turns` is not part - * of that protocol, so it is handed over out of band like `executions`. - * - * Shared with the image-generation loop, which has the same shape: a local - * tool call is replayed with its result appended, so only the final hop's - * message would otherwise survive. - */ -export const withIntermediateTurns = ({ - executions, - payload, - reasonings, - texts, -}: { - executions: ServerToolExecution[][]; - payload: ChatCompletionPayload; - reasonings: string[]; - texts: string[]; -}): { payload: ChatCompletionPayload; turns: ServerToolTurn[] } => { - const extraText = texts.filter(Boolean).join('\n\n'); - const extraReasoning = reasonings.filter(Boolean).join('\n\n'); - const [first, ...rest] = payload.choices ?? []; - - if (!first) { - return { payload, turns: [] }; - } - - const message = first.message ?? {}; - const existingText = - typeof message.content === 'string' ? message.content : ''; - - // Nothing from the earlier hops and nothing to fold in — but the hops may - // still have run tools, which is exactly the case a caller consuming `turns` - // needs: a hop that called a tool without speaking first is still a hop. - if (!extraText && !extraReasoning && !executions.length) { - return { payload, turns: [] }; - } - - const content = [extraText, existingText].filter(Boolean).join('\n\n'); - const reasoning = [extraReasoning, readReasoning(message)] - .filter(Boolean) - .join('\n\n'); - - return { - payload: { - ...payload, - choices: [ - { - ...first, - message: { - ...message, - content, - ...(reasoning ? { reasoning_content: reasoning } : {}), - }, - }, - ...rest, - ], - }, - // Per-hop grouping for renderers that can express it. The joined strings - // above stay as the OpenAI-shaped view; a client that builds Anthropic - // content blocks needs to know where one hop's reasoning ends and the next - // begins, which a joined string has already lost. The closing hop is the - // model's final answer, so it carries no further calls. - turns: buildIntermediateTurns({ - executions, - reasonings: [...reasonings, readReasoning(message)], - texts: [...texts, existingText], - }), - }; -}; - -export { readReasoning }; diff --git a/lib/server/proxy/server-tool/types.ts b/lib/server/proxy/server-tool/types.ts deleted file mode 100644 index 89c5c97..0000000 --- a/lib/server/proxy/server-tool/types.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { - WebFetchQuery, - WebFetchResponse, - WebSearchResponse, -} from '../../search/types'; -import type { ChatRequestBody } from '../codebuddy'; - -export const MAX_SEARCH_ITERATIONS = 5; -export const STREAM_TEXT_CHUNK_LENGTH = 1024; - -export type JsonRecord = Record; - -export interface ChatCompletionToolCall { - id?: string; - index?: number; - type?: string; - function?: { - arguments?: string; - name?: string; - }; -} - -export interface ChatCompletionMessage { - content?: string | null; - reasoning?: string; - reasoning_content?: string; - role?: string; - tool_calls?: ChatCompletionToolCall[]; -} - -export interface ChatCompletionPayload { - choices?: Array<{ - finish_reason?: string | null; - index?: number; - message?: ChatCompletionMessage; - }>; - created?: number; - /** - * `status` is the upstream HTTP status, carried so a downstream mapper can - * name the real error type instead of guessing it from the message text. It - * is absent for a payload that already reported an error of its own. - */ - error?: { message?: string; status?: number }; - id?: string; - model?: string; - object?: string; - usage?: unknown; -} - -/** - * Result of one server-tool pass. - * - * `response` is null when no tool could be executed: the request still has to - * be sent, but with the server-tool declarations already stripped, so the - * caller falls through to its ordinary upstream path. - */ -export interface ServerToolLoopResult { - body: ChatRequestBody; - executions: ServerToolExecution[]; - response: Response | null; - /** - * One entry per server-tool hop, in the order the model produced them. - * - * Carries the grouping `message.content` / `reasoning_content` cannot: a - * multi-hop turn joins every hop into one string per kind, which loses where - * one hop's reasoning ends and the next begins. Travels beside the response - * rather than inside it, because it is not part of the OpenAI protocol — a - * block renderer reads it off the response through `getServerToolTurns`. - * Empty when no hop ran. - */ - turns: ServerToolTurn[]; -} - -export type ServerToolInvocation = - | { - id: string; - input: { query: string }; - type: 'web_search'; - } - | { - id: string; - input: WebFetchQuery; - type: 'web_fetch'; - }; - -export type ServerToolExecution = - | (Extract & { - result: WebSearchResponse; - }) - | (Extract & { - result: WebFetchResponse; - }); - -/** - * One server-tool hop as the model produced it. - * - * `reasoning` and `text` are what the model wrote before the calls in - * `executions`; both are empty when it called tools without speaking first. The - * last hop of a turn usually has no executions, because the model answered - * instead of reaching for another tool. - */ -export interface ServerToolTurn { - executions: ServerToolExecution[]; - reasoning: string; - text: string; -} - -export interface ServerToolCallbacks { - emitStreamEvents?: boolean; - /** - * Set by routes that render a server tool's findings structurally — - * Anthropic's `web_search_tool_result` block — instead of as prose. Those - * routes must not also fold the same findings into the assistant text, or - * the user sees the results twice: once as a result block and once as if - * the model had written them. - */ - findingsAsStructuredBlocks?: boolean; - onCall?: (invocation: ServerToolInvocation) => void; - onResult?: (execution: ServerToolExecution) => void; -} - -export const SERVER_TOOL_STREAM_EVENT_KEY = 'x-codebuddy2api-server-tool'; - -export type ServerToolStreamEvent = - | { invocation: ServerToolInvocation; phase: 'call' } - | { execution: ServerToolExecution; phase: 'result' }; - -export type ServerToolUpstreamMode = - 'buffer' | 'detect-both' | 'detect-fetch' | 'detect-search' | 'stream'; diff --git a/lib/server/proxy/server-tool/args.ts b/lib/server/proxy/server-tools/args.ts similarity index 100% rename from lib/server/proxy/server-tool/args.ts rename to lib/server/proxy/server-tools/args.ts diff --git a/lib/server/proxy/server-tools/classify.ts b/lib/server/proxy/server-tools/classify.ts new file mode 100644 index 0000000..76ef0f8 --- /dev/null +++ b/lib/server/proxy/server-tools/classify.ts @@ -0,0 +1,281 @@ +import { + buildWebFetchToolDefinition, + buildWebSearchToolDefinition, + normalizeToolName, + WEB_FETCH_TOOL_TYPE_PREFIX, + WEB_SEARCH_TOOL_TYPE_PREFIX, +} from '../../search/tool'; +import { asRecord } from '../../shared/content'; +import type { ChatCompletionToolCall, ServerToolKind } from './types'; + +/** + * Decides which tool declarations the provider — this proxy — is meant to run, + * and rewrites them into functions upstream can call. + * + * ## Only the declared type counts + * + * A provider-executed tool is declared with a type of its own: Anthropic sends + * `web_search_20250305` and `web_fetch_20250910`, the Responses API sends + * `web_search_preview`. A client's own function is declared as + * `{type: 'function', function: {...}}` on OpenAI, or as a bare + * `{name, input_schema}` with no type at all on Anthropic. So the type is both + * necessary and sufficient to tell them apart. + * + * The name is deliberately never consulted. `normalizeToolName` strips case and + * separators — `WebSearch` and `web_search` both become `websearch` — so a + * name-based test cannot tell Claude Code's own `WebSearch` function from the + * server tool. Matching on the name made the proxy answer a call the client had + * every intention of resolving itself: Claude Code never received the + * `tool_use` block it needed, so the search it asked for never happened and the + * turn ended with an answer invented from memory. + * + * The distinction is what the corrected flow turns on. Claude Code declares + * `WebSearch` as an ordinary function and resolves it itself; only when it has + * a `WebSearch` result to fill in does it issue a sub-request whose tools carry + * the server type, and that sub-request is the one that runs here. + */ + +const SERVER_TOOL_PREFIXES: ReadonlyArray<{ + kind: ServerToolKind; + prefix: string; +}> = [ + { kind: 'web_search', prefix: WEB_SEARCH_TOOL_TYPE_PREFIX }, + { kind: 'web_fetch', prefix: WEB_FETCH_TOOL_TYPE_PREFIX }, +]; + +const OPENAI_FUNCTION_TYPE = 'function'; + +/** + * Classifies one tool declaration, or returns `null` for a client-owned tool. + */ +export const classifyServerToolDeclaration = ( + tool: unknown, +): ServerToolKind | null => { + const record = asRecord(tool); + + if (!record) { + return null; + } + + const type = typeof record.type === 'string' ? record.type.trim() : ''; + + // No type is Anthropic's shorthand for a client function, and `function` is + // OpenAI's. Both mean the client resolves the call, whatever the tool is + // called — including when it is called `web_search`. + if (!type || normalizeToolName(type) === OPENAI_FUNCTION_TYPE) { + return null; + } + + const normalized = normalizeToolName(type); + const match = SERVER_TOOL_PREFIXES.find(({ prefix }) => + normalized.startsWith(normalizeToolName(prefix)), + ); + + return match?.kind ?? null; +}; + +export interface ServerToolDeclarations { + fetch: boolean; + search: boolean; +} + +/** The provider-executed declarations in `tools`, or `null` when there are none. */ +export const findServerToolDeclarations = ( + tools: unknown, +): ServerToolDeclarations | null => { + if (!Array.isArray(tools) || !tools.length) { + return null; + } + + const kinds = new Set( + tools + .map(classifyServerToolDeclaration) + .filter((kind): kind is ServerToolKind => kind !== null), + ); + + if (!kinds.size) { + return null; + } + + return { fetch: kinds.has('web_fetch'), search: kinds.has('web_search') }; +}; + +const declarationName = (tool: unknown): string => { + const record = asRecord(tool); + const fn = asRecord(record?.function); + + return typeof fn?.name === 'string' + ? fn.name + : typeof record?.name === 'string' + ? record.name + : ''; +}; + +/** + * Whether any client-owned function collides with a server tool the proxy is + * about to inject. + * + * Both would arrive upstream under the same name, and a model calling it gets + * no way to say which it meant — so the call is left to the client rather than + * guessed at. This is the same normalization collision this file exists to + * avoid, reached from the other side: two declarations this time, one by type + * and one by name, that upstream cannot tell apart. + */ +export const hasAmbiguousServerToolName = (tools: unknown): boolean => { + if (!Array.isArray(tools)) { + return false; + } + + const serverNames = new Set(); + const clientNames = new Set(); + + tools.forEach((tool) => { + const name = normalizeToolName(declarationName(tool)); + + if (!name) { + return; + } + + if (classifyServerToolDeclaration(tool)) { + serverNames.add(name); + } else { + clientNames.add(name); + } + }); + + return [...serverNames].some((name) => + [...clientNames].some((client) => name === client), + ); +}; + +export interface RewrittenServerTools { + /** + * Which declared server tools the proxy will execute. A declaration the proxy + * cannot run — no backend configured — is still rewritten upstream, but is + * left for the client to resolve. + */ + executable: ServerToolDeclarations; + /** Sorts a tool call into one the proxy runs and one the client resolves. */ + isExecutableCall: (toolCall: ChatCompletionToolCall) => boolean; + /** + * Declarations for the follow-up call, with the executed server tools + * removed. They are dropped rather than left callable because the follow-up + * exists to write the answer, and a second search there would be a second + * turn this proxy does not run. + */ + followUpTools: unknown[]; + tools: unknown[]; +} + +/** + * Replaces provider-executed declarations with the functions upstream calls. + * + * Upstream has no server tools, so every provider-executed declaration — + * runnable or not — has to become a plain function before it goes out; leaving + * the declared type in place would send a shape upstream rejects. + * + * Returns `null` when no provider-executed tool is declared, so a caller can + * skip the turn entirely and forward the request untouched. + */ +export const rewriteServerTools = ({ + declarations, + fetchProvider, + searchProvider, + tools, +}: { + declarations: ServerToolDeclarations; + fetchProvider: unknown; + searchProvider: unknown; + tools: unknown; +}): RewrittenServerTools | null => { + if (!Array.isArray(tools)) { + return null; + } + + // Ambiguity is resolved in the client's favour; see + // {@link hasAmbiguousServerToolName}. + const ambiguous = hasAmbiguousServerToolName(tools); + + const executable: ServerToolDeclarations = { + fetch: declarations.fetch && Boolean(fetchProvider) && !ambiguous, + search: declarations.search && Boolean(searchProvider) && !ambiguous, + }; + + const injectedNames = new Set(); + const definitions = new Map(); + const followUpTools: unknown[] = []; + + /** Whether the proxy runs `kind`, as opposed to leaving it to the client. */ + const runsLocally = (kind: ServerToolKind): boolean => + kind === 'web_search' ? executable.search : executable.fetch; + + const rewritten = tools.map((tool) => { + const kind = classifyServerToolDeclaration(tool); + + if (!kind) { + followUpTools.push(tool); + return tool; + } + + const definition = + kind === 'web_search' + ? buildWebSearchToolDefinition() + : buildWebFetchToolDefinition(); + + injectedNames.add(normalizeToolName(definition.name)); + definitions.set(normalizeToolName(definition.name), kind); + + // A declaration the proxy is not running stays callable on the follow-up: + // the client is the one that answers it, and dropping it would silently + // remove a tool the client asked for. + if (!runsLocally(kind)) { + followUpTools.push({ type: 'function', function: definition }); + } + + return { type: 'function', function: definition }; + }); + + /** + * Only a name the proxy injected, and only for a tool it has a backend for. + * + * Matched in canonical form because the name comes back from the model, which + * is under no obligation to repeat the spelling it was given: upstream echoes + * `web_fetch` as `WebFetch` often enough to matter here. + */ + const isExecutableCall = (toolCall: ChatCompletionToolCall): boolean => { + const name = normalizeToolName(toolCall.function?.name ?? ''); + const kind = definitions.get(name); + + return kind ? runsLocally(kind) : false; + }; + + return { executable, followUpTools, isExecutableCall, tools: rewritten }; +}; + +/** Whether the proxy will run any server tool at all. */ +export const hasExecutableServerTool = ( + executable: ServerToolDeclarations, +): boolean => executable.fetch || executable.search; + +/** + * The tool a `tool_choice` forces, in either protocol's shape. + * + * Anthropic sends `{type: 'tool', name}` and OpenAI `{type: 'function', + * function: {name}}`; a translated body carries the OpenAI shape, while a + * caller reading the client's own request sees the Anthropic one. + */ +export const getForcedToolName = (toolChoice: unknown): string | null => { + const record = asRecord(toolChoice); + + if (!record) { + return null; + } + + const fn = asRecord(record.function); + + return typeof fn?.name === 'string' + ? fn.name + : typeof record.name === 'string' + ? record.name + : null; +}; diff --git a/lib/server/proxy/server-tools/execute.ts b/lib/server/proxy/server-tools/execute.ts new file mode 100644 index 0000000..e940f2d --- /dev/null +++ b/lib/server/proxy/server-tools/execute.ts @@ -0,0 +1,101 @@ +import { runWebFetchResult, runWebSearchResult } from '../../search'; +import type { WebFetchProvider, WebSearchProvider } from '../../search/types'; +import { extractFetchQuery, extractSearchQuery } from './args'; +import type { + ChatCompletionToolCall, + ServerToolExecution, + ServerToolInvocation, +} from './types'; + +/** + * Turns one tool call into the invocation a backend runs. + * + * The name decides which tool, so it is read in canonical form: the model is + * under no obligation to repeat the spelling it was given, and upstream echoes + * `web_fetch` back as `WebFetch` often enough to matter. + */ +export const buildServerToolInvocation = ( + toolCall: ChatCompletionToolCall, + index: number, +): ServerToolInvocation => { + const name = (toolCall.function?.name ?? '').toLowerCase(); + const id = toolCall.id ?? `server_tool_${index}`; + + return name.includes('fetch') + ? { + id, + input: extractFetchQuery(toolCall.function?.arguments), + type: 'web_fetch', + } + : { + id, + input: { query: extractSearchQuery(toolCall.function?.arguments) }, + type: 'web_search', + }; +}; + +export interface ServerToolRunResult { + /** Text handed back upstream as the tool's result message. */ + content: string; + execution: ServerToolExecution; + tool_call_id: string; +} + +/** + * Runs every invocation and returns one tool message per call. + * + * Failures become text rather than exceptions: the arguments came from the + * model, so the useful outcome is for it to see what went wrong and retry or + * answer without the findings — not for the whole turn to fail. + */ +export const executeServerToolInvocations = async ({ + fetchProvider, + invocations, + onCall, + onResult, + searchProvider, +}: { + fetchProvider: WebFetchProvider | null; + invocations: ServerToolInvocation[]; + onCall?: (invocation: ServerToolInvocation) => void; + onResult?: (execution: ServerToolExecution) => void; + searchProvider: WebSearchProvider | null; +}): Promise => { + return await Promise.all( + invocations.map(async (invocation) => { + // Announced before the call runs, so a client watching the stream sees + // the search start rather than only its result. + onCall?.(invocation); + + if (invocation.type === 'web_fetch') { + const result = await runWebFetchResult({ + provider: fetchProvider, + query: invocation.input, + }); + const execution: ServerToolExecution = { ...invocation, result }; + + onResult?.(execution); + + return { + content: result.content, + execution, + tool_call_id: invocation.id, + }; + } + + const result = await runWebSearchResult({ + provider: searchProvider, + query: invocation.input.query, + }); + const execution: ServerToolExecution = { ...invocation, result }; + + onResult?.(execution); + + return { + content: result.content, + execution, + tool_call_id: invocation.id, + }; + }), + ); +}; diff --git a/lib/server/proxy/server-tools/index.ts b/lib/server/proxy/server-tools/index.ts new file mode 100644 index 0000000..285fc36 --- /dev/null +++ b/lib/server/proxy/server-tools/index.ts @@ -0,0 +1,41 @@ +export { + classifyServerToolDeclaration, + findServerToolDeclarations, + getForcedToolName, + hasAmbiguousServerToolName, + hasExecutableServerTool, + rewriteServerTools, +} from './classify'; +export type { RewrittenServerTools, ServerToolDeclarations } from './classify'; +export { + buildServerToolInvocation, + executeServerToolInvocations, +} from './execute'; +export { + parseBufferedPayload, + readBufferedChatCompletionPayload, +} from './payload'; +export { synthesizeChatCompletionStream } from './sse'; +export { + foldIntermediateTexts, + prepareServerToolTurn, + resolveServerToolBackends, + runServerToolTurn, +} from './turn'; +export type { ServerToolKind } from './types'; +export { + attachServerToolExecutions, + EMPTY_PREAMBLE, + getServerToolExecutions, + STREAM_TEXT_CHUNK_LENGTH, +} from './types'; +export type { + ChatCompletionMessage, + ChatCompletionPayload, + ChatCompletionToolCall, + JsonRecord, + ServerToolExecution, + ServerToolInvocation, + ServerToolPreamble, + ServerToolTurnOutcome, +} from './types'; diff --git a/lib/server/proxy/server-tool/payload.ts b/lib/server/proxy/server-tools/payload.ts similarity index 58% rename from lib/server/proxy/server-tool/payload.ts rename to lib/server/proxy/server-tools/payload.ts index 1339645..e99b8fd 100644 --- a/lib/server/proxy/server-tool/payload.ts +++ b/lib/server/proxy/server-tools/payload.ts @@ -1,38 +1,6 @@ import { extractErrorMessage } from '../../shared/http'; import type { ChatCompletionPayload } from './types'; -/** - * Rebuilds a failed upstream response so its body can be read again. - * - * A `Response` body can only be consumed once. The loop reads it to decide - * whether the model asked for a server tool, and handing the same object back - * used to leave the route layer — which reads it again to build the answer the - * client actually sees — with a spent body: the second read threw - * "Body already used" and the client got a 500 in place of the real upstream - * status. Draining it here and replaying the bytes in a fresh response keeps - * both reads working and preserves the body verbatim, so an upstream error - * detail that is not valid JSON still reaches the client intact. - * - * `content-length` and `content-encoding` are dropped: the body is re-emitted - * rather than re-encoded, and a stale length would describe bytes the upstream - * compressed before this layer ever saw them. - */ -export const buildServerToolFailureResponse = async ( - response: Response, -): Promise => { - const headers = new Headers(response.headers); - - headers.delete('content-length'); - headers.delete('content-encoding'); - headers.set('content-type', 'application/json'); - - return new Response(await response.text(), { - headers, - status: response.status, - statusText: response.statusText, - }); -}; - /** * Parses a buffered upstream body, tolerating a failure that is not JSON. * @@ -57,13 +25,20 @@ export const parseBufferedPayload = ( } }; +/** + * Reads and parses a buffered upstream body. + * + * `buffered` is passed in rather than read here because the caller has already + * consumed the response to get at it: a `Response` body can be read once, and + * reading it again throws "Body already used". The response is still needed for + * its status and headers. + */ export const readBufferedChatCompletionPayload = async ( response: Response, + buffered?: string, ): Promise => { - // Cloned so the failure path can replay the body verbatim; see - // {@link buildServerToolFailureResponse}. - const buffered = await response.clone().text(); - const payload = parseBufferedPayload(buffered, response.ok); + const text = buffered ?? (await response.clone().text()); + const payload = parseBufferedPayload(text, response.ok); if (!response.ok || payload.error) { const ownMessage = payload.error?.message; @@ -73,7 +48,7 @@ export const readBufferedChatCompletionPayload = async ( // code has no message to find, and the JSON is still the only record of // what happened. An empty body says nothing, so it falls all the way // through to the generic message instead of winning on being non-null. - const detail = buffered.trim(); + const detail = text.trim(); return { ...payload, diff --git a/lib/server/proxy/server-tool/sse.ts b/lib/server/proxy/server-tools/sse.ts similarity index 100% rename from lib/server/proxy/server-tool/sse.ts rename to lib/server/proxy/server-tools/sse.ts diff --git a/lib/server/proxy/server-tools/turn.ts b/lib/server/proxy/server-tools/turn.ts new file mode 100644 index 0000000..a580eeb --- /dev/null +++ b/lib/server/proxy/server-tools/turn.ts @@ -0,0 +1,377 @@ +import { + getActiveConfig, + getCodeBuddyApiEndpoint, + isWebFetchEnabled, + isWebSearchEnabled, +} from '../../domain/config'; +import { resolveFetchProvider, resolveSearchProvider } from '../../search'; +import type { WebFetchProvider, WebSearchProvider } from '../../search/types'; +import { asRecord, readReasoning } from '../../shared/content'; +import type { ChatRequestBody } from '../codebuddy'; +import { + buildServerToolInvocation, + executeServerToolInvocations, +} from './execute'; +import { + findServerToolDeclarations, + getForcedToolName, + rewriteServerTools, +} from './classify'; +import { readBufferedChatCompletionPayload } from './payload'; +import type { + ChatCompletionMessage, + ChatCompletionPayload, + ChatCompletionToolCall, + JsonRecord, + ServerToolExecution, + ServerToolInvocation, + ServerToolPreamble, + ServerToolTurnOutcome, +} from './types'; +import { attachServerToolExecutions, EMPTY_PREAMBLE } from './types'; + +/** + * One server-tool turn. + * + * The model is asked for a search, the search runs here, and upstream is asked + * once more — without the server tools it could call again — to write the + * answer. Two calls at most, and the second is unconditional, which is why this + * is not a loop: there is no "until the model stops asking", because the + * follow-up cannot ask. + * + * A client that wants several searches sends several requests. Claude Code is + * the reference: it resolves its own `WebSearch` tool, and only opens a + * sub-request carrying the server type once it has a result to fill in. That + * sub-request asks for exactly one search, and this answers it. + */ + +/** + * Resolves the configured backends. + * + * A `passthrough` backend resolves to `null`, which is what tells the turn the + * client runs the tool itself: the declaration is still rewritten into a + * function upstream can call, but the call that comes back is handed to the + * client rather than executed here. + */ +export const resolveServerToolBackends = async (): Promise<{ + fetchProvider: WebFetchProvider | null; + searchProvider: WebSearchProvider | null; +}> => { + const [searchEnabled, fetchEnabled, config] = await Promise.all([ + isWebSearchEnabled(), + isWebFetchEnabled(), + getActiveConfig(), + ]); + const resolveEndpoint = getCodeBuddyApiEndpoint; + + return { + fetchProvider: fetchEnabled + ? resolveFetchProvider( + config.CODEBUDDY_WEB_FETCH_BACKEND, + resolveEndpoint, + ) + : null, + searchProvider: searchEnabled + ? resolveSearchProvider( + config.CODEBUDDY_WEB_SEARCH_BACKEND, + resolveEndpoint, + ) + : null, + }; +}; + +/** + * Decides whether `tools` contain a server tool this deployment will run. + * + * Takes the already-translated chat tools: both translators keep a + * provider-executed declaration's type, so this works for Anthropic + * (`web_search_20250305`) and the Responses API (`web_search_preview`) alike. + * + * Returns `null` when no provider-executed tool is declared, in which case the + * caller forwards the request untouched. Otherwise `rewrite.tools` must be sent + * upstream whether or not anything will be executed: upstream has no server + * tools, and leaving a declared type in the request sends a shape it rejects. + */ +export const prepareServerToolTurn = async ( + tools: unknown, +): Promise<{ + providers: { + fetchProvider: WebFetchProvider | null; + searchProvider: WebSearchProvider | null; + }; + rewrite: NonNullable>; +} | null> => { + const declarations = findServerToolDeclarations(tools); + + if (!declarations) { + return null; + } + + const { fetchProvider, searchProvider } = await resolveServerToolBackends(); + const rewrite = rewriteServerTools({ + declarations, + fetchProvider, + searchProvider, + tools, + }); + + if (!rewrite) { + return null; + } + + return { providers: { fetchProvider, searchProvider }, rewrite }; +}; + +/** + * Prose and reasoning a message carries, as the part of the turn that came + * *before* the tool call it is attached to. + */ +const readPreamble = ( + message: ChatCompletionMessage | undefined, +): ServerToolPreamble => { + if (!message) { + return EMPTY_PREAMBLE; + } + + return { + reasoning: readReasoning(message).trim(), + text: typeof message.content === 'string' ? message.content.trim() : '', + }; +}; + +/** + * Rebuilds a response whose body has already been read. + * + * The turn reads the first upstream response to see whether the model asked for + * a server tool, so the object handed back has to be reconstructed from those + * bytes — a caller reading it a second time would otherwise hit "Body already + * used". + * + * Framing headers are dropped: they describe the original body, which has since + * been re-serialized to a different length. Keeping `content-length` truncates + * the new one and keeping `content-encoding: gzip` makes a client try to + * decompress plaintext. + */ +const rebuildResponse = (response: Response, body: string): Response => { + const headers = new Headers(response.headers); + + headers.delete('content-encoding'); + headers.delete('content-length'); + headers.delete('transfer-encoding'); + + return new Response(body, { + headers, + status: response.status, + statusText: response.statusText, + }); +}; + +const asMessages = (body: ChatRequestBody): JsonRecord[] => + (Array.isArray(body.messages) ? body.messages : []) as JsonRecord[]; + +/** + * Runs one server-tool turn. + * + * Upstream is asked for a search, the search runs here, and upstream is asked + * once more — without the server tools, so it cannot ask again — to write the + * answer. Two calls at most. The response is always the one to render: the + * first has already been spent reading the tool calls, so a caller that + * re-issued it would be billed twice for the same turn. + */ +export const runServerToolTurn = async ({ + body, + callUpstream, + fetchProvider, + onCall, + onResult, + rewrite, + searchProvider, + stream, +}: { + body: ChatRequestBody; + /** + * One round trip to upstream. `stream` asks for SSE rather than a buffered + * payload; the first call is always buffered, because the tool calls are only + * visible once it has finished. + */ + callUpstream: (body: ChatRequestBody, stream: boolean) => Promise; + fetchProvider: WebFetchProvider | null; + onCall?: (invocation: ServerToolInvocation) => void; + onResult?: (execution: ServerToolExecution) => void; + /** Output of {@link rewriteServerTools} for this request. */ + rewrite: NonNullable>; + searchProvider: WebSearchProvider | null; + stream: boolean; +}): Promise => { + const { executable, followUpTools, isExecutableCall, tools } = rewrite; + + const first = await callUpstream({ ...body, tools }, false); + const buffered = await first.text(); + const payload = await readBufferedChatCompletionPayload( + rebuildResponse(first, buffered), + buffered, + ); + + // A failure is handed back untouched: whatever the turn would have done with + // the tool calls, the request did not succeed, and the client needs the real + // status and detail rather than a summary. + if (!first.ok || payload.error) { + return { + executions: [], + preamble: EMPTY_PREAMBLE, + response: rebuildResponse(first, buffered), + }; + } + + const message = payload.choices?.[0]?.message; + const toolCalls: ChatCompletionToolCall[] = message?.tool_calls ?? []; + const localCalls = toolCalls.filter(isExecutableCall); + + // The model answered without reaching for a server tool — the ordinary case + // for a request that merely *declares* one. Its answer is the whole turn, so + // the caller renders this response directly. + if (!localCalls.length) { + return { + executions: [], + preamble: readPreamble(message), + response: rebuildResponse(first, buffered), + }; + } + + const invocations = localCalls.map((toolCall, index) => + buildServerToolInvocation(toolCall, index), + ); + + const results = await executeServerToolInvocations({ + fetchProvider, + invocations, + ...(onCall ? { onCall } : {}), + ...(onResult ? { onResult } : {}), + searchProvider, + }); + + const executions: ServerToolExecution[] = results.map( + (result) => result.execution, + ); + + const messages: JsonRecord[] = [ + ...asMessages(body), + { + ...(message as JsonRecord), + content: message?.content ?? null, + role: message?.role ?? 'assistant', + }, + ...results.map((result) => ({ + role: 'tool', + content: result.content, + tool_call_id: result.tool_call_id, + })), + ]; + + const response = await callUpstream( + { + ...body, + messages, + tools: followUpTools, + tool_choice: relaxToolChoice(body.tool_choice, executable), + }, + stream, + ); + + return { + // Published on the response as well as returned, so a caller that drives + // upstream itself — the image-generation loop — can pick up searches that + // ran on a hop it did not produce. + executions, + preamble: readPreamble(message), + response: attachServerToolExecutions(response, executions), + }; +}; + +/** + * Keeps the follow-up from being forced back into a search. + * + * A `tool_choice` naming a server tool the proxy has just run would make the + * follow-up call it again — and the follow-up has no server tool to call, so + * upstream would reject it. `required` has the same effect by another route: it + * obliges the model to call something when the turn needs an answer. + */ +const relaxToolChoice = ( + toolChoice: unknown, + executable: { fetch: boolean; search: boolean }, +): unknown => { + if (!toolChoice) { + return toolChoice; + } + + const name = getForcedToolName(toolChoice); + const canonical = name ? name.toLowerCase().replace(/[_\-\s]+/g, '') : ''; + + if ( + canonical && + ((executable.search && canonical.startsWith('websearch')) || + (executable.fetch && canonical.startsWith('webfetch'))) + ) { + return 'none'; + } + + if (toolChoice === 'required') { + return 'auto'; + } + + return toolChoice; +}; + +/** + * Folds the prose earlier hops produced into a payload that only carries the + * last one. + * + * The image-generation loop replays a request with each generated image folded + * back in, so only its final hop's message survives in the payload — but the + * text the model wrote before each call is part of the turn, and dropping it + * leaves the client's transcript out of step with what the model actually said. + */ +export const foldIntermediateTexts = ( + payload: ChatCompletionPayload, + texts: string[], +): ChatCompletionPayload => { + const extraText = texts.filter(Boolean).join('\n\n'); + const [first, ...rest] = payload.choices ?? []; + + if (!first || !extraText) { + return payload; + } + + const message = first.message ?? {}; + const existingText = + typeof message.content === 'string' ? message.content : ''; + + return { + ...payload, + choices: [ + { + ...first, + message: { + ...message, + content: [extraText, existingText].filter(Boolean).join('\n\n'), + }, + }, + ...rest, + ], + }; +}; + +/** Reads the payload of a buffered upstream response. */ +export const readJsonResponse = async ( + response: Response, +): Promise> => { + const text = await response.text(); + + try { + return asRecord(JSON.parse(text)) ?? {}; + } catch { + return {}; + } +}; + +export type { ChatCompletionMessage }; diff --git a/lib/server/proxy/server-tools/types.ts b/lib/server/proxy/server-tools/types.ts new file mode 100644 index 0000000..3368245 --- /dev/null +++ b/lib/server/proxy/server-tools/types.ts @@ -0,0 +1,143 @@ +/** + * Server tools the proxy runs on the client's behalf. + * + * A client asks a provider to run a search by declaring a *provider-executed* + * tool: Anthropic sends a dated type (`web_search_20250305`), the Responses API + * sends `web_search_preview`. Upstream CodeBuddy has neither, so the proxy + * executes the call against a configured backend and hands the findings back + * as if upstream had produced them. + * + * What is deliberately absent here is a loop. A server tool is answered in one + * bounded turn: the model asks for a search, the proxy runs it, and upstream is + * asked once more — without the server tools available to call again — to write + * the answer. Iterating until the model stops asking would be a loop, and the + * corrected flow does not need one: a client that wants several searches issues + * several requests, which is exactly what Claude Code does when it answers its + * own `WebSearch` tool. + */ + +import type { + WebFetchQuery, + WebFetchResponse, + WebSearchResponse, +} from '../../search/types'; + +export type JsonRecord = Record; + +export interface ChatCompletionToolCall { + id?: string; + index?: number; + type?: string; + function?: { + arguments?: string; + name?: string; + }; +} + +export interface ChatCompletionMessage { + content?: string | null; + reasoning?: string; + reasoning_content?: string; + role?: string; + tool_calls?: ChatCompletionToolCall[]; +} + +export interface ChatCompletionPayload { + choices?: Array<{ + finish_reason?: string | null; + index?: number; + message?: ChatCompletionMessage; + }>; + created?: number; + /** + * `status` is the upstream HTTP status, carried so a downstream mapper can + * name the real error type instead of guessing it from the message text. It + * is absent for a payload that already reported an error of its own. + */ + error?: { message?: string; status?: number }; + id?: string; + model?: string; + object?: string; + usage?: unknown; +} + +/** Text slice size when a buffered completion is replayed as SSE. */ +export const STREAM_TEXT_CHUNK_LENGTH = 1024; + +/** The two tools this proxy can execute, named by what they do. */ +export type ServerToolKind = 'web_fetch' | 'web_search'; + +export type ServerToolInvocation = + | { + id: string; + input: { query: string }; + type: 'web_search'; + } + | { + id: string; + input: WebFetchQuery; + type: 'web_fetch'; + }; + +export type ServerToolExecution = + | (Extract & { + result: WebSearchResponse; + }) + | (Extract & { + result: WebFetchResponse; + }); + +/** + * Prose the model produced before it reached for a server tool. + * + * Anthropic puts that prose *ahead* of the `server_tool_use` block, so it has + * to travel separately from the answer written after the results came back: + * joining the two would show the user a conclusion before the search that + * produced it. + */ +export interface ServerToolPreamble { + reasoning: string; + text: string; +} + +export const EMPTY_PREAMBLE: ServerToolPreamble = { reasoning: '', text: '' }; + +/** + * Result of one server-tool turn. + * + * `response` is the upstream response to render as the assistant's answer, and + * it is always present: the turn has already spent the first upstream call, and + * a caller that re-issued the request would bill the turn twice. + */ +export interface ServerToolTurnOutcome { + /** Calls executed locally, in the order the model made them. */ + executions: ServerToolExecution[]; + /** What the model wrote before those calls. Empty when it spoke only after. */ + preamble: ServerToolPreamble; + response: Response; +} + +/** + * Out-of-band channel for the calls a turn executed. + * + * The image-generation loop drives upstream itself and may surface a server + * tool call on any of its hops, so it needs to collect executions from + * responses it did not produce. The alternative — threading a collector + * through every layer between the two — would couple them for one field. + */ +const serverToolExecutions = new WeakMap(); + +export const attachServerToolExecutions = ( + response: Response, + executions: ServerToolExecution[], +): Response => { + if (executions.length) { + serverToolExecutions.set(response, executions); + } + + return response; +}; + +export const getServerToolExecutions = ( + response: Response, +): ServerToolExecution[] => serverToolExecutions.get(response) ?? []; diff --git a/lib/server/proxy/web-search-loop.ts b/lib/server/proxy/web-search-loop.ts deleted file mode 100644 index 92ee8a6..0000000 --- a/lib/server/proxy/web-search-loop.ts +++ /dev/null @@ -1,924 +0,0 @@ -import { - getActiveConfig, - getCodeBuddyApiEndpoint, - isWebFetchEnabled, - isWebSearchEnabled, -} from '../domain/config'; -import { resolveFetchProvider, resolveSearchProvider } from '../search'; -import { normalizeSearchBackend } from '../search/tool'; -import type { WebFetchProvider, WebSearchProvider } from '../search/types'; -import { encodeDoneFrame } from '../shared/sse'; - -import type { ChatRequestBody } from './codebuddy'; -import { - isLocalServerToolCall, - isWebFetchTool, - isWebSearchTool, - replaceServerTools, -} from './server-tool/classify'; -import { - buildServerToolInvocation, - executeServerToolInvocations, -} from './server-tool/execution'; -import { - buildServerToolFailureResponse, - parseBufferedPayload, - readBufferedChatCompletionPayload, -} from './server-tool/payload'; -import { synthesizeChatCompletionStream } from './server-tool/sse'; -import { - type ServerToolProbe, - probeServerToolStream, -} from './server-tool/stream'; -import { - buildMixedTurnPayload, - readReasoning, - sumUsage, - withIntermediateTurns, -} from './server-tool/turns'; -import { - MAX_SEARCH_ITERATIONS, - SERVER_TOOL_STREAM_EVENT_KEY, - type ChatCompletionMessage, - type ChatCompletionPayload, - type ChatCompletionToolCall, - type JsonRecord, - type ServerToolCallbacks, - type ServerToolExecution, - type ServerToolLoopResult, - type ServerToolStreamEvent, - type ServerToolTurn, - type ServerToolUpstreamMode, -} from './server-tool/types'; - -/** - * Server-side web search for upstreams that do not implement it. - * - * Anthropic (`web_search_20260209`) and the Responses API - * (`web_search_preview`) both hand search to the provider. CodeBuddy has no - * equivalent, so when a client declares one of those tools the proxy swaps it - * for a plain `web_search` function the model can call, runs the query through - * the configured search backend, and appends the results as a tool message. - * The model then answers normally, and the client never learns the search ran - * locally. - * - * A streaming first response is probed until its first meaningful delta. Plain - * text and reasoning keep the real upstream stream, while a server-tool call - * is buffered because its arguments are only complete once that response ends. - */ - -const createInlineServerToolStream = async ({ - body, - callbacks, - callUpstream, - fetchProvider, - ownedNames, - searchProvider, -}: { - body: ChatRequestBody; - callbacks: ServerToolCallbacks; - callUpstream: ( - body: ChatRequestBody, - mode: ServerToolUpstreamMode, - ) => Promise; - fetchProvider: WebFetchProvider | null; - ownedNames?: Set; - searchProvider: WebSearchProvider | null; -}): Promise => { - const firstResponse = await callUpstream(body, 'stream'); - const contentType = firstResponse.headers.get('content-type') ?? ''; - - if (!contentType.toLowerCase().includes('text/event-stream')) { - return { body, executions: [], response: firstResponse, turns: [] }; - } - - const executions: ServerToolExecution[] = []; - const encoder = new TextEncoder(); - let activeReader: ReadableStreamDefaultReader | null = null; - let cancelled = false; - - // A call is locally executable only when its backend is available; anything - // else stays the client's to answer. - const isLocalCall = (toolCall: ChatCompletionToolCall): boolean => - isLocalServerToolCall({ - fetchProvider, - ownedNames, - searchProvider, - toolCall, - }); - - const emitJson = ( - controller: ReadableStreamDefaultController, - payload: Record, - ): void => { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`)); - }; - const emitServerToolEvent = ( - controller: ReadableStreamDefaultController, - event: ServerToolStreamEvent, - ): void => { - emitJson(controller, { [SERVER_TOOL_STREAM_EVENT_KEY]: event }); - }; - const pipeResponse = async ( - controller: ReadableStreamDefaultController, - response: Response, - ): Promise => { - if (!response.body) return; - const reader = response.body.getReader(); - activeReader = reader; - - while (true) { - const chunk = await reader.read(); - if (cancelled || chunk.done) break; - controller.enqueue(chunk.value); - } - - reader.releaseLock(); - activeReader = null; - }; - - const stream = new ReadableStream({ - start: (controller) => { - const run = async (): Promise => { - activeReader = firstResponse.body!.getReader(); - activeReader.releaseLock(); - const context = { - responseCreated: Math.floor(Date.now() / 1000), - responseId: '', - responseModel: String(body.model ?? 'unknown'), - responseObject: 'chat.completion', - role: 'assistant', - usage: null as unknown, - }; - - const first = await probeServerToolStream({ - canContinue: () => !cancelled, - context, - emitRaw: (frame) => - controller.enqueue(encoder.encode(`${frame}\n\n`)), - fetchProvider, - onReader: (reader) => { - activeReader = reader; - }, - ownedNames, - response: firstResponse, - searchProvider, - }); - if (cancelled) return; - activeReader = null; - - let usage: unknown = context.usage; - const content = first.content; - const reasoning = first.reasoning; - const role = context.role; - - const responseId = context.responseId; - const responseModel = context.responseModel; - const responseObject = context.responseObject; - const responseCreated = context.responseCreated; - - if (!first.localCalls.length) { - first.frames.forEach((frame) => - controller.enqueue(encoder.encode(`${frame}\n\n`)), - ); - controller.close(); - return; - } - - const remainingCalls = first.remainingCalls; - const invocations = first.localCalls.map((toolCall, index) => - buildServerToolInvocation(toolCall, 0, index), - ); - - invocations.forEach((invocation) => { - callbacks.onCall?.(invocation); - emitServerToolEvent(controller, { invocation, phase: 'call' }); - }); - const results = await executeServerToolInvocations({ - callbacks: { - onResult: (execution) => { - callbacks.onResult?.(execution); - emitServerToolEvent(controller, { execution, phase: 'result' }); - }, - }, - fetchProvider, - invocations, - searchProvider, - }); - if (cancelled) return; - executions.push(...results.map((result) => result.execution)); - - if (remainingCalls.length) { - // Same opt-out as `buildMixedTurnPayload`: the result event above - // already carries these findings, so a text copy would be the second. - const findings = callbacks.findingsAsStructuredBlocks - ? '' - : results.map((result) => result.content).join('\n\n'); - if (findings) { - emitJson(controller, { - choices: [{ delta: { content: findings }, index: 0 }], - created: responseCreated, - id: responseId, - model: responseModel, - object: `${responseObject}.chunk`, - }); - } - emitJson(controller, { - choices: [ - { - delta: { tool_calls: remainingCalls }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - created: responseCreated, - id: responseId, - model: responseModel, - object: `${responseObject}.chunk`, - usage, - }); - controller.enqueue(encodeDoneFrame()); - controller.close(); - return; - } - - const messages = body.messages as JsonRecord[]; - const assistantMessage: JsonRecord = { - role, - content: content || null, - tool_calls: first.toolCalls, - ...(reasoning ? { reasoning_content: reasoning } : {}), - }; - messages.push(assistantMessage); - messages.push( - ...results.map((result) => ({ - role: 'tool', - tool_call_id: result.tool_call_id, - content: result.content, - })), - ); - - let loopBody: ChatRequestBody = { - ...body, - messages, - tool_choice: body.tool_choice ? 'auto' : body.tool_choice, - }; - let finalPayload: ChatCompletionPayload | null = null; - - for ( - let iteration = 1; - iteration < MAX_SEARCH_ITERATIONS; - iteration++ - ) { - const response = await callUpstream(loopBody, 'stream'); - const isEventStream = (response.headers.get('content-type') ?? '') - .toLowerCase() - .includes('text/event-stream'); - - // Upstream answers with JSON rather than SSE when it refuses the - // request, and also when the caller is not streaming at all. Both - // shapes are read the same way; only an error ends the turn here. - const buffered = !isEventStream - ? await readBufferedChatCompletionPayload(response) - : null; - - let probe: ServerToolProbe | null = null; - - if (buffered) { - usage = sumUsage(usage, buffered.usage); - - if (!response.ok || buffered.error) { - emitJson(controller, buffered as JsonRecord); - controller.enqueue(encodeDoneFrame()); - controller.close(); - return; - } - - const bufferedMessage = buffered.choices?.[0]?.message; - const bufferedCalls = bufferedMessage?.tool_calls ?? []; - - // A JSON answer that still asks for a server tool is an - // intermediate step, not the end of the turn: it has to be - // executed and fed back, exactly as a streamed one would be. - if (!bufferedCalls.some(isLocalCall)) { - finalPayload = { - ...buffered, - ...(usage ? { usage } : {}), - }; - break; - } - - probe = { - content: - typeof bufferedMessage?.content === 'string' - ? bufferedMessage.content - : '', - frames: [], - localCalls: bufferedCalls.filter(isLocalCall), - reasoning: readReasoning(bufferedMessage), - remainingCalls: bufferedCalls.filter( - (toolCall) => !isLocalCall(toolCall), - ), - role: bufferedMessage?.role ?? 'assistant', - toolCalls: bufferedCalls, - usage, - }; - } else { - // Streamed rather than buffered: this is the iteration that very - // often ends the turn, and buffering it would make the user wait - // for the whole answer before seeing any of it. Text is forwarded - // as it arrives; only tool-call frames are held, since a server - // tool still has to be answered locally. - probe = await probeServerToolStream({ - canContinue: () => !cancelled, - context, - emitRaw: (frame) => - controller.enqueue(encoder.encode(`${frame}\n\n`)), - fetchProvider, - onReader: (reader) => { - activeReader = reader; - }, - ownedNames, - response, - searchProvider, - }); - if (cancelled) return; - activeReader = null; - usage = sumUsage(usage, context.usage); - - // No server tool to answer, so the held frames — withheld only - // because they *might* have been one — are forwarded as-is. - if (!probe.localCalls.length) { - probe.frames.forEach((frame) => - controller.enqueue(encoder.encode(`${frame}\n\n`)), - ); - controller.close(); - return; - } - } - - // The model is going to search again, so anything it just said is - // part of the visible turn rather than a discarded step. A streamed - // iteration already forwarded it through `emitRaw`, so only a - // buffered one — whose payload never reached the client — needs it - // re-emitted here. - const iterationText = buffered ? probe.content.trim() : ''; - const iterationReasoning = buffered ? probe.reasoning.trim() : ''; - - if (iterationText) { - emitJson(controller, { - choices: [{ delta: { content: iterationText }, index: 0 }], - created: context.responseCreated, - id: responseId, - model: responseModel, - object: `${responseObject}.chunk`, - }); - } - - if (iterationReasoning) { - emitJson(controller, { - choices: [ - { delta: { reasoning_content: iterationReasoning }, index: 0 }, - ], - created: context.responseCreated, - id: responseId, - model: responseModel, - object: `${responseObject}.chunk`, - }); - } - - const message: ChatCompletionMessage = { - content: probe.content || null, - role: probe.role, - tool_calls: probe.toolCalls, - ...(probe.reasoning ? { reasoning_content: probe.reasoning } : {}), - }; - const nextLocalCalls = probe.localCalls; - const nextRemainingCalls = probe.remainingCalls; - - const nextInvocations = nextLocalCalls.map((toolCall, index) => - buildServerToolInvocation(toolCall, iteration, index), - ); - nextInvocations.forEach((invocation) => { - callbacks.onCall?.(invocation); - emitServerToolEvent(controller, { invocation, phase: 'call' }); - }); - const nextResults = await executeServerToolInvocations({ - callbacks: { - onResult: (execution) => { - callbacks.onResult?.(execution); - emitServerToolEvent(controller, { - execution, - phase: 'result', - }); - }, - }, - fetchProvider, - invocations: nextInvocations, - searchProvider, - }); - if (cancelled) return; - executions.push(...nextResults.map((result) => result.execution)); - - if (nextRemainingCalls.length) { - finalPayload = buildMixedTurnPayload({ - findingsAsStructuredBlocks: callbacks?.findingsAsStructuredBlocks, - message, - payload: { - choices: [{ message }], - created: context.responseCreated, - id: responseId, - model: responseModel, - object: responseObject, - }, - remainingCalls: nextRemainingCalls, - searchResults: nextResults.map((result) => result.content), - usage, - }); - break; - } - - messages.push(message as JsonRecord); - messages.push( - ...nextResults.map((result) => ({ - role: 'tool', - tool_call_id: result.tool_call_id, - content: result.content, - })), - ); - loopBody = { - ...loopBody, - messages, - tool_choice: loopBody.tool_choice ? 'auto' : loopBody.tool_choice, - }; - } - - if (!finalPayload) { - const response = await callUpstream( - { - ...loopBody, - tools: loopBody.tools?.filter( - (tool) => !isWebSearchTool(tool) && !isWebFetchTool(tool), - ), - }, - 'stream', - ); - const isEventStream = (response.headers.get('content-type') ?? '') - .toLowerCase() - .includes('text/event-stream'); - - if (!isEventStream) { - finalPayload = await readBufferedChatCompletionPayload(response); - - if (!response.ok || finalPayload.error) { - emitJson(controller, finalPayload as JsonRecord); - controller.enqueue(encodeDoneFrame()); - controller.close(); - return; - } - - usage = sumUsage(usage, finalPayload.usage); - finalPayload = { ...finalPayload, ...(usage ? { usage } : {}) }; - } else { - const probe = await probeServerToolStream({ - canContinue: () => !cancelled, - context, - emitRaw: (frame) => - controller.enqueue(encoder.encode(`${frame}\n\n`)), - fetchProvider, - onReader: (reader) => { - activeReader = reader; - }, - ownedNames, - response, - searchProvider, - }); - if (cancelled) return; - activeReader = null; - usage = sumUsage(usage, context.usage); - - // With every server tool stripped, a tool call here can only be a - // client-owned one; hand it back so the client resolves it. - if (probe.remainingCalls.length) { - const fallbackMessage: ChatCompletionMessage = { - content: probe.content || null, - role: probe.role, - tool_calls: probe.toolCalls, - ...(probe.reasoning - ? { reasoning_content: probe.reasoning } - : {}), - }; - - finalPayload = buildMixedTurnPayload({ - findingsAsStructuredBlocks: - callbacks?.findingsAsStructuredBlocks, - message: fallbackMessage, - payload: { - choices: [{ message: fallbackMessage }], - created: context.responseCreated, - id: responseId, - model: responseModel, - object: responseObject, - }, - remainingCalls: probe.remainingCalls, - searchResults: [], - usage, - }); - } else { - probe.frames.forEach((frame) => - controller.enqueue(encoder.encode(`${frame}\n\n`)), - ); - controller.close(); - return; - } - } - } - - await pipeResponse( - controller, - synthesizeChatCompletionStream( - finalPayload, - String(loopBody.model ?? 'unknown'), - ), - ); - controller.close(); - }; - - void run().catch((error) => { - if (!cancelled) controller.error(error); - }); - }, - async cancel(reason): Promise { - cancelled = true; - try { - await activeReader?.cancel(reason); - } finally { - activeReader?.releaseLock(); - activeReader = null; - } - }, - }); - - return { - body, - executions, - response: new Response(stream, { - headers: firstResponse.headers, - status: firstResponse.status, - statusText: firstResponse.statusText, - }), - // The streamed path already emits each hop in order, so no grouping has - // to be reconstructed downstream. - turns: [], - }; -}; - -export const executeWebSearchLoop = async ({ - body, - callUpstream, - callbacks, - detectInitialStream = Boolean(body.stream), -}: { - body: ChatRequestBody; - callUpstream: ( - body: ChatRequestBody, - mode: ServerToolUpstreamMode, - ) => Promise; - callbacks?: ServerToolCallbacks; - detectInitialStream?: boolean; -}): Promise => { - const [searchEnabled, fetchEnabled, config] = await Promise.all([ - isWebSearchEnabled(), - isWebFetchEnabled(), - getActiveConfig(), - ]); - - const resolveEndpoint = getCodeBuddyApiEndpoint; - const searchProvider = searchEnabled - ? resolveSearchProvider( - config.CODEBUDDY_WEB_SEARCH_BACKEND, - resolveEndpoint, - ) - : null; - const fetchProvider = fetchEnabled - ? resolveFetchProvider(config.CODEBUDDY_WEB_FETCH_BACKEND, resolveEndpoint) - : null; - - const replacement = replaceServerTools({ - fetchEnabled, - fetchProvider, - searchEnabled, - searchPassthrough: - normalizeSearchBackend(config.CODEBUDDY_WEB_SEARCH_BACKEND) === - 'passthrough', - searchProvider, - tools: body.tools, - }); - - if (!replacement) { - return null; - } - - const { executes, ownedNames, tools } = replacement; - - // Nothing can be executed, so there is nothing to loop for. The rewritten - // `tools` still have to reach the caller: it forwards them upstream, and the - // stripped declarations have to stay stripped on that path too. - if (!executes) { - return { - body: { ...body, tools }, - executions: [], - response: null, - turns: [], - }; - } - - const messages: JsonRecord[] = body.messages as JsonRecord[]; - let loopBody: ChatRequestBody = { ...body, messages, tools }; - let response: Response | null = null; - let payload: ChatCompletionPayload | null = null; - let usage: unknown = null; - const executions: ServerToolExecution[] = []; - // Text and reasoning the model produced before a *later* server-tool call. - // Only the last iteration's message survives in `payload`, so a multi-hop - // turn has to carry its earlier steps forward explicitly. - const intermediateTexts: string[] = []; - const intermediateReasonings: string[] = []; - // The calls each hop made, parallel to the two arrays above. Block renderers - // need the calls grouped with the prose that produced them, not flattened - // into one list at the end. - const intermediateExecutions: ServerToolExecution[][] = []; - const initialMode: ServerToolUpstreamMode = - searchProvider && fetchProvider - ? 'detect-both' - : searchProvider - ? 'detect-search' - : 'detect-fetch'; - - if (detectInitialStream && callbacks?.emitStreamEvents) { - return await createInlineServerToolStream({ - body: loopBody, - callbacks, - callUpstream, - fetchProvider, - ownedNames, - searchProvider, - }); - } - - for (let iteration = 0; iteration < MAX_SEARCH_ITERATIONS; iteration++) { - response = await callUpstream( - loopBody, - iteration === 0 && detectInitialStream ? initialMode : 'buffer', - ); - - if ( - response.headers - .get('content-type') - ?.toLowerCase() - .includes('text/event-stream') - ) { - return { body: loopBody, executions, response, turns: [] }; - } - - // The payload is only needed to detect a tool call or a failure, so read - // the body once and reuse it: the caller reads it again to build the - // client's answer, and a spent body would surface as a 500. - const buffered = await response.clone().text(); - payload = parseBufferedPayload(buffered, response.ok); - - if (!response.ok || payload.error) { - return { - body: loopBody, - executions, - response: await buildServerToolFailureResponse(response), - turns: [], - }; - } - - usage = sumUsage(usage, payload.usage); - - const message = payload.choices?.[0]?.message; - const toolCalls = message?.tool_calls ?? []; - // The same ownership test the streaming paths use. Matching the name alone - // would execute a client's own `web_fetch` whenever a backend is - // configured, instead of handing the call back. - const isLocalCall = (toolCall: ChatCompletionToolCall): boolean => - isLocalServerToolCall({ - fetchProvider, - ownedNames, - searchProvider, - toolCall, - }); - const localCalls = toolCalls.filter(isLocalCall); - const remainingCalls = toolCalls.filter( - (toolCall) => !isLocalCall(toolCall), - ); - - if (!localCalls.length) { - break; - } - - const iterationText = - typeof message?.content === 'string' ? message.content.trim() : ''; - const iterationReasoning = readReasoning(message).trim(); - - const invocations = localCalls.map((toolCall, index) => - buildServerToolInvocation(toolCall, iteration, index), - ); - const results = await executeServerToolInvocations({ - callbacks, - fetchProvider, - invocations, - searchProvider, - }); - executions.push(...results.map((result) => result.execution)); - - // A turn mixing server tools with client-side calls cannot be continued - // locally: the client owns those calls, and re-issuing the transcript with - // only server-tool results would leave them unanswered, which upstream - // rejects as an invalid tool-call transcript. Run the server tools and - // hand the outstanding calls back so the client resolves them on its next - // turn. The findings ride along in the message text only for routes that - // cannot render them structurally; see `buildMixedTurnPayload`. - if (remainingCalls.length) { - const { payload: folded, turns: priorTurns } = withIntermediateTurns({ - executions: intermediateExecutions, - payload, - reasonings: intermediateReasonings, - texts: intermediateTexts, - }); - // The hops already run, plus this one's own: it called server tools - // before handing the client's calls back, so it is a hop like any other - // and has to stay grouped with them. Dropping it would leave the block - // renderer with no grouping at all, flattening every hop's prose ahead - // of the tool blocks. - // - // `withIntermediateTurns` already built this hop as its closing entry — - // the one that carries the current message's prose — but without the - // calls, because it runs before they exist. So the calls are added to - // that entry rather than appended as a new hop, which would repeat the - // prose. Only when there are no earlier hops does it return nothing and - // a fresh entry has to be built here. - const currentTurn: ServerToolTurn = { - // With no earlier hops there is no closing entry to carry the prose, - // so this hop's own text and reasoning are used directly. They are - // what `withIntermediateTurns` folded into `folded` above, and a block - // renderer renders purely from `turns` once it is non-empty, so - // leaving them out would drop everything the model said here. - ...(priorTurns.at(-1) ?? { - reasoning: iterationReasoning, - text: iterationText, - }), - executions: results.map((result) => result.execution), - }; - const mixedTurns: ServerToolTurn[] = [ - ...priorTurns.slice(0, -1), - currentTurn, - ]; - const mixed = buildMixedTurnPayload({ - findingsAsStructuredBlocks: callbacks?.findingsAsStructuredBlocks, - // `buildMixedTurnPayload` reads this iteration's text and reasoning - // off `message`, so only the earlier iterations go on top; the - // current one is folded in by the helper itself. - message: folded.choices?.[0]?.message, - payload, - remainingCalls, - searchResults: results.map((result) => result.content), - usage, - }); - - return { - body: loopBody, - executions, - response: Response.json(mixed, { status: response.status }), - turns: mixedTurns, - }; - } - - // This iteration is complete and the loop continues, so its prose and the - // calls it made both become part of the turn the client sees. Keep the three - // arrays index-aligned: entry N is hop N, so a renderer can pair that hop's - // reasoning, text, and tool calls without guessing. A hop that called tools - // without speaking first still gets an entry — its prose sides stay empty. - const hop = intermediateTexts.length; - - intermediateTexts[hop] = iterationText; - intermediateReasonings[hop] = iterationReasoning; - intermediateExecutions[hop] = results.map((result) => result.execution); - - messages.push(message as JsonRecord); - messages.push( - ...results.map((result) => ({ - role: 'tool', - tool_call_id: result.tool_call_id, - content: result.content, - })), - ); - - // A forced tool_choice would make the model call a server tool forever; - // once the loop is running, let it decide when it has enough. - loopBody = { - ...loopBody, - messages, - tool_choice: loopBody.tool_choice ? 'auto' : loopBody.tool_choice, - }; - payload = null; - } - - // The budget ran out with the model still asking to search or fetch. Drop - // every server tool and ask once more so it answers with what it has: looping - // forever would hang the request, and returning `null` would hand the - // unfinished tool call back to the client, which has no way to resolve it. - if (!payload) { - const finalResponse = await callUpstream( - { - ...loopBody, - tools: loopBody.tools!.filter( - (tool) => !isWebSearchTool(tool) && !isWebFetchTool(tool), - ), - }, - 'buffer', - ); - // Cloned before the read so the failure path can replay the body verbatim - // rather than hand back a spent response the caller cannot read again. - const finalBuffered = await finalResponse.clone().text(); - payload = parseBufferedPayload(finalBuffered, finalResponse.ok); - - usage = sumUsage(usage, payload.usage); - - if (!finalResponse.ok || payload.error) { - return { - body: loopBody, - executions, - response: await buildServerToolFailureResponse(finalResponse), - turns: [], - }; - } - - const final = withIntermediateTurns({ - executions: intermediateExecutions, - payload, - reasonings: intermediateReasonings, - texts: intermediateTexts, - }); - - return { - body: loopBody, - executions, - response: Response.json( - { - ...final.payload, - ...(usage ? { usage } : {}), - }, - { status: finalResponse.status }, - ), - turns: final.turns, - }; - } - - const final = withIntermediateTurns({ - executions: intermediateExecutions, - payload, - reasonings: intermediateReasonings, - texts: intermediateTexts, - }); - - return { - body: loopBody, - executions, - response: Response.json( - { - ...final.payload, - ...(usage ? { usage } : {}), - }, - { status: response!.status }, - ), - turns: final.turns, - }; -}; - -export type { - ChatCompletionMessage, - ChatCompletionPayload, - ChatCompletionToolCall, - ServerToolCallbacks, - ServerToolExecution, - ServerToolInvocation, - ServerToolLoopResult, - ServerToolStreamEvent, - ServerToolTurn, - ServerToolUpstreamMode, -} from './server-tool/types'; - -export { - attachServerToolExecutions, - attachServerToolTurns, - getServerToolExecutions, - getServerToolStreamEvent, - getServerToolTurns, -} from './server-tool/execution'; - -export { synthesizeChatCompletionStream } from './server-tool/sse'; - -export { withIntermediateTurns } from './server-tool/turns'; diff --git a/lib/server/search/tool.ts b/lib/server/search/tool.ts index c9f6c8a..37e9554 100644 --- a/lib/server/search/tool.ts +++ b/lib/server/search/tool.ts @@ -103,60 +103,13 @@ export const buildWebFetchToolDefinition = (): { }; }; -/** - * Marks a translated tool as having been declared by the client as a - * provider-executed server tool. - * - * The Responses path necessarily flattens `web_fetch_20250910` into a plain - * function for upstream, which loses the information that the client asked for a - * server tool rather than declaring its own. The proxy loop later needs that - * distinction: an unexecutable server-tool declaration is dropped, while a - * client-owned function is forwarded untouched. - * - * This is a private, non-standard field that travels only between the Responses - * translator and the proxy loop; the loop strips it before anything is sent - * upstream. - */ -const SERVER_TOOL_MARKER = 'x-codebuddy2api-server-tool'; - -export const markServerTool = (tool: T): T & Record => { - if (!tool || typeof tool !== 'object') { - return tool as T & Record; - } - - return { ...tool, [SERVER_TOOL_MARKER]: true }; -}; - -export const isMarkedServerTool = (tool: unknown): boolean => { - if (!tool || typeof tool !== 'object') { - return false; - } - - return (tool as Record)[SERVER_TOOL_MARKER] === true; -}; - -/** Removes the private marker so nothing non-standard reaches upstream. */ -export const stripServerToolMarker = (tool: T): T => { - if (!tool || typeof tool !== 'object') { - return tool; - } - - const { [SERVER_TOOL_MARKER]: _marker, ...rest } = tool as Record< - string, - unknown - >; - - return rest as T; -}; - /** * Where a server tool runs. * * The names say *who* executes the tool, because that is the decision being * made. `codebuddy` and `codebuddy2api` are both server-side and differ only in * who fetches: CodeBuddy's own agent-tool endpoint versus this machine. - * `passthrough` leaves the tool in the request, so the client (Claude Code, - * Codex) runs it itself. + * `passthrough` leaves the tool to the client (Claude Code, Codex). */ export type SearchBackend = 'codebuddy' | 'searxng' | 'passthrough'; export type FetchBackend = 'codebuddy' | 'codebuddy2api' | 'passthrough'; diff --git a/lib/server/shared/sse.ts b/lib/server/shared/sse.ts index 3217303..d135880 100644 --- a/lib/server/shared/sse.ts +++ b/lib/server/shared/sse.ts @@ -55,3 +55,15 @@ export const encodeEventFrame = (type: string, data: unknown): Uint8Array => /** The frame that ends an SSE stream. */ export const encodeDoneFrame = (): Uint8Array => encoder.encode(`${DONE_FRAME_TEXT}\n\n`); + +/** + * Whether a response is already an SSE stream. + * + * Every route branches on this, because upstream answers with JSON rather than + * SSE both when it refuses a request and when the caller was never streaming — + * and the two have to be read the same way. + */ +export const isEventStream = (response: Response): boolean => + (response.headers.get('content-type') ?? '') + .toLowerCase() + .includes('text/event-stream'); diff --git a/tests/server/image-generation.test.ts b/tests/server/image-generation.test.ts index d88fd13..84b4412 100644 --- a/tests/server/image-generation.test.ts +++ b/tests/server/image-generation.test.ts @@ -22,7 +22,7 @@ import type { ProxyContext } from '@/lib/server/proxy/codebuddy'; import { attachServerToolExecutions, getServerToolExecutions, -} from '@/lib/server/proxy/web-search-loop'; +} from '@/lib/server/proxy/server-tools'; import { handleResponsesRequest, resetResponseSessions, diff --git a/tests/server/search-providers.test.ts b/tests/server/search-providers.test.ts new file mode 100644 index 0000000..f3803d0 --- /dev/null +++ b/tests/server/search-providers.test.ts @@ -0,0 +1,2304 @@ +/** + * Coverage for the search provider layer. + * + * The proxy-facing half of the server tools lives in `server-tools/`; this file + * covers the half that actually goes and gets things — the backend registry, + * the two CodeBuddy agent-tool backends, and the local fetch — plus the small + * helpers they share. + * + * Nothing here touches the network. The CodeBuddy backends are driven through a + * stubbed `fetch`, and the local fetch is driven by stubbing `node:http` / + * `node:https` `.request`, which is the only seam it has: it deliberately + * avoids `fetch` so it can pin the socket to an already-validated address. + */ + +import dns from 'node:dns/promises'; +import { EventEmitter } from 'node:events'; +import http from 'node:http'; +import https from 'node:https'; + +import { + getWebSearchProvider, + resetWebSearchProviders, + resolveFetchProvider, + resolveSearchProvider, + runWebFetch, + runWebFetchResult, + runWebSearch, + runWebSearchResult, +} from '@/lib/server/search'; +import { + createCodeBuddyFetchProvider, + normalizeFetchUrl, +} from '@/lib/server/search/providers/codebuddy-fetch'; +import { createCodeBuddySearchProvider } from '@/lib/server/search/providers/codebuddy-search'; +import { + createLocalFetchProvider, + type HostResolver, +} from '@/lib/server/search/providers/local-fetch'; +import { + clampInteger, + collapse, + formatFetchResult, + formatSearchResults, + MAX_SNIPPET_LENGTH, + MAX_TITLE_LENGTH, + readEnv, +} from '@/lib/server/search/shared'; +import { + pickCredentialToken, + resolveCodeBuddyToken, + withCodeBuddyToken, + type TokenResolver, +} from '@/lib/server/search/token'; +import { + buildWebFetchToolDefinition, + DEFAULT_FETCH_BACKEND, + DEFAULT_SEARCH_BACKEND, + FETCH_BACKENDS, + normalizeFetchBackend, + normalizeSearchBackend, + normalizeToolName, + SEARCH_BACKENDS, + WEB_FETCH_TOOL_NAME, +} from '@/lib/server/search/tool'; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +const SEARXNG_ENV_NAMES = [ + 'SEARXNG_URL', + 'SEARXNG_API_KEY', + 'SEARXNG_ENGINES', + 'SEARXNG_LANGUAGE', + 'SEARXNG_MAX_RESULTS', + 'SEARXNG_TIMEOUT_MS', +] as const; + +const clearSearxngEnv = (): void => { + for (const name of SEARXNG_ENV_NAMES) { + delete process.env[name]; + } + resetWebSearchProviders(); +}; + +const TOKEN = 'agent-tool-token'; +const tokenResolver: TokenResolver = async () => TOKEN; +const endpointResolver = async () => 'https://agent.test/'; + +/** Runs `fn` with a CodeBuddy token in scope, as a proxy request would. */ +const withToken = (fn: () => Promise): Promise => + withCodeBuddyToken(tokenResolver, fn); + +const makeJsonResponse = (payload: unknown, status = 200): Response => + new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +const makeTextResponse = (text: string, status = 200): Response => + new Response(text, { status }); + +interface FetchCall { + init: RequestInit; + url: string; +} + +/** Stubs `fetch`, returning a recorder for the calls the backend made. */ +const stubFetch = ( + implementation: (url: string, init: RequestInit) => Promise, +): { calls: FetchCall[] } => { + const calls: FetchCall[] = []; + const mock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + const requestInit = (init ?? {}) as RequestInit; + calls.push({ init: requestInit, url }); + + return implementation(url, requestInit); + }, + ); + vi.stubGlobal('fetch', mock); + + return { calls }; +}; + +const stubJsonFetch = ( + payload: unknown, + status = 200, +): { calls: FetchCall[] } => + stubFetch(async () => makeJsonResponse(payload, status)); + +const readJsonBody = (call: FetchCall): Record => + JSON.parse(String(call.init.body)) as Record; + +const readHeaders = (call: FetchCall): Headers => + new Headers(call.init.headers as HeadersInit); + +// -- Fake `node:http` / `node:https` transport ------------------------------ + +type LookupCallback = ( + error: Error | null, + address: string | Array<{ address: string; family: number }>, + family?: number, +) => void; + +type PinnedLookup = ( + hostname: string, + options: { all?: boolean }, + callback: LookupCallback, +) => void; + +interface PinnedRequestOptions { + headers?: Record; + host?: string; + lookup?: PinnedLookup; + method?: string; + path?: string; + port?: number; + servername?: string; +} + +interface FakeRequest { + destroy: () => void; + emitError: (error: unknown) => void; + end: () => void; + on: (event: string, listener: (error: unknown) => void) => FakeRequest; +} + +interface FakeResponse extends EventEmitter { + destroy: (error?: Error) => void; + headers: Record; + idleTimeoutListener: (() => void) | null; + resume: () => void; + setTimeout: (ms: number, listener: () => void) => FakeResponse; + statusCode: number | undefined; +} + +const createFakeRequest = (): FakeRequest => { + const listeners = new Map void>>(); + const request: FakeRequest = { + destroy: () => undefined, + // Deferred, because `requestPinned` registers its listener *after* + // `request` is handed back — a socket error never arrives that early. + emitError: (error) => { + setTimeout(() => { + for (const listener of listeners.get('error') ?? []) { + listener(error); + } + }, 0); + }, + end: () => undefined, + on: (event, listener) => { + listeners.set(event, [...(listeners.get(event) ?? []), listener]); + + return request; + }, + }; + + return request; +}; + +const createFakeResponse = ( + options: { + headers?: Record; + statusCode?: number | undefined; + } = {}, +): FakeResponse => { + const response = new EventEmitter() as unknown as FakeResponse; + response.headers = options.headers ?? {}; + // A test may pass `statusCode: undefined` on purpose: that is what a + // response carrying no status line looks like to the reader. + response.statusCode = 'statusCode' in options ? options.statusCode : 200; + response.idleTimeoutListener = null; + // Matches `IncomingMessage`: destroying with an error surfaces it on the + // stream, which is how a stalled body reaches the reader. + response.destroy = (error?: Error) => { + if (error) response.emit('error', error); + }; + response.resume = () => undefined; + response.setTimeout = (_ms: number, listener: () => void) => { + response.idleTimeoutListener = listener; + + return response; + }; + + return response; +}; + +interface RespondOptions { + body?: string; + /** `false` leaves the body open, as a server that stalls mid-stream does. */ + endStream?: boolean; + headers?: Record; + statusCode?: number | undefined; +} + +interface TransportHandlerContext { + /** Zero-based index of this request within one provider call. */ + call: number; + options: PinnedRequestOptions; + request: FakeRequest; + respond: (options?: RespondOptions) => FakeResponse; +} + +interface TransportCall { + options: PinnedRequestOptions; + request: FakeRequest; +} + +/** + * Replaces `http.request` / `https.request`. + * + * The handler decides what each request sees; `respond` schedules the response + * for the next tick, because a synchronous callback would run before + * `requestPinned` installs its own timeout. + */ +const installTransport = ( + handler: (context: TransportHandlerContext) => void, +): TransportCall[] => { + const calls: TransportCall[] = []; + + const implementation = ( + options: PinnedRequestOptions, + callback: (response: FakeResponse) => void, + ): FakeRequest => { + const request = createFakeRequest(); + const call = calls.length; + calls.push({ options, request }); + + handler({ + call, + options, + request, + respond: (options: RespondOptions = {}) => { + const response = createFakeResponse({ + headers: options.headers ?? {}, + // `undefined` is meaningful: it is what a response with no status + // line looks like to the reader. + statusCode: 'statusCode' in options ? options.statusCode : 200, + }); + + setTimeout(() => { + callback(response); + + if (options.body) { + response.emit('data', Buffer.from(options.body)); + } + + if (options.endStream ?? true) { + response.emit('end'); + } + }, 0); + + return response; + }, + }); + + return request; + }; + + vi.spyOn(http, 'request').mockImplementation( + implementation as unknown as typeof http.request, + ); + vi.spyOn(https, 'request').mockImplementation( + implementation as unknown as typeof https.request, + ); + + return calls; +}; + +/** A resolver answering with a public address, as ordinary DNS would. */ +const publicResolver: HostResolver = async () => ['93.184.216.34']; + +/** Lets the transport's scheduled response arrive. */ +const tick = (): Promise => + new Promise((resolve) => setTimeout(resolve, 5)); + +/** Drains pending timers until `done`, so a test cannot leak into the next. */ +const settle = async (done: () => boolean): Promise => { + for (let attempt = 0; attempt < 200 && !done(); attempt += 1) { + await tick(); + } +}; + +/** + * Stops the local fallback before it opens a socket. + * + * The CodeBuddy backend always starts a local fetch alongside the endpoint and + * abandons it when the endpoint wins, so a test of the endpoint alone still has + * to account for that attempt. Answering with a private address makes it fail + * at the address check — no DNS, no transport stub, nothing left in flight. + */ +const blockedResolver: HostResolver = async () => ['127.0.0.1']; + +const trustedResolver: HostResolver = Object.assign(async () => ['10.0.0.5'], { + trusted: true, +}); + +// --------------------------------------------------------------------------- +// Backend selection and tool definitions +// --------------------------------------------------------------------------- + +describe('search tool definitions and backend names', () => { + describe('normalizeSearchBackend', () => { + it('keeps the three known backends', () => { + expect(normalizeSearchBackend('codebuddy')).toBe('codebuddy'); + expect(normalizeSearchBackend('searxng')).toBe('searxng'); + expect(normalizeSearchBackend('passthrough')).toBe('passthrough'); + }); + + it('ignores case and surrounding whitespace', () => { + expect(normalizeSearchBackend(' CodeBuddy ')).toBe('codebuddy'); + expect(normalizeSearchBackend('SEARXNG')).toBe('searxng'); + }); + + it('renames the legacy `none` backend to passthrough', () => { + // `none` used to mean "the client runs it", which reads like "off". + expect(normalizeSearchBackend('none')).toBe('passthrough'); + }); + + it('falls back to the default for a backend only fetch knows', () => { + // `local` renames to `codebuddy2api`, which is not a search backend. + expect(normalizeSearchBackend('local')).toBe(DEFAULT_SEARCH_BACKEND); + expect(normalizeSearchBackend('codebuddy2api')).toBe( + DEFAULT_SEARCH_BACKEND, + ); + }); + + it('falls back to the default for unknown, empty, and missing values', () => { + expect(normalizeSearchBackend('bogus')).toBe('searxng'); + expect(normalizeSearchBackend('')).toBe('searxng'); + expect(normalizeSearchBackend(null)).toBe('searxng'); + expect(normalizeSearchBackend(undefined)).toBe('searxng'); + expect(normalizeSearchBackend(42)).toBe('searxng'); + }); + }); + + describe('normalizeFetchBackend', () => { + it('keeps the three known backends', () => { + expect(normalizeFetchBackend('codebuddy')).toBe('codebuddy'); + expect(normalizeFetchBackend('codebuddy2api')).toBe('codebuddy2api'); + expect(normalizeFetchBackend('passthrough')).toBe('passthrough'); + }); + + it('renames the legacy backends', () => { + expect(normalizeFetchBackend('local')).toBe('codebuddy2api'); + expect(normalizeFetchBackend('none')).toBe('passthrough'); + }); + + it('ignores case and surrounding whitespace', () => { + expect(normalizeFetchBackend(' Local ')).toBe('codebuddy2api'); + expect(normalizeFetchBackend('CODEBUDDY')).toBe('codebuddy'); + }); + + it('falls back to passthrough for unknown values', () => { + expect(normalizeFetchBackend('searxng')).toBe('passthrough'); + expect(normalizeFetchBackend(null)).toBe('passthrough'); + expect(normalizeFetchBackend(undefined)).toBe('passthrough'); + }); + }); + + it('exposes the backend lists the console offers', () => { + expect(SEARCH_BACKENDS).toEqual(['codebuddy', 'searxng', 'passthrough']); + expect(FETCH_BACKENDS).toEqual([ + 'codebuddy', + 'codebuddy2api', + 'passthrough', + ]); + expect(DEFAULT_SEARCH_BACKEND).toBe('searxng'); + expect(DEFAULT_FETCH_BACKEND).toBe('passthrough'); + for (const backend of SEARCH_BACKENDS) { + expect(SEARCH_BACKENDS).toContain(backend); + } + }); + + it('declares web_fetch with a required url and an optional prompt', () => { + const tool = buildWebFetchToolDefinition(); + + expect(tool.name).toBe(WEB_FETCH_TOOL_NAME); + expect(tool.name).toBe('web_fetch'); + expect(tool.parameters).toMatchObject({ + properties: { + prompt: { type: 'string' }, + url: { type: 'string' }, + }, + required: ['url'], + type: 'object', + }); + // The description has to draw the line against search, or a model reaches + // for fetch to look things up. + expect(tool.description).toContain('search for that instead'); + }); + + it('compares tool names without case, separators, or spaces', () => { + expect(normalizeToolName('web_fetch')).toBe('webfetch'); + expect(normalizeToolName('WebFetch')).toBe('webfetch'); + expect(normalizeToolName(' Web Fetch ')).toBe('webfetch'); + expect(normalizeToolName('web-fetch')).toBe('webfetch'); + expect(normalizeToolName('WEB_SEARCH')).toBe('websearch'); + expect(normalizeToolName('web_search_20260209')).toBe( + normalizeToolName('websearch20260209'), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Shared rendering helpers +// --------------------------------------------------------------------------- + +describe('search result rendering helpers', () => { + describe('readEnv', () => { + afterEach(() => { + delete process.env.TEST_SEARCH_ENV; + }); + + it('trims the value of a set variable', () => { + process.env.TEST_SEARCH_ENV = ' https://searx.test/ '; + + expect(readEnv('TEST_SEARCH_ENV')).toBe('https://searx.test/'); + }); + + it('returns an empty string when the variable is absent', () => { + expect(readEnv('TEST_SEARCH_ENV')).toBe(''); + }); + }); + + describe('clampInteger', () => { + it('uses the fallback for missing or unparseable input', () => { + expect(clampInteger(undefined, 5, 1, 10)).toBe(5); + expect(clampInteger('', 5, 1, 10)).toBe(5); + expect(clampInteger(' ', 5, 1, 10)).toBe(5); + expect(clampInteger('not-a-number', 5, 1, 10)).toBe(5); + }); + + it('parses a value inside the range', () => { + expect(clampInteger('7', 5, 1, 10)).toBe(7); + expect(clampInteger(' 3 ', 5, 1, 10)).toBe(3); + }); + + it('clamps a value outside the range', () => { + expect(clampInteger('99', 5, 1, 10)).toBe(10); + expect(clampInteger('-4', 5, 1, 10)).toBe(1); + }); + }); + + describe('collapse', () => { + it('collapses runs of whitespace', () => { + expect(collapse(' one\n\n two \t three ', 100)).toBe('one two three'); + }); + + it('truncates to the limit and marks the cut', () => { + expect(collapse('abcdefghij', 5)).toBe('abcd…'); + expect(collapse('abcdefghij', 5)).toHaveLength(5); + }); + + it('leaves text at or under the limit untouched', () => { + expect(collapse('abcde', 5)).toBe('abcde'); + }); + }); + + describe('formatSearchResults', () => { + it('tells the model to answer from memory when nothing came back', () => { + const text = formatSearchResults('nothing here', []); + + expect(text).toContain('returned no results'); + expect(text).toContain('nothing here'); + }); + + it('renders one result in the singular', () => { + const text = formatSearchResults('q', [ + { title: 'Only', url: 'https://a' }, + ]); + + expect(text).toContain('(1 result)'); + expect(text).toContain('1. Only'); + expect(text).toContain('URL: https://a'); + }); + + it('renders several results with their snippets', () => { + const text = formatSearchResults('q', [ + { content: 'First body', title: 'First', url: 'https://a' }, + { content: 'Second body', title: 'Second', url: 'https://b' }, + ]); + + expect(text).toContain('(2 results)'); + expect(text).toContain('1. First'); + expect(text).toContain('2. Second'); + expect(text).toContain('First body'); + expect(text).toContain('Second body'); + expect(text).toContain('Cite the URL'); + }); + + it('substitutes placeholders for missing fields', () => { + const text = formatSearchResults('q', [{}, { title: ' ' }]); + + expect(text).toContain('1. (untitled)'); + expect(text).toContain('2. (untitled)'); + // Neither entry has a URL, so no citation line is emitted. + expect(text).not.toContain('URL:'); + }); + }); + + describe('formatFetchResult', () => { + it('names the source url', () => { + const text = formatFetchResult({ + content: 'Body', + url: 'https://a.test', + }); + + expect(text).toContain('Web fetch result for https://a.test:'); + expect(text).toContain('Body'); + }); + + it('includes the requested focus when the model gave one', () => { + const text = formatFetchResult({ + content: 'Body', + prompt: 'the pricing tiers', + url: 'https://a.test', + }); + + expect(text).toContain('Requested focus: the pricing tiers'); + }); + + it('omits the focus line when there is no prompt', () => { + const text = formatFetchResult({ + content: 'Body', + url: 'https://a.test', + }); + + expect(text).not.toContain('Requested focus'); + }); + + it('clamps an over-long prompt to the title limit', () => { + const text = formatFetchResult({ + content: 'Body', + prompt: 'x'.repeat(MAX_TITLE_LENGTH + 50), + url: 'https://a.test', + }); + + expect(text).toContain('…'); + expect(text).not.toContain('x'.repeat(MAX_TITLE_LENGTH + 1)); + }); + }); + + it('bounds titles and snippets', () => { + expect(MAX_TITLE_LENGTH).toBe(200); + expect(MAX_SNIPPET_LENGTH).toBe(800); + }); +}); + +// --------------------------------------------------------------------------- +// Token plumbing +// --------------------------------------------------------------------------- + +const STORED_TOKEN = 'stored-bearer-token'; + +/** + * The credential the store hands back, mutable so one test can empty it. + * + * Hoisted because the module factory below runs before anything else in this + * file is initialised. + */ +const store = vi.hoisted(() => ({ + credential: { data: { bearer_token: 'stored-bearer-token' } } as { + data: Record; + } | null, +})); + +/** + * Stands in for credential storage. + * + * `resolveCodeBuddyToken` falls back to an unscoped credential lookup when no + * token is in scope, so the fallback is the only path that reads storage — and + * it is reachable outside a proxy turn. Stubbing it here keeps that branch + * deterministic without a credential file on disk. + */ +vi.mock('@/lib/server/domain/credentials', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveCredentialForRequest: async () => store.credential, +})); + +describe('CodeBuddy token plumbing', () => { + afterEach(() => { + store.credential = { data: { bearer_token: STORED_TOKEN } }; + }); + describe('pickCredentialToken', () => { + it('prefers the bearer token', () => { + expect( + pickCredentialToken({ + access_token: 'access', + bearer_token: 'bearer', + }), + ).toBe('bearer'); + }); + + it('falls through a blank bearer token to the access token', () => { + expect( + pickCredentialToken({ access_token: 'access', bearer_token: '' }), + ).toBe('access'); + expect( + pickCredentialToken({ access_token: 'access', bearer_token: ' ' }), + ).toBe('access'); + }); + + it('returns null when both are blank or absent', () => { + expect( + pickCredentialToken({ access_token: ' ', bearer_token: '' }), + ).toBeNull(); + expect(pickCredentialToken({})).toBeNull(); + expect(pickCredentialToken({ bearer_token: null })).toBeNull(); + }); + + it('stringifies and trims non-string values', () => { + expect(pickCredentialToken({ bearer_token: 1234 })).toBe('1234'); + expect(pickCredentialToken({ bearer_token: ' padded ' })).toBe( + 'padded', + ); + }); + }); + + describe('withCodeBuddyToken', () => { + it('makes the token visible to the resolver inside the scope', async () => { + const seen = await withCodeBuddyToken( + async () => 'scoped', + async () => resolveCodeBuddyToken(), + ); + + expect(seen).toBe('scoped'); + }); + + it('keeps the token visible across an await', async () => { + const seen = await withCodeBuddyToken( + async () => 'scoped', + async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + + return resolveCodeBuddyToken(); + }, + ); + + expect(seen).toBe('scoped'); + }); + + it('returns the value the scope produced', async () => { + await expect( + withCodeBuddyToken( + async () => 'scoped', + async () => 'done', + ), + ).resolves.toBe('done'); + }); + + it('falls back to stored credentials outside the scope', async () => { + // No token is in scope, so the resolver reads the credential store — + // and it must not see the token of a scope that is not running. + await expect(resolveCodeBuddyToken()).resolves.toBe(STORED_TOKEN); + }); + + it('returns null when no credential is stored either', async () => { + store.credential = null; + + await expect(resolveCodeBuddyToken()).resolves.toBeNull(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +describe('search provider registry', () => { + beforeEach(() => { + clearSearxngEnv(); + }); + + afterEach(() => { + clearSearxngEnv(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe('resolveSearchProvider', () => { + it('builds the CodeBuddy backend', () => { + expect(resolveSearchProvider('codebuddy', endpointResolver)?.id).toBe( + 'codebuddy', + ); + }); + + it('builds a fresh CodeBuddy backend per call', () => { + // A cached backend would freeze the endpoint it was built with. + const first = resolveSearchProvider('codebuddy', endpointResolver); + const second = resolveSearchProvider('codebuddy', endpointResolver); + + expect(first).not.toBe(second); + }); + + it('returns null for passthrough and for its legacy name', () => { + expect(resolveSearchProvider('passthrough')).toBeNull(); + expect(resolveSearchProvider('none')).toBeNull(); + }); + + it('builds the SearXNG backend when one is configured', () => { + process.env.SEARXNG_URL = 'https://searx.test/'; + resetWebSearchProviders(); + + expect(resolveSearchProvider('searxng')?.id).toBe('searxng'); + }); + + it('returns null for SearXNG when no instance is configured', () => { + expect(resolveSearchProvider('searxng')).toBeNull(); + }); + + it('defaults to SearXNG for an unknown or missing backend', () => { + process.env.SEARXNG_URL = 'https://searx.test/'; + resetWebSearchProviders(); + + expect(resolveSearchProvider(null)?.id).toBe('searxng'); + expect(resolveSearchProvider(undefined)?.id).toBe('searxng'); + expect(resolveSearchProvider('bogus')?.id).toBe('searxng'); + }); + + it('caches the SearXNG backend until it is reset', () => { + process.env.SEARXNG_URL = 'https://searx.test/'; + resetWebSearchProviders(); + const first = resolveSearchProvider('searxng'); + + expect(resolveSearchProvider('searxng')).toBe(first); + + resetWebSearchProviders(); + expect(resolveSearchProvider('searxng')).not.toBe(first); + }); + }); + + describe('resolveFetchProvider', () => { + it('builds the local backend and caches it', () => { + const first = resolveFetchProvider('codebuddy2api'); + + expect(first?.id).toBe('local'); + expect(resolveFetchProvider('local')).toBe(first); + }); + + it('rebuilds the local backend after a reset', () => { + const first = resolveFetchProvider('local'); + resetWebSearchProviders(); + + expect(resolveFetchProvider('local')).not.toBe(first); + }); + + it('builds the CodeBuddy backend', () => { + expect(resolveFetchProvider('codebuddy', endpointResolver)?.id).toBe( + 'codebuddy', + ); + }); + + it('returns null for passthrough and for its legacy name', () => { + expect(resolveFetchProvider('passthrough')).toBeNull(); + expect(resolveFetchProvider('none')).toBeNull(); + }); + + it('returns null for an unknown or missing backend', () => { + // Passthrough is the default: a fetch the client did not ask us to run + // is the client's to run. + expect(resolveFetchProvider(null)).toBeNull(); + expect(resolveFetchProvider(undefined)).toBeNull(); + expect(resolveFetchProvider('bogus')).toBeNull(); + }); + }); + + describe('getWebSearchProvider', () => { + it('returns null when SearXNG is not configured', () => { + expect(getWebSearchProvider()).toBeNull(); + }); + + it('returns the cached SearXNG backend when it is', () => { + process.env.SEARXNG_URL = 'https://searx.test/'; + resetWebSearchProviders(); + + expect(getWebSearchProvider()?.id).toBe('searxng'); + expect(getWebSearchProvider()).toBe(getWebSearchProvider()); + }); + }); + + describe('runWebSearchResult', () => { + it('runs the supplied provider', async () => { + const result = await runWebSearchResult({ + provider: { + id: 'stub', + search: async (query) => ({ content: `ok:${query}`, results: [] }), + }, + query: 'hello', + }); + + expect(result.content).toBe('ok:hello'); + expect(result.results).toEqual([]); + }); + + it('reports an unconfigured backend instead of failing', async () => { + await expect( + runWebSearchResult({ backend: 'passthrough', query: 'hello' }), + ).resolves.toMatchObject({ + content: expect.stringContaining('no local search backend'), + results: [], + }); + }); + + it('treats an explicit null provider as unavailable', async () => { + await expect( + runWebSearchResult({ provider: null, query: 'hello' }), + ).resolves.toMatchObject({ results: [] }); + }); + + it('resolves the backend when no provider is supplied', async () => { + const { calls } = stubJsonFetch({ results: [] }); + + await withToken(() => + runWebSearchResult({ + backend: 'codebuddy', + query: 'hello', + resolveEndpoint: endpointResolver, + }), + ); + + expect(calls[0].url).toBe('https://agent.test/agenttool/v1/search'); + }); + + it('turns a thrown error into text the model can act on', async () => { + await expect( + runWebSearchResult({ + provider: { + id: 'boom', + search: async () => { + throw new Error('connection refused'); + }, + }, + query: 'hello', + }), + ).resolves.toMatchObject({ + content: expect.stringContaining( + 'Web search failed: connection refused', + ), + results: [], + }); + }); + + it('reports a timeout as a timeout', async () => { + await expect( + runWebSearchResult({ + provider: { + id: 'slow', + search: async () => { + throw Object.assign(new Error('aborted'), { name: 'AbortError' }); + }, + }, + query: 'hello', + }), + ).resolves.toMatchObject({ + content: expect.stringContaining('timed out'), + }); + }); + + it('reports a non-Error rejection as an unknown failure', async () => { + await expect( + runWebSearchResult({ + provider: { + id: 'weird', + search: async () => { + throw 'a string'; + }, + }, + query: 'hello', + }), + ).resolves.toMatchObject({ + content: expect.stringContaining('unknown error'), + }); + }); + + it('reports an Error with no message as an unknown failure', async () => { + await expect( + runWebSearchResult({ + provider: { + id: 'silent', + search: async () => { + throw new Error(''); + }, + }, + query: 'hello', + }), + ).resolves.toMatchObject({ + content: expect.stringContaining('unknown error'), + }); + }); + }); + + it('runWebSearch returns only the text', async () => { + await expect( + runWebSearch({ + provider: { + id: 'stub', + search: async () => ({ content: 'the text', results: [] }), + }, + query: 'hello', + }), + ).resolves.toBe('the text'); + }); + + describe('runWebFetchResult', () => { + it('runs the supplied provider', async () => { + await expect( + runWebFetchResult({ + provider: { + fetch: async (query) => ({ content: `ok:${query.url}` }), + id: 'stub', + }, + query: { url: 'https://a.test' }, + }), + ).resolves.toEqual({ content: 'ok:https://a.test' }); + }); + + it('reports an unconfigured backend instead of failing', async () => { + await expect( + runWebFetchResult({ query: { url: 'https://a.test' } }), + ).resolves.toEqual({ + content: expect.stringContaining('no web fetch backend is enabled'), + }); + }); + + it('treats an explicit null provider as unavailable', async () => { + await expect( + runWebFetchResult({ provider: null, query: { url: 'https://a.test' } }), + ).resolves.toEqual({ + content: expect.stringContaining('Web fetch is unavailable'), + }); + }); + + it('resolves the backend when no provider is supplied', async () => { + const { calls } = stubJsonFetch({ content: 'page text' }); + + const result = await withToken(() => + runWebFetchResult({ + backend: 'codebuddy', + // A loopback url keeps the backend's local fallback — which runs + // alongside the endpoint and is abandoned — at the address check, + // so it never reaches DNS. + query: { url: 'http://127.0.0.1/internal' }, + resolveEndpoint: endpointResolver, + }), + ); + + expect(calls[0].url).toBe('https://agent.test/agenttool/v1/webfetch'); + expect(result.content).toContain('page text'); + }); + + it('turns a thrown error into text', async () => { + await expect( + runWebFetchResult({ + provider: { + fetch: async () => { + throw new Error('socket hang up'); + }, + id: 'boom', + }, + query: { url: 'https://a.test' }, + }), + ).resolves.toEqual({ + content: expect.stringContaining('Web fetch failed: socket hang up'), + }); + }); + + it('reports a timeout as a timeout', async () => { + await expect( + runWebFetchResult({ + provider: { + fetch: async () => { + throw Object.assign(new Error('aborted'), { name: 'AbortError' }); + }, + id: 'slow', + }, + query: { url: 'https://a.test' }, + }), + ).resolves.toEqual({ content: expect.stringContaining('timed out') }); + }); + + it('reports a non-Error rejection as an unknown failure', async () => { + await expect( + runWebFetchResult({ + provider: { + fetch: async () => { + throw 404; + }, + id: 'weird', + }, + query: { url: 'https://a.test' }, + }), + ).resolves.toEqual({ content: expect.stringContaining('unknown error') }); + }); + }); + + it('runWebFetch returns only the text', async () => { + await expect( + runWebFetch({ + provider: { + fetch: async () => ({ content: 'the text', url: 'https://a.test' }), + id: 'stub', + }, + query: { url: 'https://a.test' }, + }), + ).resolves.toBe('the text'); + }); +}); + +// --------------------------------------------------------------------------- +// CodeBuddy search backend +// --------------------------------------------------------------------------- + +describe('CodeBuddy search provider', () => { + const provider = ( + options: { maxResults?: number; timeoutMs?: number } = {}, + ) => + createCodeBuddySearchProvider({ + maxResults: options.maxResults, + resolveEndpoint: endpointResolver, + resolveToken: tokenResolver, + timeoutMs: options.timeoutMs, + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('identifies itself', () => { + expect(provider().id).toBe('codebuddy'); + }); + + it('posts to the agent-tool search endpoint', async () => { + const { calls } = stubJsonFetch({ results: [] }); + + await provider().search('latest news'); + + // The endpoint keeps a trailing slash in settings; the path is appended. + expect(calls[0].url).toBe('https://agent.test/agenttool/v1/search'); + expect(calls[0].init.method).toBe('POST'); + expect(calls[0].init.cache).toBe('no-store'); + expect(calls[0].init.signal).toBeInstanceOf(AbortSignal); + }); + + it('sends the credential and the CLI headers', async () => { + const { calls } = stubJsonFetch({ results: [] }); + + await provider().search('q'); + const headers = readHeaders(calls[0]); + + expect(headers.get('authorization')).toBe(`Bearer ${TOKEN}`); + expect(headers.get('content-type')).toBe('application/json;charset=UTF-8'); + expect(headers.get('accept')).toBe('application/json'); + expect(headers.get('x-requested-with')).toBe('XMLHttpRequest'); + }); + + it('sends the query, result count, and request type', async () => { + const { calls } = stubJsonFetch({ results: [] }); + + await provider().search('latest news'); + + expect(readJsonBody(calls[0])).toEqual({ + max_results: 5, + query: 'latest news', + type: 'text2text', + }); + }); + + it('parses results into the shared shape', async () => { + stubJsonFetch({ + results: [ + { + snippet: ' Snippet one ', + title: ' First ', + url: ' https://a.test ', + }, + { content: 'From content', title: 'Second', url: 'https://b.test' }, + { title: 'No body', url: 'https://c.test' }, + ], + }); + + const result = await provider().search('q'); + + expect(result.results).toEqual([ + { content: 'Snippet one', title: 'First', url: 'https://a.test' }, + { content: 'From content', title: 'Second', url: 'https://b.test' }, + { content: undefined, title: 'No body', url: 'https://c.test' }, + ]); + expect(result.content).toContain('First'); + expect(result.content).toContain('Snippet one'); + }); + + it('leaves a title and url it was not given undefined', async () => { + // A hit with only a body still has to render, so the entry cannot be + // dropped for want of a title. + stubJsonFetch({ results: [{ snippet: 'body only' }] }); + + const result = await provider().search('q'); + + expect(result.results).toEqual([ + { content: 'body only', title: undefined, url: undefined }, + ]); + expect(result.content).toContain('(untitled)'); + }); + + it('drops entries that are not objects and caps the count', async () => { + stubJsonFetch({ + results: [ + null, + 'not an object', + 7, + ...Array.from({ length: 12 }, (_, index) => ({ + title: `Result ${index}`, + url: `https://${index}.test`, + })), + ], + }); + + const result = await provider({ maxResults: 10 }).search('q'); + + expect(result.results).toHaveLength(10); + expect(result.results[0].title).toBe('Result 0'); + }); + + it('clamps the requested result count', async () => { + const zero = stubJsonFetch({ results: [] }); + await provider({ maxResults: 0 }).search('q'); + expect(readJsonBody(zero.calls[0]).max_results).toBe(1); + + const huge = stubJsonFetch({ results: [] }); + await provider({ maxResults: 99 }).search('q'); + expect(readJsonBody(huge.calls[0]).max_results).toBe(10); + }); + + it('truncates an over-long query', async () => { + const { calls } = stubJsonFetch({ results: [] }); + + await provider().search('x'.repeat(600)); + + expect(readJsonBody(calls[0]).query).toHaveLength(500); + }); + + it('skips the request for a blank query', async () => { + const { calls } = stubJsonFetch({ results: [] }); + + const result = await provider().search(' '); + + expect(calls).toHaveLength(0); + expect(result.content).toContain('without a query'); + expect(result.results).toEqual([]); + }); + + it('refuses to run without a token', async () => { + const { calls } = stubJsonFetch({ results: [] }); + const anonymous = createCodeBuddySearchProvider({ + resolveEndpoint: endpointResolver, + resolveToken: async () => null, + }); + + await expect(anonymous.search('q')).rejects.toThrow( + 'Authentication required', + ); + expect(calls).toHaveLength(0); + }); + + it('treats a whitespace-only token as no token', async () => { + const blank = createCodeBuddySearchProvider({ + resolveEndpoint: endpointResolver, + resolveToken: async () => ' ', + }); + + await expect(blank.search('q')).rejects.toThrow('Authentication required'); + }); + + it('reports an HTTP failure that carries a JSON message', async () => { + stubJsonFetch({ code: 7, msg: 'upstream down' }, 500); + + await expect(provider().search('q')).rejects.toThrow( + 'CodeBuddy web search error: upstream down (code: 7)', + ); + }); + + it('names an unknown code when the message carries none', async () => { + stubJsonFetch({ msg: 'upstream down' }, 500); + + await expect(provider().search('q')).rejects.toThrow( + 'CodeBuddy web search error: upstream down (code: unknown)', + ); + }); + + it('reports an HTTP failure with no usable message', async () => { + stubJsonFetch({ code: 7 }, 502); + await expect(provider().search('q')).rejects.toThrow('HTTP 502'); + + vi.unstubAllGlobals(); + stubFetch(async () => makeTextResponse('not json', 503)); + await expect(provider().search('q')).rejects.toThrow('HTTP 503'); + + vi.unstubAllGlobals(); + stubFetch(async () => makeTextResponse('', 504)); + await expect(provider().search('q')).rejects.toThrow('HTTP 504'); + }); + + it('falls back to the status when the error body cannot be read', async () => { + // A body that fails to decode must not mask the status. + stubFetch( + async () => + ({ + ok: false, + status: 500, + text: async () => { + throw new Error('body already consumed'); + }, + }) as unknown as Response, + ); + + await expect(provider().search('q')).rejects.toThrow( + 'CodeBuddy web search failed with HTTP 500', + ); + }); + + it('abandons a request that outlasts the timeout', async () => { + // The endpoint is on the critical path of a model turn, so a hanging + // search has to fail on its own rather than wait for the socket. + stubFetch( + (_url, init) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }), + ); + + await expect(provider({ timeoutMs: 1_000 }).search('q')).rejects.toThrow( + 'aborted', + ); + }); + + it('reports an error code in a successful response', async () => { + stubJsonFetch({ code: 3, msg: 'bad request' }); + + await expect(provider().search('q')).rejects.toThrow( + 'CodeBuddy web search error: bad request', + ); + }); + + it('names an unknown error code when no message came back', async () => { + stubJsonFetch({ code: 3 }); + + await expect(provider().search('q')).rejects.toThrow('Unknown error'); + }); + + it('tolerates a payload with no results', async () => { + stubJsonFetch({}); + + const result = await provider().search('q'); + + expect(result.results).toEqual([]); + expect(result.content).toContain('returned no results'); + }); + + it('tolerates a results field that is not an array', async () => { + stubJsonFetch({ results: 'nope' }); + + const result = await provider().search('q'); + + expect(result.results).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// CodeBuddy fetch backend +// --------------------------------------------------------------------------- + +describe('CodeBuddy fetch provider', () => { + const provider = ( + options: { + maxContentLength?: number; + resolveHost?: HostResolver; + timeoutMs?: number; + } = {}, + ) => + createCodeBuddyFetchProvider({ + maxContentLength: options.maxContentLength, + resolveEndpoint: endpointResolver, + resolveHost: options.resolveHost ?? blockedResolver, + resolveToken: tokenResolver, + timeoutMs: options.timeoutMs, + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe('normalizeFetchUrl', () => { + it('trims the url', () => { + expect(normalizeFetchUrl(' https://a.test/ ')).toBe('https://a.test/'); + }); + + it('upgrades plain http to https', () => { + expect(normalizeFetchUrl('http://a.test/page')).toBe( + 'https://a.test/page', + ); + }); + + it('rewrites a GitHub blob url to its raw counterpart', () => { + expect( + normalizeFetchUrl('https://github.com/org/repo/blob/main/README.md'), + ).toBe('https://raw.githubusercontent.com/org/repo/main/README.md'); + }); + + it('upgrades and rewrites in one pass', () => { + expect( + normalizeFetchUrl('http://github.com/org/repo/blob/main/a.ts'), + ).toBe('https://raw.githubusercontent.com/org/repo/main/a.ts'); + }); + + it('leaves a url that is neither plain http nor a blob alone', () => { + expect(normalizeFetchUrl('https://a.test/blob/not-github')).toBe( + 'https://a.test/blob/not-github', + ); + expect(normalizeFetchUrl('https://github.com/org/repo/tree/main')).toBe( + 'https://github.com/org/repo/tree/main', + ); + }); + }); + + it('identifies itself', () => { + expect(provider().id).toBe('codebuddy'); + }); + + it('posts to the agent-tool webfetch endpoint', async () => { + const { calls } = stubJsonFetch({ + content: 'page text', + content_type: 'text/markdown', + url: 'https://final.test/page', + }); + + const result = await provider().fetch({ url: 'https://a.test/page' }); + + expect(calls[0].url).toBe('https://agent.test/agenttool/v1/webfetch'); + expect(calls[0].init.method).toBe('POST'); + expect(calls[0].init.cache).toBe('no-store'); + expect(readHeaders(calls[0]).get('authorization')).toBe(`Bearer ${TOKEN}`); + expect(result.url).toBe('https://final.test/page'); + expect(result.content).toContain( + 'Web fetch result for https://final.test/page:', + ); + expect(result.content).toContain('page text'); + }); + + it('sends the extraction hint, format, and bound the CLI sends', async () => { + const { calls } = stubJsonFetch({ content: 'page text' }); + + await provider().fetch({ + prompt: ' the pricing tiers ', + url: 'https://a.test/page', + }); + + expect(readJsonBody(calls[0])).toEqual({ + format: 'markdown', + max_length: 100_000, + prompt: 'the pricing tiers', + timeout: 30, + url: 'https://a.test/page', + }); + }); + + it('asks for the whole page when the model gives no prompt', async () => { + const { calls } = stubJsonFetch({ content: 'page text' }); + + await provider().fetch({ url: 'https://a.test/page' }); + + expect(readJsonBody(calls[0]).prompt).toBe(''); + }); + + it('truncates an over-long prompt', async () => { + const { calls } = stubJsonFetch({ content: 'page text' }); + + await provider().fetch({ prompt: 'p'.repeat(600), url: 'https://a.test/' }); + + expect(readJsonBody(calls[0]).prompt).toHaveLength(500); + }); + + it('sends the normalized url and truncates an over-long one', async () => { + const { calls } = stubJsonFetch({ content: 'page text' }); + + await provider().fetch({ + url: `http://github.com/org/repo/blob/main/${'a'.repeat(3_000)}`, + }); + + const body = readJsonBody(calls[0]); + expect(body.url).toHaveLength(2_048); + expect( + String(body.url).startsWith('https://raw.githubusercontent.com/'), + ).toBe(true); + }); + + it('honours a configured content limit', async () => { + const { calls } = stubJsonFetch({ content: 'x'.repeat(200) }); + + const result = await provider({ maxContentLength: 10 }).fetch({ + url: 'https://a.test/', + }); + + expect(readJsonBody(calls[0]).max_length).toBe(10); + expect(result.content).toContain('x'.repeat(10)); + expect(result.content).not.toContain('x'.repeat(11)); + }); + + it('falls back to the requested url when the endpoint reports none', async () => { + stubJsonFetch({ content: 'page text', url: ' ' }); + + const result = await provider().fetch({ url: 'https://a.test/page' }); + + expect(result.url).toBe('https://a.test/page'); + }); + + it('skips the request for a blank url', async () => { + const { calls } = stubJsonFetch({ content: 'page text' }); + + const result = await provider().fetch({ url: ' ' }); + + expect(calls).toHaveLength(0); + expect(result.content).toContain('without a URL'); + }); + + it('refuses to run without a token', async () => { + const { calls } = stubJsonFetch({ content: 'page text' }); + const anonymous = createCodeBuddyFetchProvider({ + resolveEndpoint: endpointResolver, + resolveHost: publicResolver, + resolveToken: async () => ' ', + }); + + await expect(anonymous.fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'Authentication required', + ); + expect(calls).toHaveLength(0); + }); + + it('reports an HTTP failure that carries a JSON message', async () => { + stubJsonFetch({ code: 9, msg: 'rate limited' }, 429); + + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'CodeBuddy web fetch error: rate limited (code: 9)', + ); + }); + + it('reports an HTTP failure with no usable message', async () => { + stubJsonFetch({ code: 9 }, 500); + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'HTTP 500', + ); + + vi.unstubAllGlobals(); + stubFetch(async () => makeTextResponse('not json', 502)); + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'HTTP 502', + ); + + vi.unstubAllGlobals(); + stubFetch(async () => makeTextResponse('', 503)); + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'HTTP 503', + ); + }); + + it('names an unknown code when the message carries none', async () => { + stubJsonFetch({ msg: 'rate limited' }, 429); + + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'CodeBuddy web fetch error: rate limited (code: unknown)', + ); + }); + + it('falls back to the status when the error body cannot be read', async () => { + stubFetch( + async () => + ({ + ok: false, + status: 500, + text: async () => { + throw new Error('body already consumed'); + }, + }) as unknown as Response, + ); + + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'CodeBuddy web fetch failed with HTTP 500', + ); + }); + + it('reports an error code in a successful response', async () => { + stubJsonFetch({ code: 4, msg: 'no such page' }); + + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'CodeBuddy web fetch error: no such page', + ); + }); + + it('names an unknown error code when no message came back', async () => { + stubJsonFetch({ code: 4 }); + + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'Unknown error', + ); + }); + + describe('resource classification', () => { + it.each([ + { content_type: '', kind: 'text' }, + { content_type: 'text/html; charset=utf-8', kind: 'text' }, + { content_type: 'TEXT/PLAIN', kind: 'text' }, + { content_type: 'application/json', kind: 'text' }, + { content_type: 'application/xml', kind: 'text' }, + { content_type: 'application/javascript', kind: 'text' }, + { content_type: 'image/png', kind: 'binary' }, + { content_type: 'application/pdf', kind: 'binary' }, + { content_type: 'application/zip', kind: 'binary' }, + { content_type: 'application/octet-stream', kind: 'binary' }, + { content_type: 'video/mp4', kind: 'binary' }, + { content_type: 'audio/mpeg', kind: 'binary' }, + ])('treats $content_type as $kind', async ({ content_type, kind }) => { + stubJsonFetch({ content: 'page text', content_type }); + + if (kind === 'text') { + await expect( + provider().fetch({ url: 'https://a.test/' }), + ).resolves.toEqual(expect.objectContaining({ url: 'https://a.test/' })); + + return; + } + + // The endpoint's reason survives into the combined failure. + await expect( + provider().fetch({ url: 'https://a.test/' }), + ).rejects.toThrow('non-text resource'); + }); + + it('treats a missing content type as text', async () => { + stubJsonFetch({ content: 'page text' }); + + await expect( + provider().fetch({ url: 'https://a.test/' }), + ).resolves.toMatchObject({ url: 'https://a.test/' }); + }); + }); + + it('rejects a body that is not a string', async () => { + // A payload whose `content` is not text has nothing to hand the model. + stubJsonFetch({ content: 42 }); + + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'found no readable content', + ); + }); + + it('rejects an empty body from the endpoint', async () => { + stubJsonFetch({ content: ' \n ' }); + + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'found no readable content', + ); + }); + + describe('local fallback', () => { + it('uses the local result when the endpoint fails', async () => { + stubJsonFetch({ msg: 'unauthorized' }, 401); + installTransport(({ respond }) => { + respond({ + body: 'local page text', + headers: { 'content-type': 'text/plain' }, + }); + }); + + const result = await provider({ resolveHost: publicResolver }).fetch({ + prompt: 'the release date', + url: 'http://a.test/page', + }); + + expect(result.content).toContain('local page text'); + // The local fetch gets the url as given, not the https upgrade. + expect(result.url).toBe('http://a.test/page'); + }); + + it('reports an endpoint failure that is not an Error', async () => { + stubFetch(async () => { + throw 'endpoint fell over'; + }); + + await expect( + provider({ resolveHost: publicResolver }).fetch({ + url: 'https://a.test/', + }), + ).rejects.toThrow('endpoint fell over'); + }); + + it('abandons a request that outlasts the timeout', async () => { + stubFetch( + (_url, init) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => + reject( + Object.assign(new Error('aborted'), { name: 'AbortError' }), + ), + ); + }), + ); + + await expect( + provider({ timeoutMs: 1_000 }).fetch({ url: 'https://a.test/' }), + ).rejects.toThrow('aborted'); + }); + + it('reports a local failure that is not an Error', async () => { + stubJsonFetch({ msg: 'unauthorized' }, 401); + installTransport(({ request }) => { + request.emitError('local fell over'); + }); + + await expect( + provider({ resolveHost: publicResolver }).fetch({ + url: 'https://a.test/', + }), + ).rejects.toThrow('local fallback also failed: local fell over'); + }); + + it('reports both reasons when the local fallback also fails', async () => { + stubJsonFetch({ msg: 'unauthorized' }, 401); + installTransport(({ request }) => { + request.emitError(new Error('connection reset')); + }); + + await expect( + provider({ resolveHost: publicResolver }).fetch({ + url: 'https://a.test/', + }), + ).rejects.toThrow('local fallback also failed: connection reset'); + }); + + it('reports one reason when both attempts agree', async () => { + const reason = 'CodeBuddy web fetch error: boom (code: unknown)'; + stubJsonFetch({ msg: 'boom' }, 500); + installTransport(({ request }) => { + request.emitError(new Error(reason)); + }); + + await expect( + provider({ resolveHost: publicResolver }).fetch({ + url: 'https://a.test/', + }), + ).rejects.toThrow(reason); + }); + + it('reports a failed local fetch that returned a status', async () => { + stubJsonFetch({ msg: 'unauthorized' }, 401); + const calls = installTransport(({ respond }) => { + respond({ statusCode: 500 }); + }); + + await expect( + provider({ resolveHost: publicResolver }).fetch({ + url: 'https://a.test/', + }), + ).rejects.toThrow(/local fallback also failed/); + expect(calls).toHaveLength(1); + }); + + it('prefers the endpoint result when both succeed', async () => { + stubJsonFetch({ content: 'endpoint text' }); + const calls = installTransport(({ respond }) => { + respond({ + body: 'local text', + headers: { 'content-type': 'text/plain' }, + }); + }); + + const result = await provider({ resolveHost: publicResolver }).fetch({ + url: 'https://a.test/', + }); + + expect(result.content).toContain('endpoint text'); + expect(result.content).not.toContain('local text'); + + // The abandoned local attempt is allowed to finish; wait for it so the + // request it makes is not recorded against the next test. + await settle(() => calls.length === 1); + expect(calls).toHaveLength(1); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Local fetch backend +// --------------------------------------------------------------------------- + +describe('local fetch provider', () => { + const provider = ( + options: { maxContentLength?: number; timeoutMs?: number } = {}, + ) => + createLocalFetchProvider({ + maxContentLength: options.maxContentLength, + resolveHost: publicResolver, + timeoutMs: options.timeoutMs, + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('identifies itself', () => { + expect(provider().id).toBe('local'); + }); + + it('reports a missing url without fetching', async () => { + const calls = installTransport(({ respond }) => respond()); + + const result = await provider().fetch({ url: ' ' }); + + expect(calls).toHaveLength(0); + expect(result.content).toContain('without a URL'); + }); + + it('reports a url that is not absolute', async () => { + const calls = installTransport(({ respond }) => respond()); + + const result = await provider().fetch({ url: 'example.test/page' }); + + expect(calls).toHaveLength(0); + expect(result.content).toContain('is not a valid absolute URL'); + }); + + it.each(['file:///etc/passwd', 'data:text/plain,hello', 'ftp://a.test/f'])( + 'refuses the %s scheme', + async (url) => { + const calls = installTransport(({ respond }) => respond()); + + await expect(provider().fetch({ url })).rejects.toThrow( + 'Unsupported URL protocol', + ); + expect(calls).toHaveLength(0); + }, + ); + + it.each([ + 'http://localhost/admin', + 'http://api.localhost/admin', + 'http://127.0.0.1:8080/admin', + 'http://10.1.2.3/admin', + 'http://172.16.0.1/admin', + 'http://192.168.1.1/admin', + 'http://169.254.169.254/latest/meta-data', + 'http://0.0.0.0/admin', + 'http://[::1]/admin', + 'http://[fe80::1]/admin', + 'http://[fd00::1]/admin', + ])('refuses to fetch the private host %s', async (url) => { + const calls = installTransport(({ respond }) => respond()); + + await expect(provider().fetch({ url })).rejects.toThrow( + /Refusing to fetch a private or loopback address/, + ); + expect(calls).toHaveLength(0); + }); + + it('allows a public literal address', async () => { + const calls = installTransport(({ respond }) => + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }), + ); + + const result = await provider().fetch({ url: 'http://8.8.8.8/dns' }); + + expect(calls).toHaveLength(1); + expect(result.content).toContain('ok'); + }); + + it('allows a public IPv6 literal address', async () => { + const calls = installTransport(({ respond }) => + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }), + ); + + const result = await provider().fetch({ + url: 'http://[2001:4860:4860::8888]/dns', + }); + + expect(calls).toHaveLength(1); + // The brackets are stripped before the address is pinned. + expect(calls[0].options.lookup).toBeDefined(); + expect(result.content).toContain('ok'); + }); + + it('resolves the host through DNS when no resolver is injected', async () => { + const lookup = vi + .spyOn(dns, 'lookup') + .mockResolvedValue([ + { address: '93.184.216.34', family: 4 }, + ] as unknown as Awaited>); + installTransport(({ respond }) => + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }), + ); + + const result = await createLocalFetchProvider().fetch({ + url: 'http://a.test/page', + }); + + expect(lookup).toHaveBeenCalledWith('a.test', { all: true }); + expect(result.content).toContain('ok'); + }); + + it('allows a host whose addresses are all public', async () => { + const resolver: HostResolver = async () => [ + '93.184.216.34', + '93.184.216.35', + ]; + const calls = installTransport(({ respond }) => + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }), + ); + + await expect( + createLocalFetchProvider({ resolveHost: resolver }).fetch({ + url: 'http://a.test/', + }), + ).resolves.toMatchObject({ url: 'http://a.test/' }); + expect(calls).toHaveLength(1); + }); + + it('refuses a host with any private address', async () => { + const resolver: HostResolver = async () => ['93.184.216.34', '127.0.0.1']; + installTransport(({ respond }) => respond()); + + await expect( + createLocalFetchProvider({ resolveHost: resolver }).fetch({ + url: 'http://a.test/', + }), + ).rejects.toThrow('it resolves to the private address 127.0.0.1'); + }); + + it('trusts an operator-supplied override', async () => { + // Pinning a name to a private address is the point of an override. + const calls = installTransport(({ respond }) => + respond({ body: 'internal', headers: { 'content-type': 'text/plain' } }), + ); + + const result = await createLocalFetchProvider({ + resolveHost: trustedResolver, + }).fetch({ url: 'http://internal.test/' }); + + expect(result.content).toContain('internal'); + expect(calls).toHaveLength(1); + }); + + it('reports a host that cannot be resolved', async () => { + const failing: HostResolver = async () => { + throw new Error('ENOTFOUND'); + }; + installTransport(({ respond }) => respond()); + + await expect( + createLocalFetchProvider({ resolveHost: failing }).fetch({ + url: 'http://a.test/', + }), + ).rejects.toThrow('could not resolve host: a.test'); + }); + + it('reports a host that resolves to nothing', async () => { + const empty: HostResolver = async () => []; + installTransport(({ respond }) => respond()); + + await expect( + createLocalFetchProvider({ resolveHost: empty }).fetch({ + url: 'http://a.test/', + }), + ).rejects.toThrow('could not resolve host: a.test'); + }); + + describe('the pinned request', () => { + it('pins the socket to the validated address', async () => { + const calls = installTransport(({ respond }) => + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }), + ); + + await provider().fetch({ url: 'http://a.test/page?q=1' }); + + const [call] = calls; + expect(call.options.host).toBe('a.test'); + expect(call.options.method).toBe('GET'); + expect(call.options.path).toBe('/page?q=1'); + expect(call.options.port).toBe(80); + // The Host header keeps the real name, so virtual hosting survives. + expect(call.options.servername).toBeUndefined(); + expect(call.options.headers?.['User-Agent']).toContain('Mozilla/5.0'); + expect(call.options.headers?.Accept).toContain('text/html'); + expect(call.options.headers?.['Accept-Language']).toBe('en-US,en;q=0.9'); + + const lookup = call.options.lookup as PinnedLookup; + const seen: unknown[] = []; + lookup('a.test', { all: true }, (error, address) => + seen.push({ address, error }), + ); + lookup('a.test', {}, (error, address, family) => + seen.push({ address, error, family }), + ); + + expect(seen).toEqual([ + { address: [{ address: '93.184.216.34', family: 4 }], error: null }, + { address: '93.184.216.34', error: null, family: 4 }, + ]); + }); + + it('uses https with its port and servername', async () => { + const calls = installTransport(({ respond }) => + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }), + ); + + await provider().fetch({ url: 'https://a.test/secure' }); + + expect(calls[0].options.port).toBe(443); + // TLS needs the real hostname for SNI and certificate checks. + expect(calls[0].options.servername).toBe('a.test'); + }); + + it('honours an explicit port', async () => { + const calls = installTransport(({ respond }) => + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }), + ); + + await provider().fetch({ url: 'https://a.test:8443/secure' }); + + expect(calls[0].options.port).toBe(8443); + }); + }); + + it('returns the page text with the source url', async () => { + installTransport(({ respond }) => + respond({ body: 'page body', headers: { 'content-type': 'text/plain' } }), + ); + + const result = await provider().fetch({ + prompt: 'the release date', + url: 'http://a.test/page', + }); + + expect(result.url).toBe('http://a.test/page'); + expect(result.content).toContain( + 'Web fetch result for http://a.test/page:', + ); + expect(result.content).toContain('Requested focus: the release date'); + expect(result.content).toContain('page body'); + }); + + it('converts HTML to readable text', async () => { + installTransport(({ respond }) => + respond({ + body: 'T

Hello

One & two

', + headers: { 'content-type': 'text/html' }, + }), + ); + + const result = await provider().fetch({ url: 'http://a.test/page' }); + + expect(result.content).toContain('Hello'); + expect(result.content).toContain('One & two'); + expect(result.content).not.toContain('bad()'); + expect(result.content).not.toContain('hidden'); + expect(result.content).not.toContain('a{}'); + }); + + it('decodes numeric character references', async () => { + installTransport(({ respond }) => + respond({ + // `�` has no character of its own, so it decodes to a space. + body: '

AAB C'D�E

', + headers: { 'content-type': 'text/html' }, + }), + ); + + const result = await provider().fetch({ url: 'http://a.test/page' }); + + expect(result.content).toContain("AAB C'D E"); + }); + + it('reads a stream that delivers strings rather than buffers', async () => { + installTransport(({ respond }) => { + const response = respond({ endStream: false }); + setTimeout(() => { + response.emit('data', 'plain string body'); + response.emit('end'); + }, 0); + }); + + const result = await provider().fetch({ url: 'http://a.test/page' }); + + expect(result.content).toContain('plain string body'); + }); + + it('detects HTML when the server sends no content type', async () => { + installTransport(({ respond }) => + respond({ body: '

Hi

' }), + ); + + const result = await provider().fetch({ url: 'http://a.test/page' }); + + expect(result.content).toContain('Hi'); + expect(result.content).not.toContain('

'); + }); + + it('keeps plain text as it is', async () => { + installTransport(({ respond }) => + respond({ + body: ' # Title\n\nNot html ', + headers: { 'content-type': 'text/markdown' }, + }), + ); + + const result = await provider().fetch({ url: 'http://a.test/page' }); + + expect(result.content).toContain('# Title'); + }); + + it.each(['text/html', 'application/json', 'application/xml', 'text/plain'])( + 'reads the %s content type', + async (contentType) => { + installTransport(({ respond }) => + respond({ + body: 'body text', + headers: { 'content-type': contentType }, + }), + ); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).resolves.toMatchObject({ url: 'http://a.test/page' }); + }, + ); + + it.each(['image/png', 'application/pdf', 'application/octet-stream'])( + 'refuses the %s content type', + async (contentType) => { + installTransport(({ respond }) => + respond({ body: 'binary', headers: { 'content-type': contentType } }), + ); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow(`unsupported content type ${contentType}`); + }, + ); + + it('treats a missing content type as text', async () => { + // `isTextContentType` allows an empty type through, so the refusal's + // `|| 'unknown'` fallback is unreachable and is not asserted here. + installTransport(({ respond }) => respond({ body: 'page text' })); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).resolves.toMatchObject({ url: 'http://a.test/page' }); + }); + + it('truncates a long page to the content limit', async () => { + installTransport(({ respond }) => + respond({ + body: 'a'.repeat(2_000), + headers: { 'content-type': 'text/plain' }, + }), + ); + + const result = await provider({ maxContentLength: 32 }).fetch({ + url: 'http://a.test/page', + }); + + expect(result.content).toContain('a'.repeat(32)); + expect(result.content).not.toContain('a'.repeat(33)); + }); + + it('reports a page with no readable content', async () => { + installTransport(({ respond }) => + respond({ body: ' \n\t ', headers: { 'content-type': 'text/plain' } }), + ); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow('found no readable content at http://a.test/page'); + }); + + it.each([301, 302, 303, 307, 308])( + 'follows a %s redirect and re-validates the target', + async (status) => { + const calls = installTransport(({ call, respond }) => { + if (call === 0) { + respond({ + headers: { location: '/next' }, + statusCode: status, + }); + + return; + } + + respond({ + body: 'target text', + headers: { 'content-type': 'text/plain' }, + }); + }); + + const result = await provider().fetch({ url: 'http://a.test/page' }); + + expect(calls.map((call) => call.options.path)).toEqual([ + '/page', + '/next', + ]); + expect(result.url).toBe('http://a.test/next'); + expect(result.content).toContain('target text'); + }, + ); + + it('refuses a redirect that points at a private address', async () => { + const calls = installTransport(({ respond }) => + respond({ + headers: { location: 'http://localhost/admin' }, + statusCode: 302, + }), + ); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow('Refusing to fetch a private or loopback address'); + expect(calls).toHaveLength(1); + }); + + it('reports a redirect with no target', async () => { + installTransport(({ respond }) => respond({ statusCode: 302 })); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow('redirect with no target'); + }); + + it('uses the first of several location headers', async () => { + const calls = installTransport(({ call, respond }) => { + if (call === 0) { + respond({ + headers: { location: ['/first', '/second'] }, + statusCode: 301, + }); + + return; + } + + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }); + }); + + const result = await provider().fetch({ url: 'http://a.test/page' }); + + expect(calls[1].options.path).toBe('/first'); + expect(result.url).toBe('http://a.test/first'); + }); + + it('gives up after too many redirects', async () => { + const calls = installTransport(({ respond }) => + respond({ headers: { location: '/next' }, statusCode: 302 }), + ); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow('followed more than 5 redirects'); + // Six hops are attempted: the first request plus one per redirect slot. + expect(calls).toHaveLength(6); + }); + + it('reports an HTTP failure', async () => { + installTransport(({ respond }) => respond({ statusCode: 404 })); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow('Web fetch failed with HTTP 404 for http://a.test/page'); + }); + + it('reports a response with no status code as a failure', async () => { + installTransport(({ respond }) => respond({ statusCode: undefined })); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow('Web fetch failed with HTTP 0'); + }); + + it('reports a socket error', async () => { + installTransport(({ request }) => { + request.emitError(new Error('socket hang up')); + }); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow('socket hang up'); + }); + + it('gives up when the response never arrives', async () => { + installTransport(() => undefined); + + await expect( + provider({ timeoutMs: 1_000 }).fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow('Web fetch timed out after 1000ms'); + }); + + it('stops reading a body that stalls mid-stream', async () => { + let response: FakeResponse | undefined; + installTransport(({ respond }) => { + response = respond({ body: 'start', endStream: false }); + }); + + // The response arrived but never ends, so only the socket timeout can + // release the reader. + const attempt = provider({ timeoutMs: 1_000 }).fetch({ + url: 'http://a.test/page', + }); + await tick(); + expect(response?.idleTimeoutListener).not.toBeNull(); + response?.idleTimeoutListener?.(); + + await expect(attempt).rejects.toThrow('Web fetch timed out after 1000ms'); + }); + + it('ignores a socket error that arrives after the response', async () => { + installTransport(({ request, respond }) => { + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }); + request.emitError(new Error('late error')); + }); + + // The request already answered, so a late error on the socket is not the + // caller's problem. + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).resolves.toMatchObject({ url: 'http://a.test/page' }); + }); + + it('ignores a second response on the same request', async () => { + installTransport(({ respond }) => { + respond({ body: 'first', headers: { 'content-type': 'text/plain' } }); + respond({ body: 'second', headers: { 'content-type': 'text/plain' } }); + }); + + const result = await provider().fetch({ url: 'http://a.test/page' }); + + expect(result.content).toContain('first'); + expect(result.content).not.toContain('second'); + }); + + it('truncates an over-long url before parsing it', async () => { + const calls = installTransport(({ respond }) => + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }), + ); + + await provider().fetch({ url: `http://a.test/${'p'.repeat(3_000)}` }); + + // The cap applies to the whole url, so the path keeps what is left. + expect(calls[0].options.path).toHaveLength( + 2_048 - 'http://a.test/'.length + 1, + ); + }); +}); diff --git a/tests/server/server-tools.test.ts b/tests/server/server-tools.test.ts index 4af81ee..4d12c8d 100644 --- a/tests/server/server-tools.test.ts +++ b/tests/server/server-tools.test.ts @@ -1,2259 +1,602 @@ -import fs from 'node:fs'; -import http from 'node:http'; -import path from 'node:path'; - -import { NextRequest } from 'next/server'; - -import { - getActiveConfig, - isWebFetchEnabled, - isWebSearchEnabled, - updateSettings, -} from '@/lib/server/domain/config'; -import { - createCodeBuddyFetchProvider, - normalizeFetchUrl, -} from '@/lib/server/search/providers/codebuddy-fetch'; -import { createCodeBuddySearchProvider } from '@/lib/server/search/providers/codebuddy-search'; -import { - createLocalFetchProvider, - type HostResolver, -} from '@/lib/server/search/providers/local-fetch'; -import { - normalizeFetchBackend, - normalizeSearchBackend, - resetWebSearchProviders, - resolveFetchProvider, - resolveSearchProvider, - runWebFetch, - runWebSearch, -} from '@/lib/server/search'; -import { - createProxyContextFromCredential, - proxyChatCompletions, -} from '@/lib/server/proxy/codebuddy'; -import { - addCredential, - resetCredentialRuntimeState, -} from '@/lib/server/domain/credentials'; -import { resetUsageStats } from '@/lib/server/domain/stats'; -import type { ChatRequestBody } from '@/lib/server/proxy/codebuddy'; import { - buildWebFetchToolDefinition, - buildWebSearchToolDefinition, - isMarkedServerTool, - normalizeToolName, - stripServerToolMarker, -} from '@/lib/server/search/tool'; -import { executeWebSearchLoop } from '@/lib/server/proxy/web-search-loop'; -import { translateResponsesToolsToChat } from '@/lib/server/proxy/responses'; -import { - pickCredentialToken, - resolveCodeBuddyToken, - withCodeBuddyToken, -} from '@/lib/server/search/token'; + classifyServerToolDeclaration, + findServerToolDeclarations, + foldIntermediateTexts, + getForcedToolName, + hasAmbiguousServerToolName, + hasExecutableServerTool, + rewriteServerTools, + runServerToolTurn, + type ServerToolInvocation, +} from '@/lib/server/proxy/server-tools'; +import type { + WebFetchProvider, + WebSearchProvider, +} from '@/lib/server/search/types'; /** - * Settings persist to storage, and the storage directory defaults to the - * process working directory — so writes here would leak into any test file that - * runs later in the same worker. Pointing storage at a scratch directory keeps - * this file's settings to itself. + * The classifier is the whole fix, so it is tested on the distinction that was + * broken rather than on the happy path alone. + * + * `normalizeToolName` strips case and separators, so `WebSearch` — the ordinary + * function Claude Code declares and resolves itself — and `web_search` — the + * provider-executed server tool — become the same string. Matching on the name + * made the proxy answer Claude Code's own calls, so the `tool_use` block it was + * waiting for never arrived. Only the declared type can tell them apart. */ -const tempRootDir = path.join(process.cwd(), '.tmp-test-server-tools-root'); -const tempDataDir = path.join(tempRootDir, '.codebuddy_data'); -const tempCredsDir = path.join(tempRootDir, '.codebuddy_creds'); -const cleanupDir = (): void => { - fs.rmSync(tempRootDir, { force: true, recursive: true }); +const SEARCH_TYPE = 'web_search_20250305'; +const FETCH_TYPE = 'web_fetch_20250910'; + +const claudeCodeWebSearch = { + name: 'WebSearch', + description: 'Search the web', + input_schema: { type: 'object' }, }; +const makeSearchProvider = ( + content = 'Search findings', +): WebSearchProvider => ({ + id: 'test-search', + search: async () => ({ + content, + results: [ + { content: 'A snippet', title: 'Docs', url: 'https://docs.test' }, + ], + }), +}); + +const makeFetchProvider = (): WebFetchProvider => ({ + id: 'test-fetch', + fetch: async ({ url }) => ({ content: `Fetched ${url}`, url }), +}); + const makeJsonResponse = ( payload: Record, status = 200, -): Response => { - return new Response(JSON.stringify(payload), { +): Response => + new Response(JSON.stringify(payload), { status, headers: { 'Content-Type': 'application/json' }, }); -}; - -type FetchCall = [string, RequestInit]; - -const lastFetchCall = (mock: ReturnType): FetchCall => - mock.mock.calls[mock.mock.calls.length - 1] as unknown as FetchCall; - -const stubFetch = (impl: (...args: unknown[]) => Promise) => { - const mock = vi.fn(impl as never); - - vi.stubGlobal('fetch', mock as unknown as typeof fetch); - - return mock; -}; - -const withCredential = async (): Promise => { - await addCredential({ - bearer_token: 'cred-token', - created_at: Math.floor(Date.now() / 1000), - supported_models: 'glm-5.1', - user_id: 'tester', - }); -}; - -/** - * Reads the loop's buffered payload, asserting the loop produced a response. - * - * `executeWebSearchLoop` legitimately returns a null response when no backend - * can run the declared tools, so assertions on the payload have to rule that - * out rather than reading through a nullable. - */ -const readPayload = async ( - result: { response: Response | null } | null, -): Promise> => { - if (!result?.response) { - throw new Error('Expected the server-tool loop to produce a response'); - } - - return (await result.response.json()) as Record; -}; - -describe('server tool backends', () => { - beforeEach(async () => { - resetWebSearchProviders(); - resetCredentialRuntimeState(); - cleanupDir(); - fs.mkdirSync(tempDataDir, { recursive: true }); - fs.mkdirSync(tempCredsDir, { recursive: true }); - vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); - }); - afterEach(async () => { - resetWebSearchProviders(); - resetCredentialRuntimeState(); - resetUsageStats(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - cleanupDir(); - }); - - describe('backend normalization', () => { - it.each([ - ['codebuddy', 'codebuddy'], - [' searxng ', 'searxng'], - ['PASSTHROUGH', 'passthrough'], - ])('accepts %s as a search backend', (input, expected) => { - expect(normalizeSearchBackend(input)).toBe(expected); - }); - - it('falls back to the default for an unknown search backend', () => { - expect(normalizeSearchBackend('bogus')).toBe('searxng'); - expect(normalizeSearchBackend(undefined)).toBe('searxng'); - }); - - it('falls back to the default for an unknown fetch backend', () => { - expect(normalizeFetchBackend('bogus')).toBe('passthrough'); - expect(normalizeFetchBackend(null)).toBe('passthrough'); - }); - - it('accepts the previous backend names', () => { - // An upgrade must not silently change which side runs the tool: `local` - // and `none` are how these were saved before the rename. - expect(normalizeFetchBackend('local')).toBe('codebuddy2api'); - expect(normalizeFetchBackend('none')).toBe('passthrough'); - expect(normalizeSearchBackend('none')).toBe('passthrough'); - }); +const assistantToolCall = ( + name: string, + args: string, + id = 'call_1', +): Record => ({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + role: 'assistant', + tool_calls: [ + { id, type: 'function', function: { arguments: args, name } }, + ], + }, + }, + ], + usage: { total_tokens: 10 }, +}); - it('resolves the renamed backends to the same providers', () => { +describe('server tool classification', () => { + describe('declarations', () => { + it('recognises an Anthropic dated server tool type', () => { expect( - resolveFetchProvider('local', async () => 'https://cb.test')?.id, - ).toBe('local'); - expect( - resolveFetchProvider('codebuddy2api', async () => 'https://cb.test') - ?.id, - ).toBe('local'); + classifyServerToolDeclaration({ + type: SEARCH_TYPE, + name: 'web_search', + max_uses: 8, + }), + ).toBe('web_search'); }); - it('resolves no provider when the backend is none', () => { - expect( - resolveSearchProvider('none', async () => 'https://cb.test'), - ).toBeNull(); + it('recognises a Responses preview server tool type', () => { expect( - resolveFetchProvider('none', async () => 'https://cb.test'), - ).toBeNull(); + classifyServerToolDeclaration({ type: 'web_search_preview' }), + ).toBe('web_search'); }); - it('resolves the local backend without any configuration', () => { + it('recognises a fetch server tool type', () => { expect( - resolveFetchProvider('local', async () => 'https://cb.test')?.id, - ).toBe('local'); - }); - }); - - describe('codebuddy search provider', () => { - it('posts to the agent-tool search path with the credential', async () => { - const mock = stubFetch(async () => - makeJsonResponse({ - provider: 'tencent', - results: [ - { - snippet: 'A snippet', - title: 'Docs', - url: 'https://docs.test', - }, - ], - total_results: 1, - }), - ); - - const result = await createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test/', - resolveToken: async () => 'token-123', - }).search('weather today'); - - const [url, init] = lastFetchCall(mock); - expect(url).toBe('https://cb.test/agenttool/v1/search'); - expect(init.method).toBe('POST'); - expect(new Headers(init.headers).get('Authorization')).toBe( - 'Bearer token-123', - ); - - const body = JSON.parse(String(init.body)) as Record; - expect(body.query).toBe('weather today'); - expect(body.type).toBe('text2text'); - expect(body.max_results).toBe(5); - - expect(result.results).toHaveLength(1); - expect(result.content).toContain('Docs'); - expect(result.content).toContain('https://docs.test'); - }); - - it('surfaces an error payload from the endpoint', async () => { - stubFetch(async () => makeJsonResponse({ code: 15001, msg: 'quota' })); - - await expect( - runWebSearch({ - backend: 'codebuddy', - provider: createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: 'hello', - }), - ).resolves.toContain('CodeBuddy web search error: quota'); - }); - - it('reports a non-ok HTTP status', async () => { - stubFetch(async () => makeJsonResponse({ msg: 'nope' }, 502)); - - await expect( - runWebSearch({ - provider: createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: 'hello', - }), - ).resolves.toContain('CodeBuddy web search error: nope'); - }); - - it('refuses to call the endpoint without a token', async () => { - const mock = stubFetch(async () => makeJsonResponse({ results: [] })); - - await expect( - runWebSearch({ - provider: createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => null, - }), - query: 'hello', - }), - ).resolves.toContain('Authentication required'); - expect(mock).not.toHaveBeenCalled(); + classifyServerToolDeclaration({ type: FETCH_TYPE, name: 'web_fetch' }), + ).toBe('web_fetch'); }); - it('shapes results that lack snippets or titles', async () => { - stubFetch(async () => - makeJsonResponse({ - results: [ - { content: 'fallback snippet', url: 'https://a.test' }, - { title: 'Only title' }, - 'not-an-object', - ], + it('leaves an OpenAI function alone, even one named web_search', () => { + expect( + classifyServerToolDeclaration({ + type: 'function', + function: { name: 'web_search' }, }), - ); - - const result = await createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }).search('anything here'); - - expect(result.results).toHaveLength(2); - expect(result.results[0]?.content).toBe('fallback snippet'); - expect(result.results[1]?.title).toBe('Only title'); - expect(result.content).toContain('(untitled)'); - }); - - it('tolerates a payload with no results array', async () => { - stubFetch(async () => makeJsonResponse({})); - - const result = await createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }).search('anything here'); - - expect(result.results).toEqual([]); - expect(result.content).toContain('returned no results'); + ).toBeNull(); }); - it('surfaces an error payload with no message', async () => { - stubFetch(async () => makeJsonResponse({ code: 7 })); - - await expect( - runWebSearch({ - provider: createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: 'hello', + /** + * The regression. `WebSearch` normalises to the same string as the server + * tool, so a name-based test cannot tell them apart — and getting it wrong + * is what made Claude Code's own search silently stop working. + */ + it('leaves Claude Code’s own WebSearch function alone', () => { + // Anthropic's shorthand for a client function: no `type` at all. + expect(classifyServerToolDeclaration(claudeCodeWebSearch)).toBeNull(); + // OpenAI's spelling of the same thing. + expect( + classifyServerToolDeclaration({ + type: 'function', + function: { name: 'WebSearch' }, }), - ).resolves.toContain('Unknown error'); + ).toBeNull(); }); - it('falls back to the status when the error body is empty', async () => { - stubFetch(async () => new Response('', { status: 500 })); - - await expect( - runWebSearch({ - provider: createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: 'hello', + it('classifies by type even when the name is the client’s spelling', () => { + expect( + classifyServerToolDeclaration({ + type: SEARCH_TYPE, + name: 'WebSearch', }), - ).resolves.toContain('failed with HTTP 500'); + ).toBe('web_search'); }); - it('falls back to the status when the error body is not JSON', async () => { - stubFetch(async () => new Response('502', { status: 502 })); - - await expect( - runWebSearch({ - provider: createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: 'hello', - }), - ).resolves.toContain('failed with HTTP 502'); + it('ignores a declaration that is not an object', () => { + expect(classifyServerToolDeclaration(null)).toBeNull(); + expect(classifyServerToolDeclaration('web_search')).toBeNull(); }); - it('short-circuits an empty query without calling the endpoint', async () => { - const mock = stubFetch(async () => makeJsonResponse({ results: [] })); - - const result = await runWebSearch({ - provider: createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: ' ', - }); - - expect(result).toContain('without a query'); - expect(mock).not.toHaveBeenCalled(); + it('leaves an unrelated tool type alone', () => { + expect(classifyServerToolDeclaration({ type: 'mcp' })).toBeNull(); }); }); - describe('provider resolution', () => { - it('resolves the codebuddy search backend', () => { - expect( - resolveSearchProvider('codebuddy', async () => 'https://cb.test')?.id, - ).toBe('codebuddy'); + describe('findServerToolDeclarations', () => { + it('returns null when no provider-executed tool is declared', () => { + expect(findServerToolDeclarations([claudeCodeWebSearch])).toBeNull(); + expect(findServerToolDeclarations(undefined)).toBeNull(); + expect(findServerToolDeclarations([])).toBeNull(); }); - it('resolves the local fetch backend', () => { + it('reports which server tools were declared', () => { expect( - resolveFetchProvider('local', async () => 'https://cb.test')?.id, - ).toBe('local'); - }); - - it('resolves the codebuddy fetch backend', () => { + findServerToolDeclarations([ + claudeCodeWebSearch, + { type: SEARCH_TYPE, name: 'web_search' }, + ]), + ).toEqual({ fetch: false, search: true }); expect( - resolveFetchProvider('codebuddy', async () => 'https://cb.test')?.id, - ).toBe('codebuddy'); + findServerToolDeclarations([ + { type: SEARCH_TYPE, name: 'web_search' }, + { type: FETCH_TYPE, name: 'web_fetch' }, + ]), + ).toEqual({ fetch: true, search: true }); }); }); - describe('token resolution', () => { - it('prefers bearer_token', () => { + describe('name collisions', () => { + it('flags a client function that collides with an injected server tool', () => { expect( - pickCredentialToken({ access_token: 'a', bearer_token: 'b' }), - ).toBe('b'); + hasAmbiguousServerToolName([ + { type: SEARCH_TYPE, name: 'web_search' }, + claudeCodeWebSearch, + ]), + ).toBe(true); }); - it('falls back to access_token when bearer_token is empty', () => { - // Empty must fall through, not just null: `??` would stop at the blank - // and report "no token" for a credential that has one. + it('is not confused by a client function of another name', () => { expect( - pickCredentialToken({ access_token: 'fallback', bearer_token: '' }), - ).toBe('fallback'); + hasAmbiguousServerToolName([ + { type: SEARCH_TYPE, name: 'web_search' }, + { name: 'Read', input_schema: {} }, + ]), + ).toBe(false); }); - it('falls back to access_token when bearer_token is missing', () => { - expect(pickCredentialToken({ access_token: 'fallback' })).toBe( - 'fallback', - ); + it('ignores a non-array tool list', () => { + expect(hasAmbiguousServerToolName(undefined)).toBe(false); }); - it('treats a whitespace-only token as absent', () => { - expect( - pickCredentialToken({ access_token: ' ', bearer_token: ' ' }), - ).toBeNull(); - }); + /** + * Both would arrive upstream under one name and a model calling it has no + * way to say which it meant, so the call goes to the client rather than + * being guessed at. + */ + it('declines to execute either tool when the names collide', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [{ type: SEARCH_TYPE, name: 'web_search' }, claudeCodeWebSearch], + }); - it('reports no token for an empty credential', () => { - expect(pickCredentialToken({})).toBeNull(); + expect(rewrite?.executable).toEqual({ fetch: false, search: false }); + expect(hasExecutableServerTool(rewrite!.executable)).toBe(false); }); }); - describe('declaration stripping', () => { - it('keeps a client-declared web_search function when search cannot run', async () => { - // Search is selected but unconfigured (no SEARXNG_URL), so nothing can - // execute it — a client-owned function must survive untouched. - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); + describe('rewriteServerTools', () => { + it('returns null when there is no tool list', () => { + expect( + rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: undefined, + }), + ).toBeNull(); + }); - const clientTool = { - type: 'function', - function: { name: 'web_search', parameters: { type: 'object' } }, - }; - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [clientTool], - } as ChatRequestBody, - callUpstream: async () => - makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'No tools.' } }, - ], - }), + it('swaps a runnable search declaration for a function upstream can call', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [{ type: SEARCH_TYPE, name: 'web_search' }], }); - // Nothing matched a server-tool declaration, so the request is left - // alone rather than rewritten. - expect(result).toBeNull(); + expect(rewrite?.executable).toEqual({ fetch: false, search: true }); + expect(rewrite?.tools).toEqual([ + { + type: 'function', + function: expect.objectContaining({ name: 'web_search' }), + }, + ]); + // Dropped from the follow-up, or the model could search again there. + expect(rewrite?.followUpTools).toEqual([]); }); - it.each(['passthrough', 'PASSTHROUGH', 'none'])( - 'passes through a server-declared search tool for %s', - async (backend) => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - CODEBUDDY_WEB_SEARCH_BACKEND: backend, - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - } as ChatRequestBody, - callUpstream: async () => - makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'No tools.' } }, - ], - }), - }); - - expect(result?.response).toBeNull(); - expect(result?.body.tools).toEqual([ - { type: 'web_search_20260209', name: 'web_search' }, - ]); - }, - ); - - it('reads a query from the first non-empty string field', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', + it('keeps a declaration with no backend callable for the client', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: null, + tools: [{ type: SEARCH_TYPE, name: 'web_search' }], }); - let searched = ''; - stubFetch(async (...args: unknown[]) => { - const url = String(args[0]); - - if (url.includes('searx.test')) { - searched = new URL(url).searchParams.get('q') ?? ''; - - return makeJsonResponse({ results: [] }); - } - - return makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'done' } }], - }); - }); + expect(rewrite?.executable).toEqual({ fetch: false, search: false }); + expect(rewrite?.followUpTools).toHaveLength(1); + }); - await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - } as ChatRequestBody, - callUpstream: async () => - makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_alias', - function: { - // No known key: the fallback takes the first string. - arguments: '{"whatever":"unexpected shape"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }), + it('leaves a client function untouched in both tool lists', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [ + { type: SEARCH_TYPE, name: 'web_search' }, + { name: 'Read', input_schema: {} }, + ], }); - expect(searched).toBe('unexpected shape'); - - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); + // The client's own function is forwarded verbatim; only the server + // declaration is rewritten, and only the rewritten one is dropped from + // the follow-up. + expect(rewrite?.tools[1]).toEqual({ name: 'Read', input_schema: {} }); + expect(rewrite?.followUpTools).toEqual([ + { name: 'Read', input_schema: {} }, + ]); }); - }); - describe('url normalization', () => { - it('upgrades http to https', () => { - expect(normalizeFetchUrl('http://a.test/page')).toBe( - 'https://a.test/page', - ); - }); + it('recognises the model’s own spelling of a call it injected', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: true, search: true }, + fetchProvider: makeFetchProvider(), + searchProvider: makeSearchProvider(), + tools: [ + { type: SEARCH_TYPE, name: 'web_search' }, + { type: FETCH_TYPE, name: 'web_fetch' }, + ], + }); - it('rewrites a github blob url to its raw equivalent', () => { - // Without this the fetch returns the GitHub HTML viewer rather than the - // file contents, which is almost never what was wanted. + // Upstream echoes these back in camel case often enough to matter. expect( - normalizeFetchUrl('https://github.com/o/r/blob/main/README.md'), - ).toBe('https://raw.githubusercontent.com/o/r/main/README.md'); - }); - - it('rewrites a github blob url given over http', () => { - expect(normalizeFetchUrl('http://github.com/o/r/blob/main/a.ts')).toBe( - 'https://raw.githubusercontent.com/o/r/main/a.ts', + rewrite?.isExecutableCall({ function: { name: 'WebSearch' } }), + ).toBe(true); + expect( + rewrite?.isExecutableCall({ function: { name: 'WebFetch' } }), + ).toBe(true); + expect(rewrite?.isExecutableCall({ function: { name: 'Read' } })).toBe( + false, ); }); - it('leaves an ordinary url untouched', () => { - expect(normalizeFetchUrl('https://a.test/page')).toBe( - 'https://a.test/page', - ); - }); + it('does not claim a call when the tool has no backend', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: null, + tools: [{ type: SEARCH_TYPE, name: 'web_search' }], + }); - it('leaves a non-blob github url untouched', () => { - expect(normalizeFetchUrl('https://github.com/o/r')).toBe( - 'https://github.com/o/r', - ); + expect( + rewrite?.isExecutableCall({ function: { name: 'web_search' } }), + ).toBe(false); }); }); - describe('codebuddy fetch provider', () => { - it('posts the url and prompt to the webfetch path', async () => { - const mock = stubFetch(async () => - makeJsonResponse({ - content: '# Title\n\nBody text', - url: 'https://a.test', - }), + describe('getForcedToolName', () => { + it('reads the name from either protocol shape', () => { + expect(getForcedToolName({ type: 'tool', name: 'web_search' })).toBe( + 'web_search', ); - - const result = await createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token-123', - }).fetch({ prompt: 'the release date', url: 'https://a.test/page' }); - - const [url, init] = lastFetchCall(mock); - expect(url).toBe('https://cb.test/agenttool/v1/webfetch'); - - const body = JSON.parse(String(init.body)) as Record; - expect(body.url).toBe('https://a.test/page'); - expect(body.prompt).toBe('the release date'); - expect(body.format).toBe('markdown'); - - expect(result.content).toContain('Body text'); - expect(result.url).toBe('https://a.test'); - }); - - it('treats empty content as a failure', async () => { - stubFetch(async () => makeJsonResponse({ content: ' ' })); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test' }, - }), - ).resolves.toContain('no readable content'); - }); - - it('refuses to call the endpoint without a token', async () => { - const mock = stubFetch(async () => makeJsonResponse({ content: 'x' })); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => null, - }), - query: { url: 'https://a.test' }, - }), - ).resolves.toContain('Authentication required'); - expect(mock).not.toHaveBeenCalled(); - }); - - it('reports a non-ok HTTP status', async () => { - stubFetch(async () => makeJsonResponse({ msg: 'bad gateway' }, 502)); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test' }, + expect( + getForcedToolName({ + type: 'function', + function: { name: 'web_search' }, }), - ).resolves.toContain('CodeBuddy web fetch error: bad gateway'); + ).toBe('web_search'); }); - it('falls back to the status when the error body is empty', async () => { - stubFetch(async () => new Response('', { status: 503 })); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test' }, - }), - ).resolves.toContain('failed with HTTP 503'); + it('returns null when no tool is forced', () => { + expect(getForcedToolName('auto')).toBeNull(); + expect(getForcedToolName({ type: 'auto' })).toBeNull(); }); + }); +}); - it('surfaces an error payload with no message', async () => { - stubFetch(async () => makeJsonResponse({ code: 9 })); +describe('server tool turn', () => { + const body = { + messages: [{ role: 'user', content: 'when did it ship?' }], + model: 'test-model', + stream: false, + }; + + const makeRewrite = (searchProvider: WebSearchProvider | null) => + rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider, + tools: [{ type: SEARCH_TYPE, name: 'web_search' }], + })!; + + it('asks upstream once when the model does not call a server tool', async () => { + const callUpstream = vi.fn(async () => + makeJsonResponse({ + choices: [ + { finish_reason: 'stop', message: { content: 'Yesterday.' } }, + ], + }), + ); - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test' }, - }), - ).resolves.toContain('Unknown error'); + const outcome = await runServerToolTurn({ + body, + callUpstream, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + stream: false, }); - it('falls back to the requested URL when none is returned', async () => { - stubFetch(async () => makeJsonResponse({ content: 'Body' })); - - const result = await createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }).fetch({ url: 'https://a.test/page' }); - - expect(result.url).toBe('https://a.test/page'); - expect(result.content).toContain('Body'); - }); + expect(callUpstream).toHaveBeenCalledTimes(1); + expect(outcome.executions).toEqual([]); + // The answer it already wrote is the whole turn. + expect(outcome.preamble.text).toBe('Yesterday.'); + }); - it('reports an HTTP error carrying no message', async () => { - stubFetch(async () => makeJsonResponse({ code: 1 }, 500)); + it('runs the search and asks upstream once more for the answer', async () => { + let calls = 0; + const callUpstream = vi.fn(async () => { + calls += 1; - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test' }, - }), - ).resolves.toContain('failed with HTTP 500'); + return calls === 1 + ? makeJsonResponse(assistantToolCall('web_search', '{"query":"ship"}')) + : makeJsonResponse({ + choices: [ + { finish_reason: 'stop', message: { content: 'It shipped.' } }, + ], + }); }); - it('falls back to Unknown error when the code carries no message', async () => { - stubFetch(async () => makeJsonResponse({ code: 42 })); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test' }, - }), - ).resolves.toContain('Unknown error'); + const outcome = await runServerToolTurn({ + body, + callUpstream, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + stream: false, }); - it('treats a non-string content field as empty', async () => { - stubFetch(async () => makeJsonResponse({ content: { nope: true } })); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test' }, - }), - ).resolves.toContain('no readable content'); + expect(callUpstream).toHaveBeenCalledTimes(2); + expect(outcome.executions).toHaveLength(1); + expect(outcome.executions[0]).toMatchObject({ + input: { query: 'ship' }, + type: 'web_search', }); + expect((await outcome.response.json()).choices[0].message.content).toBe( + 'It shipped.', + ); + }); - it('omits the prompt from the result when none was given', async () => { - stubFetch(async () => makeJsonResponse({ content: 'Body text' })); - - const result = await createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }).fetch({ url: 'https://a.test/page' }); + it('appends the tool result to the follow-up request', async () => { + let calls = 0; + const sentBodies: Record[] = []; + const callUpstream = vi.fn(async (nextBody) => { + calls += 1; + sentBodies.push(nextBody); - expect(result.content).not.toContain('Requested focus'); + return calls === 1 + ? makeJsonResponse(assistantToolCall('web_search', '{"query":"ship"}')) + : makeJsonResponse({ choices: [] }); }); - it('falls back to a local fetch when the endpoint fails', async () => { - // This is the behaviour that keeps data flowing: the CLI races the - // endpoint against a local fetch, so an endpoint failure alone must not - // lose the page. - // - // The fallback uses `node:http` rather than `fetch`, so it cannot be - // stubbed — it gets a real local server and an injected resolver that - // points the hostname at it. - const server = http.createServer((_req, res) => { - res.writeHead(200, { 'content-type': 'text/plain' }); - res.end('local copy of the page'); - }); - await new Promise((resolve) => { - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - - if (!address || typeof address === 'string') { - throw new Error('failed to start test server'); - } - - stubFetch(async () => makeJsonResponse({ msg: 'endpoint down' }, 502)); - - try { - const resolveHost: HostResolver = Object.assign( - async () => ['127.0.0.1'], - { trusted: true }, - ); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveHost, - resolveToken: async () => 'token', - }), - query: { url: `http://fallback.test:${address.port}/page` }, - }), - ).resolves.toContain('local copy of the page'); - } finally { - await new Promise((resolve) => { - server.close(() => resolve()); - }); - } + await runServerToolTurn({ + body, + callUpstream, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + stream: false, }); - it('reports one reason when both failures agree', async () => { - // The host cannot resolve, so the local fallback fails with exactly the - // message the endpoint reports. Repeating it would just be noise. - const reason = 'Web fetch could not resolve host: a.test'; - stubFetch(async () => { - throw new Error(reason); - }); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test/page' }, - }), - ).resolves.toContain(`Web fetch failed: ${reason}.`); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test/page' }, - }), - ).resolves.not.toContain('local fallback also failed'); + const followUp = sentBodies[1] as { messages: unknown[] }; + expect(followUp.messages).toHaveLength(3); + expect(followUp.messages[1]).toMatchObject({ role: 'assistant' }); + expect(followUp.messages[2]).toMatchObject({ + role: 'tool', + tool_call_id: 'call_1', }); + }); - it('handles a non-Error failure from the fallback', async () => { - stubFetch(async () => { - throw 'endpoint string failure'; - }); + it('drops the executed tool so the follow-up cannot search again', async () => { + let calls = 0; + const sentBodies: Record[] = []; + const callUpstream = vi.fn(async (nextBody) => { + calls += 1; + sentBodies.push(nextBody); - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test/page' }, - }), - ).resolves.toContain('local fallback also failed'); + return calls === 1 + ? makeJsonResponse(assistantToolCall('web_search', '{"query":"ship"}')) + : makeJsonResponse({ choices: [] }); }); - it('reports both reasons when the endpoint and the fallback fail', async () => { - stubFetch(async () => makeJsonResponse({ msg: 'endpoint down' }, 502)); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test/page' }, - }), - ).resolves.toContain('local fallback also failed'); + await runServerToolTurn({ + body, + callUpstream, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + stream: false, }); - it('refuses a non-text response', async () => { - stubFetch(async () => - makeJsonResponse({ - content: 'binary bytes', - content_type: 'application/pdf', - }), - ); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test/f.pdf' }, - }), - ).resolves.toContain('non-text resource'); - }); + expect((sentBodies[1] as { tools: unknown[] }).tools).toEqual([]); + }); - it('refuses an image response', async () => { - stubFetch(async () => - makeJsonResponse({ content: 'bytes', content_type: 'image/png' }), - ); + it('relaxes a tool_choice that would force another search', async () => { + let calls = 0; + const sentBodies: Record[] = []; + const callUpstream = vi.fn(async (nextBody) => { + calls += 1; + sentBodies.push(nextBody); - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test/p.png' }, - }), - ).resolves.toContain('non-text resource'); + return calls === 1 + ? makeJsonResponse(assistantToolCall('web_search', '{"query":"ship"}')) + : makeJsonResponse({ choices: [] }); }); - it('accepts a text response with a charset suffix', async () => { - stubFetch(async () => - makeJsonResponse({ - content: 'Hello there', - content_type: 'text/html; charset=utf-8', - }), - ); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test/page' }, - }), - ).resolves.toContain('Hello there'); + await runServerToolTurn({ + body: { ...body, tool_choice: 'required' }, + callUpstream, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + stream: false, }); - it('reports a missing URL without calling the endpoint', async () => { - const mock = stubFetch(async () => makeJsonResponse({ content: 'x' })); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: ' ' }, - }), - ).resolves.toContain('without a URL'); - expect(mock).not.toHaveBeenCalled(); - }); + expect(sentBodies[1].tool_choice).toBe('auto'); }); - describe('local fetch provider', () => { - /** - * A real server, because the backend no longer goes through `globalThis.fetch`: - * it resolves the host itself and pins the socket to the validated address, - * so a stubbed `fetch` cannot exercise the SSRF path at all. - */ - let server: http.Server; - let baseUrl: string; - let handler: (req: http.IncomingMessage, res: http.ServerResponse) => void; - - beforeEach(async () => { - handler = (_req, res) => { - res.writeHead(200, { 'content-type': 'text/plain' }); - res.end('ok'); - }; - server = http.createServer((req, res) => handler(req, res)); - await new Promise((resolve) => { - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - - if (!address || typeof address === 'string') { - throw new Error('failed to start test server'); - } - - // A name that need not resolve anywhere: the injected resolver answers for - // it, so no DNS or network access is involved. It must not be `127.0.0.1`, - // which the private-address check refuses before it proves anything. - baseUrl = `http://public.test:${address.port}`; - }); + it('turns a forced server tool into no tool at all on the follow-up', async () => { + let calls = 0; + const sentBodies: Record[] = []; + const callUpstream = vi.fn(async (nextBody) => { + calls += 1; + sentBodies.push(nextBody); - afterEach(async () => { - await new Promise((resolve) => { - server.close(() => resolve()); - }); + return calls === 1 + ? makeJsonResponse(assistantToolCall('web_search', '{"query":"ship"}')) + : makeJsonResponse({ choices: [] }); }); - /** - * Resolves the test hostname to the loopback address the server is bound to. - * - * The address itself is refused by the private-range check, so this override - * is what lets a test reach a local server while still exercising the real - * resolve → validate → pin path. Pinning then connects to 127.0.0.1 while the - * request still names the public hostname. - */ - const resolveToLocalServer: HostResolver = Object.assign( - async () => ['127.0.0.1'], - { trusted: true }, - ); - - /** - * A resolver whose answers are validated. - * - * The provider only applies the private-address check to answers coming from - * its DNS resolver — an injected resolver is an explicit operator pin, and - * pinning a name to a private address is legitimate. These wrappers produce - * values that look like DNS answers so the validation path is exercised. - */ - const dnsReturning = (addresses: string[]): HostResolver => - Object.assign(async () => addresses, { trusted: false }); - - const fetchWith = ( - url: string, - options: { resolveHost?: HostResolver } = {}, - ) => - runWebFetch({ - provider: createLocalFetchProvider({ - resolveHost: options.resolveHost ?? resolveToLocalServer, - }), - query: { url }, - }); - - it('converts html to text', async () => { - handler = (_req, res) => { - res.writeHead(200, { 'content-type': 'text/html' }); - res.end( - 'T

Hello

World & friends

', - ); - }; - - const result = await createLocalFetchProvider({ - resolveHost: resolveToLocalServer, - }).fetch({ url: `${baseUrl}/page` }); - - expect(result.content).toContain('Hello'); - expect(result.content).toContain('World & friends'); - expect(result.content).not.toContain('bad()'); - expect(result.content).not.toContain('

'); + await runServerToolTurn({ + body: { + ...body, + tool_choice: { type: 'function', function: { name: 'web_search' } }, + }, + callUpstream, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + stream: false, }); - it('preserves the host header while pinning the address', async () => { - let seenHost: string | undefined; - handler = (req, res) => { - seenHost = req.headers.host; - res.writeHead(200, { 'content-type': 'text/plain' }); - res.end('ok'); - }; - - await fetchWith(`${baseUrl}/page`); - - // The connection is pinned to the validated IP, but the request still has - // to name the original host so virtual-host routing and TLS SNI work. - expect(seenHost).toBe(`public.test:${new URL(baseUrl).port}`); - }); + expect(sentBodies[1].tool_choice).toBe('none'); + }); - it('refuses a private address before connecting', async () => { - const mock = stubFetch(async () => makeJsonResponse({ content: 'x' })); + it('hands a failed upstream call back untouched', async () => { + const callUpstream = vi.fn(async () => + makeJsonResponse({ error: { message: 'rate limited' } }, 429), + ); - await expect(fetchWith('http://127.0.0.1/admin')).resolves.toContain( - 'private or loopback', - ); - expect(mock).not.toHaveBeenCalled(); + const outcome = await runServerToolTurn({ + body, + callUpstream, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + stream: false, }); - it('refuses a hostname that resolves to a private address', async () => { - // A public-looking name whose resolution is a private address must still be - // refused: validating only the hostname string would let it through, and - // pinning means the connection would then go to the loopback address. - await expect( - fetchWith('http://private.test/page', { - resolveHost: dnsReturning(['127.0.0.1']), - }), - ).resolves.toContain('resolves to the private address'); - }); + expect(callUpstream).toHaveBeenCalledTimes(1); + expect(outcome.executions).toEqual([]); + expect(outcome.response.status).toBe(429); + }); - it('refuses a non-http scheme', async () => { - const mock = stubFetch(async () => makeJsonResponse({ content: 'x' })); + it('reports the invocations it is about to run', async () => { + let calls = 0; + const invocations: ServerToolInvocation[] = []; + const callUpstream = vi.fn(async () => { + calls += 1; - await expect(fetchWith('file:///etc/passwd')).resolves.toContain( - 'Unsupported URL protocol', - ); - expect(mock).not.toHaveBeenCalled(); + return calls === 1 + ? makeJsonResponse(assistantToolCall('web_search', '{"query":"ship"}')) + : makeJsonResponse({ choices: [] }); }); - it('refuses a redirect onto a private address', async () => { - handler = (_req, res) => { - res.writeHead(302, { location: 'http://169.254.169.254/latest' }); - res.end(); - }; - - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'private or loopback', - ); + await runServerToolTurn({ + body, + callUpstream, + fetchProvider: null, + onCall: (invocation) => invocations.push(invocation), + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + stream: false, }); - it('rejects an unsupported content type', async () => { - handler = (_req, res) => { - res.writeHead(200, { 'content-type': 'application/pdf' }); - res.end('binary'); - }; - - await expect(fetchWith(`${baseUrl}/f.pdf`)).resolves.toContain( - 'unsupported content type', - ); - }); + expect(invocations).toEqual([ + { id: 'call_1', input: { query: 'ship' }, type: 'web_search' }, + ]); + }); - it('reports an invalid URL without connecting', async () => { - await expect(fetchWith('not-a-url')).resolves.toContain( - 'not a valid absolute URL', - ); - }); + it('keeps a client-owned call for the client to resolve', async () => { + let calls = 0; + const callUpstream = vi.fn(async () => { + calls += 1; - it('reports a blank URL without connecting', async () => { - await expect(fetchWith(' ')).resolves.toContain('without a URL'); + return calls === 1 + ? makeJsonResponse(assistantToolCall('Read', '{"path":"/tmp/a"}')) + : makeJsonResponse({ choices: [] }); }); - it('follows a redirect whose location header is an array', async () => { - let call = 0; - handler = (_req, res) => { - call += 1; - - if (call === 1) { - // Node exposes repeated headers as an array, and only the first is - // used. `setHeader` accepts that array shape, but it has to run - // before `writeHead` commits the headers. - res.setHeader('location', [`${baseUrl}/final`, '/ignored']); - res.writeHead(302); - res.end(); - return; - } - - res.writeHead(200, { 'content-type': 'text/plain' }); - res.end('final body'); - }; - - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'final body', - ); + const outcome = await runServerToolTurn({ + body, + callUpstream, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + stream: false, }); - it('refuses localhost and other reserved hosts', async () => { - const mock = stubFetch(async () => makeJsonResponse({ content: 'x' })); - - for (const url of [ - 'http://localhost/admin', - 'http://internal.localhost/admin', - 'http://[::1]/admin', - 'http://[fd00::1]/admin', - 'http://0.0.0.0/admin', - 'http://172.20.0.1/admin', - 'http://10.1.2.3/admin', - 'http://192.168.1.1/admin', - ]) { - await expect(fetchWith(url)).resolves.toContain('private or loopback'); - } - - expect(mock).not.toHaveBeenCalled(); - }); + // Not ours, so nothing runs and the call goes back exactly as it arrived. + expect(callUpstream).toHaveBeenCalledTimes(1); + expect(outcome.executions).toEqual([]); + }); +}); - it('reports a redirect with no target', async () => { - handler = (_req, res) => { - res.writeHead(302); - res.end(); - }; +describe('foldIntermediateTexts', () => { + it('puts prose from earlier hops ahead of the closing message', () => { + const folded = foldIntermediateTexts( + { choices: [{ message: { content: 'final' } }] }, + ['first', 'second'], + ); - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'redirect with no target', - ); - }); + expect(folded.choices?.[0]?.message?.content).toBe( + 'first\n\nsecond\n\nfinal', + ); + }); - it('reports too many redirects', async () => { - handler = (_req, res) => { - res.writeHead(302, { location: `${baseUrl}/next` }); - res.end(); - }; + it('leaves the payload alone when there is nothing to fold', () => { + const payload = { choices: [{ message: { content: 'final' } }] }; - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'more than 5 redirects', - ); - }); - - it('reports an HTTP error status', async () => { - handler = (_req, res) => { - res.writeHead(404, { 'content-type': 'text/plain' }); - res.end('gone'); - }; - - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'Web fetch failed with HTTP 404', - ); - }); - - it('reports a page with no readable text', async () => { - handler = (_req, res) => { - res.writeHead(200, { 'content-type': 'text/html' }); - res.end(' '); - }; - - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'no readable content', - ); - }); - - it('follows a redirect to another public host', async () => { - let call = 0; - handler = (_req, res) => { - call += 1; - - if (call === 1) { - res.writeHead(301, { location: `${baseUrl}/final` }); - res.end(); - return; - } - - res.writeHead(200, { 'content-type': 'text/html' }); - res.end('

Final page

'); - }; - - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'Final page', - ); - }); - - it('decodes entities when converting html', async () => { - handler = (_req, res) => { - res.writeHead(200, { 'content-type': 'text/html' }); - res.end('

a & b <c> A

'); - }; - - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'a & b A', - ); - }); - - it('passes plain text through unchanged', async () => { - handler = (_req, res) => { - res.writeHead(200, { 'content-type': 'text/plain' }); - res.end('a & b'); - }; - - // Entities are only decoded on the HTML path: a text/plain body is literal - // text, and decoding it would corrupt the content. - await expect(fetchWith(`${baseUrl}/t.txt`)).resolves.toContain( - 'a & b', - ); - }); - - it('reports a hostname whose DNS lookup fails', async () => { - await expect( - fetchWith('http://nx.test/page', { - resolveHost: async () => { - throw new Error('dns boom'); - }, - }), - ).resolves.toContain('could not resolve host'); - }); - - it('treats a missing content type as text', async () => { - handler = (_req, res) => { - res.writeHead(200); - res.end('plain body'); - }; - - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'plain body', - ); - }); - - it('reports a connection dropped mid-body', async () => { - // The socket is destroyed instead of left hanging: an abrupt close fails - // fast and deterministically, whereas asserting on a stalled body would - // depend on the idle timeout firing within the test's own time budget. - handler = (_req, res) => { - res.writeHead(200, { 'content-type': 'text/plain' }); - res.write('partial'); - res.destroy(); - }; - - const result = await runWebFetch({ - provider: createLocalFetchProvider({ - resolveHost: resolveToLocalServer, - }), - query: { url: `${baseUrl}/dropped` }, - }); - - expect(result).toContain('Web fetch failed'); - }); - - it('stops reading once the body cap is reached', async () => { - const chunk = 'y'.repeat(1000); - // Far more than the cap, in chunks, and then the server is done: the body - // is bounded by the reader, not by the server's willingness to stop. - handler = (_req, res) => { - res.writeHead(200, { 'content-type': 'text/plain' }); - for (let index = 0; index < 500; index += 1) { - res.write(chunk); - } - res.end(); - }; - - const result = await runWebFetch({ - provider: createLocalFetchProvider({ - maxContentLength: 2000, - resolveHost: resolveToLocalServer, - }), - query: { url: `${baseUrl}/huge` }, - }); - - // The cap truncated the body instead of buffering all ~500 KB of it. - expect(result.length).toBeLessThan(5000); - }); - }); - - describe('fetch tool definition', () => { - it('requires only a url', () => { - const tool = buildWebFetchToolDefinition(); - - expect(tool.name).toBe('web_fetch'); - expect(tool.parameters).toMatchObject({ - properties: { - prompt: { type: 'string' }, - url: { type: 'string' }, - }, - required: ['url'], - type: 'object', - }); - }); - }); - - describe('tool name normalization', () => { - it('collapses the spellings upstream uses for the same tool', () => { - // The wire format is snake_case, but the model echoes the call back in - // whatever casing it prefers, so every spelling has to compare equal. - const spellings = [ - 'web_fetch', - 'WebFetch', - 'webFetch', - 'Web Fetch', - 'web-fetch', - ' WEB_FETCH ', - ]; - - for (const spelling of spellings) { - expect(normalizeToolName(spelling)).toBe('webfetch'); - } - - expect(normalizeToolName('web_fetch_20250910')).toBe( - normalizeToolName('WebFetch_20250910'), - ); - }); - - it('keeps distinct tools distinct', () => { - expect(normalizeToolName('web_fetch')).not.toBe( - normalizeToolName('web_search'), - ); - expect(normalizeToolName('WebFetch')).not.toBe( - normalizeToolName('WebSearch'), - ); - }); - }); - - describe('server tool marker', () => { - it('ignores non-object values', () => { - expect(isMarkedServerTool(null)).toBe(false); - expect(isMarkedServerTool('nope')).toBe(false); - expect(stripServerToolMarker('plain')).toBe('plain'); - }); - }); - - describe('responses provenance', () => { - it('marks a translated server tool so it can be stripped later', () => { - const translated = translateResponsesToolsToChat([ - { type: 'web_fetch_20250910', name: 'web_fetch' }, - ]) as Array>; - - // The declaration becomes a plain function for upstream, so the marker is - // the only surviving evidence that the client asked for a server tool. - expect(translated[0]?.function).toMatchObject({ name: 'web_fetch' }); - expect(isMarkedServerTool(translated[0])).toBe(true); - }); - - it('does not mark a client-declared function of the same name', () => { - const translated = translateResponsesToolsToChat([ - { type: 'function', name: 'web_fetch', parameters: { type: 'object' } }, - ]) as Array>; - - expect(isMarkedServerTool(translated[0])).toBe(false); - }); - - it('passes through a translated server tool when fetch is passthrough', async () => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - - const translated = translateResponsesToolsToChat([ - { type: 'web_fetch_20250910', name: 'web_fetch' }, - ]); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: translated as unknown[], - } as ChatRequestBody, - callUpstream: async () => - makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'No tools.' } }, - ], - }), - }); - - expect(result?.body.tools).toEqual([ - expect.objectContaining({ - type: 'function', - function: expect.objectContaining({ name: 'web_fetch' }), - }), - ]); - expect(isMarkedServerTool(result?.body.tools?.[0])).toBe(false); - }); - - it('leaves a client-declared web_fetch function alone when a backend is set', async () => { - // The backend setting chooses who runs the *proxy's* tool, not whether - // the proxy may take over one the client declared. A client that ships - // its own `web_fetch` keeps resolving it — otherwise picking a backend - // would silently disable a capability the client asked for. - // - // The client's own parameters are the proof: the proxy's definition has - // `properties.url`, the client's here is `{ type: 'object' }`. - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy2api', - CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough', - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [ - { - type: 'function', - function: { - name: 'web_fetch', - parameters: { type: 'object' }, - }, - }, - ], - } as ChatRequestBody, - callUpstream: async () => - makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'No tools.' } }, - ], - }), - }); - - // Nothing matched a server-tool declaration, so the loop declines to - // touch the request at all. - expect(result).toBeNull(); - }); - - it('keeps a client function of the same name when fetch cannot run', async () => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [ - { - type: 'function', - function: { - name: 'web_fetch', - parameters: { type: 'object' }, - }, - }, - ], - } as ChatRequestBody, - callUpstream: async () => - makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'No tools.' } }, - ], - }), - }); - - // Nothing matched a server-tool declaration, so the loop declines to - // touch the request at all — the client's own function is left exactly as - // sent rather than being rewritten or dropped. - expect(result).toBeNull(); - }); - - it('never forwards the marker upstream', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - - const translated = translateResponsesToolsToChat([ - { type: 'web_search_preview' }, - { type: 'keep', name: 'keep' }, - ]); - - let forwarded: unknown[] | undefined; - await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: translated as unknown[], - } as ChatRequestBody, - callUpstream: async (loopBody) => { - forwarded = loopBody.tools; - - return makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - }); - }, - }); - - expect((forwarded ?? []).some((tool) => isMarkedServerTool(tool))).toBe( - false, - ); - - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); - }); - }); - - describe('settings', () => { - it('runs search by default but leaves web fetch to the client', async () => { - const config = await getActiveConfig(); - - expect(config.CODEBUDDY_WEB_SEARCH_BACKEND).toBe('searxng'); - expect(config.CODEBUDDY_WEB_FETCH_BACKEND).toBe('passthrough'); - - // Search stays on its historical default; fetch defaults to the client - // because a deployment has no basis for choosing a fetch backend itself. - await expect(isWebSearchEnabled()).resolves.toBe(true); - await expect(isWebFetchEnabled()).resolves.toBe(false); - }); - - it('is enabled by choosing a backend', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy2api' }); - - await expect(isWebFetchEnabled()).resolves.toBe(true); - - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough' }); - - await expect(isWebFetchEnabled()).resolves.toBe(false); - }); - }); - - describe('responses tool translation', () => { - it('emits web_fetch as a callable function', () => { - const result = translateResponsesToolsToChat([ - { type: 'web_fetch_20250910', name: 'web_fetch' }, - ]) as Array<{ function: { name: string } }>; - - expect(result.map((entry) => entry.function.name)).toEqual(['web_fetch']); - }); - - it('emits both server tools when declared together', () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - - const result = translateResponsesToolsToChat([ - { type: 'web_search_preview' }, - { type: 'web_fetch_20250910', name: 'web_fetch' }, - ]) as Array<{ function: { name: string } }>; - - expect(result.map((entry) => entry.function.name)).toEqual([ - 'web_search', - 'web_fetch', - ]); - - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); - }); - - it('leaves an unrelated server tool alone', () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - - expect( - translateResponsesToolsToChat([{ type: 'file_search' }]), - ).toBeUndefined(); - - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); - }); - }); - - describe('token scoping', () => { - it('uses the credential backing the request', async () => { - const seen: string[] = []; - - await withCodeBuddyToken( - async () => 'scoped-token', - async () => { - seen.push(String(await resolveCodeBuddyToken())); - }, - ); - - expect(seen).toEqual(['scoped-token']); - }); - - it('falls back to a saved credential outside a request scope', async () => { - await withCredential(); - - await expect(resolveCodeBuddyToken()).resolves.toBe('cred-token'); - }); - - it('reports no token when no credential exists', async () => { - await expect(resolveCodeBuddyToken()).resolves.toBeNull(); - }); - - it('ignores a credential that carries no bearer token', async () => { - await addCredential({ - access_token: '', - created_at: Math.floor(Date.now() / 1000), - user_id: 'empty', - }); - - await expect(resolveCodeBuddyToken()).resolves.toBeNull(); - }); - }); - - describe('registry fallbacks', () => { - it('normalizes a blank search backend to the default', () => { - expect(normalizeSearchBackend('')).toBe('searxng'); - }); - - it('falls back to searxng for an unknown search backend', () => { - // No SEARXNG_URL here, so the fallback resolves to a backend that cannot - // be constructed — the point is that an unknown value is not an error. - expect( - resolveSearchProvider('bogus', async () => 'https://cb.test'), - ).toBeNull(); - }); - - it('falls back to no backend for an unknown fetch backend', () => { - // Unlike search, an unrecognised fetch value resolves to nothing: silently - // enabling a backend that fetches arbitrary model-supplied URLs would be - // the wrong default. - expect( - resolveFetchProvider('bogus', async () => 'https://cb.test'), - ).toBeNull(); - }); - - it('reports no provider when the search backend resolves to none', () => { - expect( - resolveSearchProvider('none', async () => 'https://cb.test'), - ).toBeNull(); - }); - - it('reuses one local fetch provider across calls', () => { - expect(resolveFetchProvider('local', async () => 'https://cb.test')).toBe( - resolveFetchProvider('local', async () => 'https://cb.test'), - ); - }); - - it('reports no configured backend when a search runs unscoped', async () => { - await expect( - runWebSearch({ backend: 'searxng', query: 'hello' }), - ).resolves.toContain('no local search backend is configured'); - }); - - it('reports no enabled backend when a fetch runs unscoped', async () => { - await expect( - runWebFetch({ backend: 'none', query: { url: 'https://a.test' } }), - ).resolves.toContain('no web fetch backend is enabled'); - }); - }); - - describe('proxy integration', () => { - /** - * A `web_fetch` declaration as it arrives from a client: an Anthropic - * server-tool type run through the Responses translator, which is what - * marks it as provider-executed. Declaring the proxy's own definition - * directly would skip that step and arrive unmarked — indistinguishable - * from a tool the client owns. - */ - const translatedFetchTools = (): unknown[] => - translateResponsesToolsToChat([ - { type: 'web_fetch_20250910', name: 'web_fetch' }, - ]) ?? []; - - const runOnce = async ({ - fetchImpl, - tools, - }: { - fetchImpl: (...args: unknown[]) => Promise; - tools: unknown[]; - }): Promise => { - await withCredential(); - stubFetch(fetchImpl); - - const context = createProxyContextFromCredential({ - data: { bearer_token: 'cred-token', user_id: 'tester' }, - filePath: '/tmp/cred.json', - filename: 'cred.json', - }); - - const response = await proxyChatCompletions( - new NextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ content: 'hi', role: 'user' }], - tools, - } as ChatRequestBody, - context, - ); - - return response; - }; - - it('executes a web_fetch call through the local backend', async () => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy2api', - }); - - let call = 0; - const response = await runOnce({ - fetchImpl: async (...args: unknown[]) => { - const url = String(args[0]); - - if (url.includes('/v2/chat/completions')) { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"url":"https://a.test/page"}', - name: 'web_fetch', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Fetched.' } }, - ], - }); - } - - return new Response('

Page body

', { - headers: { 'Content-Type': 'text/html' }, - status: 200, - }); - }, - tools: translatedFetchTools(), - }); - - const payload = (await response.json()) as { - choices: Array<{ message: { content: string } }>; - }; - expect(payload.choices[0]?.message.content).toBe('Fetched.'); - expect(call).toBe(2); - }); - - it('executes a fetch the model echoes back as WebFetch', async () => { - // Regression guard: upstream returns the call as `WebFetch`, not - // `web_fetch`. An exact name match missed it, so the call was neither - // executed nor taken over — it was handed straight back to the client - // unresolved, and the fetch silently never happened. - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy2api', - CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough', - }); - - let call = 0; - const response = await runOnce({ - fetchImpl: async (...args: unknown[]) => { - const url = String(args[0]); - - if (url.includes('/v2/chat/completions')) { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"url":"https://a.test/page"}', - name: 'WebFetch', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Fetched.' } }, - ], - }); - } - - return new Response('

Page body

', { - headers: { 'Content-Type': 'text/html' }, - status: 200, - }); - }, - tools: translatedFetchTools(), - }); - - const payload = (await response.json()) as { - choices: Array<{ message: { content: string } }>; - }; - - // A second upstream call means the tool ran and its result was folded - // back in. Before the fix the loop broke on the first response and - // returned the unresolved call, so this was 1. - expect(payload.choices[0]?.message.content).toBe('Fetched.'); - expect(call).toBe(2); - }); - - it('executes a search the model echoes back as WebSearch', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - - let call = 0; - let searched = 0; - const response = await runOnce({ - fetchImpl: async (...args: unknown[]) => { - const url = String(args[0]); - - if (url.includes('searx.test')) { - searched += 1; - - return makeJsonResponse({ - results: [ - { - content: 'A snippet', - title: 'Docs', - url: 'https://docs.test', - }, - ], - }); - } - - if (url.includes('/v2/chat/completions')) { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"query":"latest news"}', - name: 'WebSearch', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { content: 'Searched.' }, - }, - ], - }); - } - - return makeJsonResponse({}); - }, - tools: [{ type: 'function', function: buildWebSearchToolDefinition() }], - }); - - const payload = (await response.json()) as { - choices: Array<{ message: { content: string } }>; - }; - - expect(payload.choices[0]?.message.content).toBe('Searched.'); - expect(searched).toBe(1); - expect(call).toBe(2); - - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); - }); - - it('passes through a fetch server-tool declaration', async () => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough', - }); - - const callUpstream = vi.fn(async (_loopBody: ChatRequestBody) => - makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'No tools.' } }, - ], - }), - ); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [ - { type: 'web_fetch_20250910', name: 'web_fetch' }, - { type: 'function', function: { name: 'keep_me' } }, - ], - } as ChatRequestBody, - callUpstream: callUpstream as never, - }); - - // Passthrough never starts the local loop; the ordinary proxy path sends - // both tools upstream after the internal marker is removed. - expect(callUpstream).not.toHaveBeenCalled(); - expect(result?.response).toBeNull(); - expect(result?.body.tools).toEqual([ - { type: 'web_fetch_20250910', name: 'web_fetch' }, - { type: 'function', function: { name: 'keep_me' } }, - ]); - }); - - it('drops a client-declared function it would otherwise keep once disabled', async () => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy', - - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'function', function: { name: 'keep_me' } }], - } as ChatRequestBody, - callUpstream: async () => - makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'No tools.' } }, - ], - }), - }); - - // No server tool was declared, so nothing is rewritten. - expect(result).toBeNull(); - }); - - it('withdraws server tools when the model keeps calling them', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - stubFetch(async (...args: unknown[]) => { - const url = String(args[0]); - - if (url.includes('searx.test')) { - return makeJsonResponse({ results: [] }); - } - - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: `call_${url.length}`, - function: { - arguments: '{"query":"again"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - } as ChatRequestBody, - callUpstream: async () => - makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_loop', - function: { - arguments: '{"query":"again"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }), - }); - - // The budget ran out, so the final call goes out with the server tool - // withdrawn — otherwise the model would search forever. - const payload = await readPayload(result); - expect(payload.choices).toBeDefined(); - - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); - }); - - it('runs server search while still handing a client web_fetch back', async () => { - // The case the declaration-level guard alone does not cover: a - // server-declared search makes the loop run, so the proxy's fetch - // backend is active and the model calls both tools in one turn. Call - // classification has to consult ownership, not just the name — - // otherwise the client's `web_fetch` is executed here even though its - // declaration was left untouched. - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy2api', - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - - let upstreamCalls = 0; - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [ - ...(translateResponsesToolsToChat([ - { type: 'web_search_preview' }, - ]) ?? []), - { - type: 'function', - function: { name: 'web_fetch', parameters: { type: 'object' } }, - }, - ], - } as ChatRequestBody, - callUpstream: async () => { - upstreamCalls += 1; - - return upstreamCalls === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - role: 'assistant', - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"url":"https://a.test/page"}', - name: 'web_fetch', - }, - }, - { - id: 'c2', - function: { - arguments: '{"query":"q"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'ANSWER' } }, - ], - }); - }, - }); - - // The loop stopped after one turn: the search ran, and the fetch was - // returned to the client rather than answered by another round. - expect(upstreamCalls).toBe(1); - // Only the client's fetch is still outstanding — the search was executed - // locally, so it is gone from the call list. - const payload = await readPayload(result); - const toolCalls = ( - payload.choices as Array<{ - message?: { - tool_calls?: Array<{ function?: { name?: string } }>; - }; - }> - )?.[0]?.message?.tool_calls; - expect(toolCalls?.map((call) => call.function?.name)).toEqual([ - 'web_fetch', - ]); - - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); - }); - - it('hands a client-declared web_fetch call back even with a backend set', async () => { - // End-to-end companion to the declaration-level guard: the backend is - // executable, so the only thing keeping this the client's call is the - // declaration being client-owned. The model calls the tool, the proxy - // must not answer it — the unresolved call goes back to the client. - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy2api', - CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough', - }); - - let upstreamCalls = 0; - let pageFetches = 0; - const response = await runOnce({ - fetchImpl: async (...args: unknown[]) => { - const url = String(args[0]); - - if (url.includes('a.test/page')) { - pageFetches += 1; - - return new Response('

Page body

', { - headers: { 'Content-Type': 'text/html' }, - status: 200, - }); - } - - upstreamCalls += 1; - - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"url":"https://a.test/page"}', - name: 'web_fetch', - }, - }, - ], - }, - }, - ], - }); - }, - tools: [ - { - type: 'function', - function: { name: 'web_fetch', parameters: { type: 'object' } }, - }, - ], - }); - - const payload = (await response.json()) as { - choices: Array<{ message: { tool_calls?: unknown[] } }>; - }; - - // The proxy never fetched the page and never re-asked upstream; the call - // comes back for the client to resolve. - expect(pageFetches).toBe(0); - expect(upstreamCalls).toBe(1); - expect(payload.choices[0]?.message.tool_calls).toHaveLength(1); - }); - - it('keeps a client fetch out of the loop when a server search runs', async () => { - // The declaration-level guard alone is not enough: a server-declared - // search starts the loop, and once it is running the call classifier - // used to match on name alone — so the client's `web_fetch` was executed - // alongside the search it had nothing to do with. - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy2api', - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - - let upstreamCalls = 0; - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [ - // A server tool the proxy owns... - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - // ...and a client-owned tool the proxy must not touch. - { - type: 'function', - function: { name: 'web_fetch', parameters: { type: 'object' } }, - }, - ], - } as ChatRequestBody, - callUpstream: async () => { - upstreamCalls += 1; - - return upstreamCalls === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"url":"https://a.test/page"}', - name: 'web_fetch', - }, - }, - { - id: 'c2', - function: { - arguments: '{"query":"q"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Done.' } }, - ], - }); - }, - }); - - // Only the search ran. The client's fetch is left for the client. - expect(result?.executions.map((execution) => execution.type)).toEqual([ - 'web_search', - ]); - - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); - }); - - it('leaves a client-declared web_fetch function alone when disabled', async () => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - - const declared = { - type: 'function', - function: { name: 'web_fetch', parameters: { type: 'object' } }, - }; - let upstreamTools: unknown[] | undefined; - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [declared], - } as ChatRequestBody, - callUpstream: async (loopBody) => { - upstreamTools = loopBody.tools; - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'No tools.' } }, - ], - }); - }, - }); - - // The client resolves its own tool, so the proxy must not delete it even - // though local fetch is off — and with nothing to execute, the loop - // declines to run at all. - expect(result).toBeNull(); - expect(upstreamTools).toBeUndefined(); - }); + expect(foldIntermediateTexts(payload, [])).toBe(payload); + expect(foldIntermediateTexts({}, ['text'])).toEqual({}); }); }); diff --git a/tests/server/units.test.ts b/tests/server/units.test.ts index d4ec55d..46d161f 100644 --- a/tests/server/units.test.ts +++ b/tests/server/units.test.ts @@ -5334,8 +5334,11 @@ describe('server units', () => { ]); expect(result).toHaveLength(7); + // A provider-executed declaration keeps its declared type: it is the only + // thing that tells it apart from the client's own function of the same + // name, and upstream never sees it because the turn rewrites it first. expect(result?.[0]).toMatchObject({ - type: 'function', + type: 'web_search_preview', function: { name: 'web_search', }, @@ -5358,7 +5361,7 @@ describe('server units', () => { }, }); expect(result?.[3]).toMatchObject({ - type: 'function', + type: 'web_search_preview', function: { name: 'web_search', }, @@ -5506,8 +5509,6 @@ describe('server units', () => { ]), ).toEqual([ { - // Marked as server-declared so the proxy knows it executes the call. - 'x-codebuddy2api-server-tool': true, type: 'function', function: expect.objectContaining({ name: 'image_generation', diff --git a/tests/server/web-search.test.ts b/tests/server/web-search.test.ts index 02341f4..11b060c 100644 --- a/tests/server/web-search.test.ts +++ b/tests/server/web-search.test.ts @@ -3,11 +3,7 @@ import path from 'node:path'; import { NextRequest } from 'next/server'; -import { - getSettingLabels, - isWebSearchEnabled, - updateSettings, -} from '@/lib/server/domain/config'; +import { updateSettings } from '@/lib/server/domain/config'; import { getWebSearchProvider, isLocalWebSearchConfigured, @@ -21,22 +17,17 @@ import { } from '@/lib/server/search/providers/searxng'; import { buildWebSearchToolDefinition } from '@/lib/server/search/tool'; import { - createProxyContextFromCredential, - proxyChatCompletions, -} from '@/lib/server/proxy/codebuddy'; + rewriteServerTools, + runServerToolTurn, +} from '@/lib/server/proxy/server-tools'; +import { proxyChatCompletions } from '@/lib/server/proxy/codebuddy'; import { addCredential, resetCredentialRuntimeState, } from '@/lib/server/domain/credentials'; -import { resetUsageStats } from '@/lib/server/domain/stats'; -import type { ChatRequestBody } from '@/lib/server/proxy/codebuddy'; import { translateResponsesToolsToChat } from '@/lib/server/proxy/responses'; import { handleResponsesRequest } from '@/lib/server/proxy/responses'; import { handleMessagesRequest } from '@/lib/server/proxy/anthropic'; -import { - executeWebSearchLoop, - synthesizeChatCompletionStream, -} from '@/lib/server/proxy/web-search-loop'; const SEARXNG_ENV_NAMES = [ 'SEARXNG_URL', @@ -70,39 +61,20 @@ const makeSseResponse = (...chunks: Record[]): Response => { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } }, ); -type LoopCall = (body: ChatRequestBody) => Promise; - -/** - * Reads the loop's buffered payload, asserting the loop ran. - * - * `executeWebSearchLoop` legitimately returns a null response when no backend - * can execute the declared tools, so every assertion on the payload has to rule - * that out first rather than silently reading through a nullable. - */ -const readPayload = async ( - result: { response: Response | null } | null, -): Promise> => { - if (!result?.response) { - throw new Error('Expected the server-tool loop to produce a response'); - } - - return (await result.response.json()) as Record; -}; - -const readSseEvents = async (response: Response): Promise => { - const text = await response.text(); - - return text - .split('\n\n') - .map((frame) => - frame - .split('\n') - .filter((line) => line.startsWith('data: ')) - .map((line) => line.slice(6)) - .join(''), - ) - .filter((payload) => payload.length > 0); -}; +/** Upstream is always asked to stream, so the follow-up answer arrives as SSE. */ +const makeSseAnswer = (content: string): Response => + makeSseResponse( + { + choices: [{ delta: { content, role: 'assistant' }, index: 0 }], + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + }, + { + choices: [{ delta: {}, finish_reason: 'stop', index: 0 }], + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + }, + ); describe('server local web search', () => { beforeEach(() => { @@ -486,7 +458,7 @@ describe('server local web search', () => { await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); let call = 0; - const callUpstream = vi.fn(async () => { + const callUpstream = vi.fn(async () => { call += 1; return call === 1 @@ -513,12 +485,21 @@ describe('server local web search', () => { }); }); - await executeWebSearchLoop({ + await runServerToolTurn({ body: { messages: [{ content: 'hi', role: 'user' }], tools: [{ type: 'web_search_preview' }], }, callUpstream, + fetchProvider: null, + rewrite: rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: resolveSearchProvider('searxng'), + tools: [{ type: 'web_search_preview' }], + })!, + searchProvider: resolveSearchProvider('searxng'), + stream: false, }); if (!fetchMock.mock.calls.length) { @@ -555,7 +536,7 @@ describe('server local web search', () => { }); it('skips the search for a non-object argument payload', async () => { - // No query can be recovered, so no request is made and the loop reports + // No query can be recovered, so no request is made and the turn reports // that back to the model as tool result text. await expect(runOnce('42')).resolves.toBeNull(); }); @@ -567,5790 +548,453 @@ describe('server local web search', () => { await expect(runOnce('{"text":"alias two"}')).resolves.toBe('alias two'); }); }); +}); - describe('search loop', () => { - const enableSearch = async (): Promise => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - }; - - it('skips requests that declare no web search tool', async () => { - await enableSearch(); - const callUpstream = vi.fn(async () => makeJsonResponse({})); +// --------------------------------------------------------------------------- +// Route-level behaviour +// +// The corrected flow, end to end. A client declares `WebSearch` as an ordinary +// function and resolves it itself; the proxy must hand the model's call back as +// a `tool_use` block and run nothing. Only a request whose tools carry a +// provider-executed *type* — the sub-request Claude Code sends once it has a +// `WebSearch` result to fill in — runs a search here. +// --------------------------------------------------------------------------- - await expect( - executeWebSearchLoop({ - body: { messages: [{ content: 'hi', role: 'user' }], tools: [] }, - callUpstream, - }), - ).resolves.toBeNull(); - expect(callUpstream).not.toHaveBeenCalled(); - }); +describe('responses tool translation', () => { + it('keeps a provider-executed declaration’s type on the chat tool', () => { + const translated = translateResponsesToolsToChat([ + { type: 'web_search_preview' }, + ]) as Array<{ type: string; function: { name: string } }>; - it('does not run the loop when the setting is disabled', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - // Both off: the loop runs if *either* tool can be executed, so leaving - // fetch enabled would make it call upstream regardless of search. - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough', - }); - const callUpstream = vi.fn(async () => makeJsonResponse({})); + expect(translated).toHaveLength(1); + // Downstream classification reads the type, so it has to survive. + expect(translated[0].type).toBe('web_search_preview'); + expect(translated[0].function.name).toBe('web_search'); + }); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - callUpstream, - }); + it('translates a client function as an ordinary function', () => { + const translated = translateResponsesToolsToChat([ + { type: 'function', name: 'Read', parameters: {} }, + ]) as Array<{ type: string; function: { name: string } }>; - // No upstream call: nothing can execute, so there is nothing to loop for. - expect(callUpstream).not.toHaveBeenCalled(); - expect(result?.response).toBeNull(); - expect(result?.body.tools).toEqual([ - { type: 'web_search_20260209', name: 'web_search' }, - ]); - }); + expect(translated[0].type).toBe('function'); + expect(translated[0].function.name).toBe('Read'); + }); +}); - it('drops an unconfigured non-passthrough server declaration', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - const callUpstream = vi.fn(async () => makeJsonResponse({})); +describe('server tool routing', () => { + const tempRootDir = path.join(process.cwd(), '.tmp-servertool-route-root'); + const tempDataDir = path.join(tempRootDir, '.codebuddy_data'); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - callUpstream, - }); + const cleanupDir = (): void => { + fs.rmSync(tempRootDir, { force: true, recursive: true, maxRetries: 5 }); + }; - expect(callUpstream).not.toHaveBeenCalled(); - expect(result?.body.tools).toEqual([]); + const makeRequest = (url: string): NextRequest => + new NextRequest(url, { + method: 'POST', + headers: { authorization: 'Bearer servertool-token' }, }); - it.each([ - [ - 'anthropic server tool', - { type: 'web_search_20260209', name: 'web_search' }, - ], - [ - 'anthropic legacy tool', - { type: 'web_search_20250305', name: 'web_search' }, - ], - ['responses preview tool', { type: 'web_search_preview' }], - ])('replaces the %s with a callable function', async (_label, tool) => { - await enableSearch(); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - }), - ); + const enableSearch = async (): Promise => { + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + }; - const result = await executeWebSearchLoop({ - body: { messages: [{ content: 'hi', role: 'user' }], tools: [tool] }, - callUpstream, - }); + /** Upstream answers the first call with a tool call and the rest with text. */ + const mockUpstream = ({ + toolName = 'web_search', + answer = 'It shipped yesterday.', + arguments: args = '{"query":"latest release"}', + } = {}): { upstreamCalls: () => number } => { + let calls = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); - expect(result).not.toBeNull(); - const upstreamBody = callUpstream.mock.calls[0]?.[0] as ChatRequestBody; - const tools = upstreamBody.tools as Array<{ - function: { name: string }; - }>; - expect(tools.map((entry) => entry.function.name)).toEqual(['web_search']); - }); + if (url.includes('searx.test')) { + return makeJsonResponse({ + results: [ + { content: 'A snippet', title: 'Docs', url: 'https://docs.test' }, + ], + }) as unknown as Response; + } - it('runs the query and feeds results back to the model', async () => { - await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ - results: [ - { content: 'A snippet', title: 'Docs', url: 'https://docs.test' }, + calls += 1; + + return calls === 1 + ? (makeJsonResponse({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + role: 'assistant', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { arguments: args, name: toolName }, + }, + ], + }, + }, ], - }), - ) as unknown as typeof fetch, - ); + }) as unknown as Response) + : makeSseAnswer(answer); + }); - let call = 0; - const callUpstream = vi.fn(async () => { - call += 1; + return { upstreamCalls: () => calls }; + }; - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: null, - role: 'assistant', - tool_calls: [ - { - id: 'call_1', - type: 'function', - function: { - arguments: '{"query":"current weather"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'It is sunny.' } }, - ], - }); - }); + const readEvents = async ( + response: Response, + ): Promise> => { + const text = await response.text(); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'weather?', role: 'user' }], - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - callUpstream, - }); + return text + .split('\n\n') + .map((frame) => { + const lines = frame.split('\n'); + const event = lines + .find((line) => line.startsWith('event: ')) + ?.slice(7) + .trim(); + const data = lines + .filter((line) => line.startsWith('data: ')) + .map((line) => line.slice(6)) + .join(''); - expect(callUpstream).toHaveBeenCalledTimes(2); - const payload = (await readPayload(result)) as { - choices: Array<{ message: { content: string } }>; - }; - expect(payload.choices[0]?.message.content).toBe('It is sunny.'); - - const secondCallBody = callUpstream.mock.calls[1]?.[0] as ChatRequestBody; - const secondMessages = (secondCallBody.messages ?? []) as Array< - Record - >; - const toolMessage = secondMessages - .filter((message) => message.role === 'tool') - .at(-1); - expect(String(toolMessage?.content)).toContain('https://docs.test'); - expect(toolMessage?.tool_call_id).toBe('call_1'); + return { data, event: event ?? '' }; + }) + .filter((frame) => frame.data.length > 0); + }; + + beforeEach(async () => { + for (const name of SEARXNG_ENV_NAMES) { + delete process.env[name]; + } + resetWebSearchProviders(); + resetCredentialRuntimeState(); + cleanupDir(); + fs.mkdirSync(tempDataDir, { recursive: true }); + vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + process.env.CODEBUDDY_AUTH_MODE = 'auto'; + await addCredential({ + bearer_token: 'servertool-token', + responses_passthrough: false, + user_id: 'servertool@example.com', }); + }); + + afterEach(() => { + for (const name of SEARXNG_ENV_NAMES) { + delete process.env[name]; + } + resetWebSearchProviders(); + cleanupDir(); + vi.restoreAllMocks(); + }); - it('accepts alternate query argument shapes', async () => { + describe('/v1/messages', () => { + /** + * The regression, end to end. Claude Code declares `WebSearch` as an + * ordinary function and resolves it itself, so the proxy has to hand the + * model's call straight back. Executing it here instead is what left Claude + * Code with an answer invented from memory and no search at all. + */ + it('hands Claude Code’s own WebSearch call back as a tool_use block', async () => { await enableSearch(); - const fetchMock = vi.fn(async () => makeJsonResponse({ results: [] })); - vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); + const { upstreamCalls } = mockUpstream({ toolName: 'WebSearch' }); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [ + const response = await handleMessagesRequest( + makeRequest('http://localhost/v1/messages'), + { + max_tokens: 256, + messages: [{ role: 'user', content: 'Search for the release date' }], + tools: [ { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_q', - function: { - arguments: '{"q":"alternate query"}', - name: 'web_search', - }, - }, - ], - }, + name: 'WebSearch', + description: 'Search the web', + input_schema: { type: 'object' }, }, ], - }), + }, ); - await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); + const payload = (await response.json()) as { + content: Array>; + stop_reason: string; + }; - const [url] = fetchMock.mock.calls[0] as unknown as [string]; - expect(url).toContain('q=alternate+query'); + expect(payload.content).toEqual([ + expect.objectContaining({ + id: 'call_1', + name: 'WebSearch', + type: 'tool_use', + }), + ]); + expect(payload.stop_reason).toBe('tool_use'); + // Nothing was executed, so upstream was asked exactly once. + expect(upstreamCalls()).toBe(1); }); - it('stops after the iteration cap when the model keeps searching', async () => { + it('runs the search for a declared server tool and reports it structurally', async () => { await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, - ); - - // The model always asks to search; only the final call, which has the - // search tool withdrawn, produces an answer. - const callUpstream = vi.fn(async (body) => { - const hasSearchTool = ( - (body.tools ?? []) as Array<{ - function?: { name?: string }; - }> - ).some((tool) => tool.function?.name === 'web_search'); - - return hasSearchTool - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_loop', - function: { - arguments: '{"query":"again"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Enough.' } }, - ], - }); - }); + const { upstreamCalls } = mockUpstream(); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], + const response = await handleMessagesRequest( + makeRequest('http://localhost/v1/messages'), + { + max_tokens: 256, + messages: [{ role: 'user', content: 'when did it ship?' }], + tools: [ + { + type: 'web_search_20250305', + name: 'web_search', + input_schema: {}, + }, + ], }, - callUpstream, - }); - - // 5 search iterations plus one final call with the search tool removed. - expect(callUpstream).toHaveBeenCalledTimes(6); + ); - const finalBody = callUpstream.mock.calls[5]?.[0] as ChatRequestBody; - expect(finalBody.tools).toEqual([]); + const payload = (await response.json()) as { + content: Array>; + stop_reason: string; + }; - const payload = (await readPayload(result)) as { - choices: Array<{ message: { content?: string } }>; + // Anthropic's own order: the call, its result, then the answer. + expect(payload.content.map((block) => block.type)).toEqual([ + 'server_tool_use', + 'web_search_tool_result', + 'text', + ]); + expect(payload.content[0]).toMatchObject({ + input: { query: 'latest release' }, + name: 'web_search', + }); + const result = payload.content[1] as { + content: Array<{ url: string }>; + tool_use_id: string; }; - expect(payload.choices[0]?.message.content).toBe('Enough.'); - expect(result?.response?.ok).toBe(true); + expect(result.content[0].url).toBe('https://docs.test'); + expect(result.tool_use_id).toBe(payload.content[0].id); + expect(payload.content[2]).toEqual({ + text: 'It shipped yesterday.', + type: 'text', + }); + expect(payload.stop_reason).toBe('end_turn'); + expect(upstreamCalls()).toBe(2); }); - it('preserves unrelated tools and passes through non-search tool calls', async () => { + it('streams the server tool blocks ahead of the answer', async () => { await enableSearch(); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [ + mockUpstream(); + + const response = await handleMessagesRequest( + makeRequest('http://localhost/v1/messages'), + { + max_tokens: 256, + messages: [{ role: 'user', content: 'when did it ship?' }], + stream: true, + tools: [ { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_other', - function: { arguments: '{}', name: 'read_file' }, - }, - ], - }, + type: 'web_search_20250305', + name: 'web_search', + input_schema: {}, }, ], - }), + }, ); - const fetchMock = vi.fn(async () => makeJsonResponse({ results: [] })); - vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], + expect(response.headers.get('content-type')).toContain( + 'text/event-stream', + ); + + const events = await readEvents(response); + const starts = events.filter( + (event) => event.event === 'content_block_start', + ); + const types = starts.map( + (event) => + (JSON.parse(event.data) as { content_block: { type: string } }) + .content_block.type, + ); + + expect(types).toEqual([ + 'server_tool_use', + 'web_search_tool_result', + 'text', + ]); + expect(events[0].event).toBe('message_start'); + // The answer the results produced still reaches the client. + expect(JSON.stringify(events)).toContain('It shipped yesterday.'); + }); + + it('leaves a server tool with no backend for the client to resolve', async () => { + // Backend is passthrough, so nothing runs here. + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough' }); + const { upstreamCalls } = mockUpstream(); + + const response = await handleMessagesRequest( + makeRequest('http://localhost/v1/messages'), + { + max_tokens: 256, + messages: [{ role: 'user', content: 'when did it ship?' }], tools: [ - { type: 'web_search_preview' }, - { type: 'function', function: { name: 'read_file' } }, + { + type: 'web_search_20250305', + name: 'web_search', + input_schema: {}, + }, ], }, - callUpstream, - }); + ); - expect(fetchMock).not.toHaveBeenCalled(); - expect(callUpstream).toHaveBeenCalledTimes(1); - const payload = (await readPayload(result)) as { - choices: Array<{ message: { tool_calls?: unknown[] } }>; + const payload = (await response.json()) as { + content: Array>; }; - expect(payload.choices[0]?.message.tool_calls).toHaveLength(1); + + expect(payload.content.map((block) => block.type)).toEqual(['tool_use']); + expect(upstreamCalls()).toBe(1); }); - it('folds search findings into text when a turn mixes search and client calls', async () => { + it('reports an upstream failure as an Anthropic error', async () => { await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ - results: [ - { - content: 'Docs snippet', - title: 'Docs', - url: 'https://docs.test', - }, - ], - }), - ) as unknown as typeof fetch, - ); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Checking now.', - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"release date"}', - name: 'web_search', - }, - }, - { - id: 'call_read', - function: { arguments: '{"path":"a"}', name: 'read_file' }, - }, - ], - }, - }, - ], - usage: { total_tokens: 12 }, - }), + vi.spyOn(globalThis, 'fetch').mockImplementation( + async () => + new Response(JSON.stringify({ error: { message: 'nope' } }), { + headers: { 'Content-Type': 'application/json' }, + status: 429, + }) as unknown as Response, ); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], + const response = await handleMessagesRequest( + makeRequest('http://localhost/v1/messages'), + { + max_tokens: 256, + messages: [{ role: 'user', content: 'hi' }], tools: [ - { type: 'web_search_preview' }, - { type: 'function', function: { name: 'read_file' } }, - ], - }, - callUpstream, - }); - - // The loop stops after one iteration instead of continuing with a - // transcript that has no result for read_file. - expect(callUpstream).toHaveBeenCalledTimes(1); - - const payload = (await readPayload(result)) as { - choices: Array<{ - finish_reason: string | null; - message: { - content: string | null; - tool_calls?: Array<{ function?: { name?: string }; id?: string }>; - }; - }>; - usage?: { total_tokens?: number }; - }; - const choice = payload.choices[0]; - - expect(choice?.finish_reason).toBe('tool_calls'); - expect(choice?.message.content).toContain('Checking now.'); - expect(choice?.message.content).toContain('https://docs.test'); - // Only the client-owned call is handed back. - expect(choice?.message.tool_calls).toHaveLength(1); - expect(choice?.message.tool_calls?.[0]?.id).toBe('call_read'); - expect(payload.usage?.total_tokens).toBe(12); - }); - - it('keeps a mixed turn that carries usage alongside findings', async () => { - await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, - ); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [ { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - { - id: 'call_read', - function: { arguments: '{}', name: 'read_file' }, - }, - ], - }, + type: 'web_search_20250305', + name: 'web_search', + input_schema: {}, }, ], - usage: { total_tokens: 3 }, - }), - ); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], }, - callUpstream, - }); - - const payload = (await readPayload(result)) as { - choices: Array<{ message: { tool_calls?: unknown[] } }>; - usage?: { total_tokens?: number }; - }; - - expect(payload.choices[0]?.message.tool_calls).toHaveLength(1); - expect(payload.usage?.total_tokens).toBe(3); - }); - - it('omits usage from a mixed turn when upstream reports none', async () => { - await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, - ); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'partial', - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - { - id: 'call_read', - function: { arguments: '{}', name: 'read_file' }, - }, - ], - }, - }, - ], - }), ); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, + expect(response.status).toBe(429); + expect((await response.json()) as { type: string }).toMatchObject({ + type: 'error', }); - - const payload = (await readPayload(result)) as { - choices: Array<{ message: { content: string | null } }>; - usage?: unknown; - }; - - expect(payload.choices[0]?.message.content).toContain('partial'); - expect(payload.usage).toBeUndefined(); }); + }); - it('leaves non-primary choices untouched in a mixed turn', async () => { + describe('/v1/responses', () => { + it('reports the search as a web_search_call item', async () => { await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ - results: [ - { content: 'snippet', title: 'T', url: 'https://t.test' }, - ], - }), - ) as unknown as typeof fetch, - ); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - { id: 'call_read', function: { name: 'read_file' } }, - ], - }, - }, - { finish_reason: 'stop', message: { content: 'second choice' } }, - ], - }), - ); + mockUpstream(); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], + const response = await handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + { + input: 'when did it ship?', + model: 'glm-5.1', tools: [{ type: 'web_search_preview' }], }, - callUpstream, - }); + ); - const payload = (await readPayload(result)) as { - choices: Array<{ - finish_reason: string | null; - message: { content: string | null; tool_calls?: unknown[] }; - }>; + const payload = (await response.json()) as { + output: Array>; }; + const types = payload.output.map((item) => item.type); - // The second choice passes through unchanged. - expect(payload.choices[1]?.message.content).toBe('second choice'); - // The first is rewritten to carry the findings. - expect(payload.choices[0]?.message.content).toContain('https://t.test'); - expect(payload.choices[0]?.message.tool_calls).toHaveLength(1); - }); - - it('truncates long snippets and titles in the rendered findings', async () => { - await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ - results: [ - { - content: 'word '.repeat(400), - title: 'T'.repeat(400), - url: 'https://long.test', - }, - ], - }), - ) as unknown as typeof fetch, + expect(types).toContain('web_search_call'); + expect(types).toContain('message'); + // The search ran before the answer that used it. + expect(types.indexOf('web_search_call')).toBeLessThan( + types.indexOf('message'), ); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - { id: 'call_read', function: { name: 'read_file' } }, - ], - }, - }, - ], - }), - ); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, + expect(payload.output[0]).toMatchObject({ + action: { query: 'latest release', type: 'search' }, + status: 'completed', }); - - const payload = (await readPayload(result)) as { - choices: Array<{ message: { content: string | null } }>; - }; - const content = payload.choices[0]?.message.content ?? ''; - - // Both the title and the snippet are capped, marked with an ellipsis. - expect(content).toContain('…'); - expect(content.length).toBeLessThan(1400); }); - it('carries findings without prior text in a mixed turn', async () => { + it('leaves a client function named web_search to the client', async () => { await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ - results: [ - { content: 'snippet', title: 'T', url: 'https://t.test' }, - ], - }), - ) as unknown as typeof fetch, - ); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: null, - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - { - id: 'call_read', - function: { arguments: '{}', name: 'read_file' }, - }, - ], - }, - }, - ], - }), - ); + const { upstreamCalls } = mockUpstream(); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], + const response = await handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + { + input: 'read the file', + model: 'glm-5.1', + tools: [{ type: 'function', name: 'web_search', parameters: {} }], }, - callUpstream, - }); + ); - const payload = (await readPayload(result)) as { - choices: Array<{ message: { content: string | null } }>; + const payload = (await response.json()) as { + output: Array>; }; - expect(payload.choices[0]?.message.content).toContain('https://t.test'); - }); - - it('returns the upstream error response unchanged', async () => { - await enableSearch(); - const callUpstream = vi.fn(async () => - makeJsonResponse({ error: { message: 'boom' } }, 502), + expect(payload.output.map((item) => item.type)).not.toContain( + 'web_search_call', ); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); - - expect(result?.response?.ok).toBe(false); - expect(result?.response?.status).toBe(502); + expect(upstreamCalls()).toBe(1); }); - it('relaxes a forced tool_choice so the loop can terminate', async () => { + it('streams the search lifecycle events', async () => { await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, - ); - let call = 0; - const callUpstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'done' } }, - ], - }); - }); + mockUpstream(); - await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tool_choice: { function: { name: 'web_search' }, type: 'function' }, + const response = await handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + { + input: 'when did it ship?', + model: 'glm-5.1', + stream: true, tools: [{ type: 'web_search_preview' }], }, - callUpstream, - }); - - const secondCall = callUpstream.mock.calls[1]?.[0] as ChatRequestBody; - expect(secondCall.tool_choice).toBe('auto'); - }); - - it('recognises a plain function tool named web_search', async () => { - await enableSearch(); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - }), ); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'function', function: { name: 'web_search' } }], - }, - callUpstream, - }); + const events = await readEvents(response); + const types = events.map((event) => event.event); - expect(result).not.toBeNull(); + expect(types).toContain('response.output_item.added'); + expect(types).toContain('response.web_search_call.in_progress'); + expect(types).toContain('response.web_search_call.searching'); + expect(types).toContain('response.web_search_call.completed'); + expect(types).toContain('response.output_item.done'); }); + }); - it('ignores non-object tool declarations', async () => { + describe('/v1/chat/completions', () => { + /** + * A chat client's `web_search` function is its own. There is no + * server-tool convention in the chat protocol, so nothing here runs — + * the call goes back for the client to resolve. + */ + it('does not execute a client’s own web_search function', async () => { await enableSearch(); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - }), - ); + const { upstreamCalls } = mockUpstream(); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: ['web_search_preview'], + const response = await proxyChatCompletions( + makeRequest('http://localhost/v1/chat/completions'), + { + messages: [{ role: 'user', content: 'search for it' }], + tools: [{ type: 'function', function: { name: 'web_search' } }], }, - callUpstream, - }); - - // A string tool cannot be a search declaration, so the loop stands down. - expect(result).toBeNull(); - expect(callUpstream).not.toHaveBeenCalled(); - }); - - it('accepts a tool that only names web_search without a type', async () => { - await enableSearch(); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - }), ); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ name: 'web_search_preview' }], - }, - callUpstream, - }); + const payload = (await response.json()) as { + choices: Array<{ message: { tool_calls?: unknown[] } }>; + }; - expect(result).not.toBeNull(); + expect(payload.choices[0].message.tool_calls).toHaveLength(1); + expect(upstreamCalls()).toBe(1); }); - - it('handles a search call with no arguments', async () => { - await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, - ); - let call = 0; - const callUpstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [{ function: { name: 'web_search' } }], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - }); - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); - - const toolMessage = ( - (callUpstream.mock.calls[1]?.[0] as ChatRequestBody).messages ?? [] - ) - .filter((message) => message.role === 'tool') - .at(-1) as { content?: unknown }; - expect(String(toolMessage.content)).toContain('without a query'); - expect(result?.response?.ok).toBe(true); - }); - - it('falls back to raw argument text when JSON is malformed', async () => { - await enableSearch(); - const fetchMock = vi.fn(async () => makeJsonResponse({ results: [] })); - vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); - let call = 0; - const callUpstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { arguments: 'not json', name: 'web_search' }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - }); - }); - - await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); - - const [url] = fetchMock.mock.calls[0] as unknown as [string]; - expect(url).toContain('q=not+json'); - }); - - it('keeps the first usage when a later iteration omits it', async () => { - await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, - ); - let call = 0; - const callUpstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - usage: { prompt_tokens: 4, total_tokens: 9 }, - }) - : makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - }); - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); - - const payload = (await readPayload(result)) as { - usage: { prompt_tokens?: number }; - }; - expect(payload.usage.prompt_tokens).toBe(4); - }); - - it('adopts usage when the first iteration reports none', async () => { - await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, - ); - let call = 0; - const callUpstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - usage: { prompt_tokens: 3, total_tokens: 6 }, - }); - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); - - const payload = (await readPayload(result)) as { - usage: { prompt_tokens?: number }; - }; - expect(payload.usage.prompt_tokens).toBe(3); - }); - - it('sums usage across loop iterations', async () => { - await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, - ); - let call = 0; - const callUpstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - usage: { prompt_tokens: 10, total_tokens: 20 }, - }) - : makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - usage: { prompt_tokens: 5, total_tokens: 8 }, - }); - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); - - const payload = (await readPayload(result)) as { - usage: { prompt_tokens?: number; total_tokens?: number }; - }; - expect(payload.usage.prompt_tokens).toBe(15); - expect(payload.usage.total_tokens).toBe(28); - }); - - it('returns the error when the final call without search tools fails', async () => { - await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, - ); - const callUpstream = vi.fn(async (body) => { - const hasSearchTool = ( - (body.tools ?? []) as Array<{ - function?: { name?: string }; - }> - ).some((tool) => tool.function?.name === 'web_search'); - - return hasSearchTool - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ error: { message: 'late failure' } }, 500); - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); - - expect(result?.response?.status).toBe(500); - }); - }); - - describe('stream synthesis', () => { - it('emits role, content, finish, and usage events', async () => { - const response = synthesizeChatCompletionStream( - { - choices: [ - { - finish_reason: 'stop', - message: { content: 'Hello there', role: 'assistant' }, - }, - ], - created: 42, - id: 'chatcmpl_test', - model: 'glm-5.1', - usage: { total_tokens: 7 }, - }, - 'fallback-model', - ); - - expect(response.headers.get('content-type')).toContain( - 'text/event-stream', - ); - - const events = await readSseEvents(response); - expect(events.at(-1)).toBe('[DONE]'); - - const parsed = events - .filter((event) => event !== '[DONE]') - .map((event) => JSON.parse(event) as Record); - - expect(parsed[0]).toMatchObject({ - choices: [{ delta: { role: 'assistant' } }], - id: 'chatcmpl_test', - model: 'glm-5.1', - object: 'chat.completion.chunk', - }); - expect(JSON.stringify(parsed)).toContain('Hello there'); - expect(JSON.stringify(parsed.at(-2))).toContain('"finish_reason":"stop"'); - expect(JSON.stringify(parsed.at(-1))).toContain('"total_tokens":7'); - }); - - it('chunks long content across multiple deltas', async () => { - const response = synthesizeChatCompletionStream( - { - choices: [ - { - finish_reason: 'stop', - message: { content: 'x'.repeat(2500), role: 'assistant' }, - }, - ], - }, - 'fallback-model', - ); - - const events = await readSseEvents(response); - const contentEvents = events.filter((event) => - event.includes('"content":"x'), - ); - - expect(contentEvents.length).toBeGreaterThan(1); - }); - - it('emits reasoning content and passes through tool calls', async () => { - const response = synthesizeChatCompletionStream( - { - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: null, - reasoning_content: 'Let me check.', - tool_calls: [ - { - function: { arguments: '{}', name: 'read_file' }, - type: 'function', - }, - ], - }, - }, - ], - }, - 'fallback-model', - ); - - const events = await readSseEvents(response); - const parsed = events - .filter((event) => event !== '[DONE]') - .map((event) => JSON.parse(event) as Record); - - expect(JSON.stringify(parsed)).toContain('Let me check.'); - expect(JSON.stringify(parsed)).toContain('read_file'); - expect(JSON.stringify(parsed.at(-1))).toContain( - '"finish_reason":"tool_calls"', - ); - }); - - it('omits a usage event when the payload has none', async () => { - const response = synthesizeChatCompletionStream( - { choices: [{ finish_reason: 'stop', message: { content: 'hi' } }] }, - 'fallback-model', - ); - - const events = await readSseEvents(response); - - expect(JSON.stringify(events)).not.toContain('"usage"'); - expect(events.at(-1)).toBe('[DONE]'); - }); - - it('injects an id and type for tool calls that omit them', async () => { - const response = synthesizeChatCompletionStream( - { - choices: [ - { - finish_reason: null, - message: { - content: '', - tool_calls: [{ function: { arguments: '{}', name: 'go' } }], - }, - }, - ], - }, - 'fallback-model', - ); - - const events = await readSseEvents(response); - const parsed = events - .filter((event) => event !== '[DONE]') - .map((event) => JSON.parse(event) as Record); - const toolEvent = JSON.parse( - events.find((event) => event.includes('"tool_calls"')) ?? '{}', - ) as { - choices: Array<{ - delta: { tool_calls: Array> }; - }>; - }; - const toolCall = toolEvent.choices[0]?.delta.tool_calls?.[0]; - - expect(toolCall?.id).toMatch(/^call_/); - expect(toolCall?.type).toBe('function'); - expect(toolCall?.index).toBe(0); - // No text was produced, so no content delta is emitted. With no usage - // block the finish event is the last one before [DONE]. - expect(JSON.stringify(parsed)).not.toContain('"content":""'); - expect(JSON.stringify(parsed.at(-1))).toContain( - '"finish_reason":"tool_calls"', - ); - }); - - it('skips reasoning when only the reasoning field is set', async () => { - const response = synthesizeChatCompletionStream( - { - choices: [ - { - finish_reason: 'stop', - message: { content: 'hi', reasoning: 'step one' }, - }, - ], - }, - 'fallback-model', - ); - - const events = await readSseEvents(response); - - expect(JSON.stringify(events)).toContain('step one'); - }); - - it('falls back to the provided model and a generated id', async () => { - const response = synthesizeChatCompletionStream( - { choices: [{ finish_reason: 'stop', message: { content: 'hi' } }] }, - 'fallback-model', - ); - - const events = await readSseEvents(response); - const first = JSON.parse(events[0] ?? '{}') as Record; - - expect(first.model).toBe('fallback-model'); - expect(String(first.id)).toMatch(/^chatcmpl_/); - }); - }); - - describe('upstream streaming contract', () => { - const enableSearxngSearch = async (): Promise => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - vi.spyOn(globalThis, 'fetch').mockImplementation(async () => - makeJsonResponse({ - results: [ - { - content: 'Search result', - title: 'Result', - url: 'https://result.test', - }, - ], - }), - ); - }; - - const inlineBody = (): ChatRequestBody => ({ - messages: [{ content: 'Search', role: 'user' }], - stream: true, - tools: [{ type: 'web_search_preview' }], - }); - - it('hands a client call from a buffered iteration back to the client', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async () => { - call += 1; - - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"query":"mixed"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - // A buffered iteration can mix a server tool with a client-owned one: - // the server tool runs here, the client's is handed back unanswered. - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Checking both.', - tool_calls: [ - { - function: { - arguments: '{"query":"second"}', - name: 'web_search', - }, - }, - { - id: 'call_client', - function: { arguments: '{}', name: 'client_tool' }, - type: 'function', - }, - ], - }, - }, - ], - }); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const text = await result!.response!.text(); - - expect(text).toContain('Checking both.'); - expect(text).toContain('client_tool'); - expect(text).toContain('"finish_reason":"tool_calls"'); - }); - - it('ends the turn when a follow-up iteration stops calling server tools', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async () => { - call += 1; - - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"only hop"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - // No server tool this time: the held frames are forwarded untouched - // and the turn is over. - return makeSseResponse( - { - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_client', - index: 0, - function: { arguments: '{}', name: 'client_tool' }, - type: 'function', - }, - ], - }, - index: 0, - }, - ], - }, - { choices: [{ delta: {}, finish_reason: 'tool_calls', index: 0 }] }, - ); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const text = await result!.response!.text(); - - expect(text).toContain('client_tool'); - expect(text).toContain('"finish_reason":"tool_calls"'); - expect(upstream).toHaveBeenCalledTimes(2); - }); - - it('keeps malformed frames and returns mixed client tool calls', async () => { - await enableSearxngSearch(); - const upstream = vi.fn(async () => { - const toolChunk = { - choices: [ - { - delta: { - content: 'Searching.', - reasoning_content: 'Need current data.', - tool_calls: [ - { - function: { - arguments: '{"query":"mixed tools"}', - name: 'web_search', - }, - }, - { - id: 'call_client', - index: 1, - function: { - arguments: '{"city":"Shenzhen"}', - name: 'get_weather', - }, - type: 'function', - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - created: 123, - id: 'chatcmpl_mixed', - model: 'hy4-dev', - object: 'chat.completion.chunk', - }; - - return new Response( - `event: ping\n\ndata:\n\ndata: ${JSON.stringify(toolChunk)}\n\ndata: [DONE]`, - { headers: { 'Content-Type': 'text/event-stream' } }, - ); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const text = await result!.response!.text(); - - expect(text).toContain('event: ping'); - expect(text).toContain('Need current data.'); - expect(text).toContain('server_tool_0_0'); - expect(text).toContain('Search result'); - expect(text).toContain('get_weather'); - expect(text).toContain('data: [DONE]'); - expect(upstream).toHaveBeenCalledTimes(1); - }); - - it('aggregates usage when the turn after a streamed tool call completes', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async () => { - call += 1; - - if (call === 1) { - return makeSseResponse( - { - choices: [ - { - delta: { - reasoning_content: 'Need usage data.', - tool_calls: [ - { index: 0, function: { name: 'web_search' } }, - ], - }, - index: 0, - }, - ], - }, - { - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_usage', - index: 0, - function: { arguments: '{"query":"usage"}' }, - }, - ], - }, - index: 0, - }, - ], - usage: { - completion_tokens: 1, - prompt_tokens: 2, - total_tokens: 3, - }, - }, - { - choices: [{ delta: {}, finish_reason: 'tool_calls', index: 0 }], - }, - ); - } - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Final answer.' } }, - ], - usage: { completion_tokens: 4, prompt_tokens: 5, total_tokens: 9 }, - }); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const events = await readSseEvents(result!.response!); - const usageEvent = events - .map((event) => (event === '[DONE]' ? null : JSON.parse(event))) - .find((event) => event?.usage); - - expect(usageEvent?.usage).toEqual({ - completion_tokens: 5, - prompt_tokens: 7, - total_tokens: 12, - }); - expect(JSON.stringify(events)).toContain('Final answer.'); - expect(JSON.stringify(events)).toContain('call_usage'); - expect(upstream).toHaveBeenCalledTimes(2); - }); - - it('returns a later upstream error inside the composite stream', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"error"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }) - : makeJsonResponse({ error: { message: 'follow-up failed' } }, 502); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - - await expect(result!.response!.text()).resolves.toContain( - 'follow-up failed', - ); - }); - - it('synthesizes an error for an empty later upstream failure', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"error"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }) - : new Response(null, { status: 502 }); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - - await expect(result!.response!.text()).resolves.toContain( - 'Upstream request failed with status 502', - ); - }); - - it('rejects invalid JSON from a successful later upstream response', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"invalid follow-up"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }) - : new Response('not json', { status: 200 }); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - - await expect(result!.response!.text()).rejects.toThrow(); - }); - - it('returns later mixed client calls after executing another server tool', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async () => { - call += 1; - - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_first', - index: 0, - function: { - arguments: '{"query":"first"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Use both results.', - tool_calls: [ - { - function: { - arguments: '{"query":"second"}', - name: 'web_search', - }, - }, - { - id: 'call_client', - function: { - arguments: '{}', - name: 'client_tool', - }, - type: 'function', - }, - ], - }, - }, - ], - }); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const text = await result!.response!.text(); - - expect(text).toContain('server_tool_1_0'); - expect(text).toContain('client_tool'); - expect(text).toContain('Search result'); - expect(upstream).toHaveBeenCalledTimes(2); - }); - - it('drops local tools after the inline iteration budget is exhausted', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async (body) => { - call += 1; - - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"query":"loop 0"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - if (body.tools?.length) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - function: { - arguments: `{"query":"loop ${call - 1}"}`, - name: 'web_search', - }, - }, - ], - }, - }, - ], - usage: { total_tokens: 1 }, - }); - } - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Budget answer.' } }, - ], - usage: { total_tokens: 2 }, - }); - }); - const body = inlineBody(); - body.tool_choice = { function: { name: 'web_search' }, type: 'function' }; - - const result = await executeWebSearchLoop({ - body, - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const text = await result!.response!.text(); - const finalBody = upstream.mock.calls.at(-1)?.[0]; - - expect(text).toContain('Budget answer.'); - expect(finalBody?.tools).toEqual([]); - expect(upstream).toHaveBeenCalledTimes(6); - }); - - it('streams the budget fallback answer once local tools are dropped', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async (body) => { - call += 1; - - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"query":"loop 0"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - if (body.tools?.length) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - function: { - arguments: `{"query":"loop ${call - 1}"}`, - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - } - - // The budget is spent and every server tool is gone, so this answer - // ends the turn. It is streamed, so it must reach the client as-is - // rather than being buffered into a payload. - return makeSseResponse({ - choices: [{ delta: { content: 'Budget stream.' }, index: 0 }], - }); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const text = await result!.response!.text(); - const finalBody = upstream.mock.calls.at(-1)?.[0]; - - expect(text).toContain('Budget stream.'); - expect(finalBody?.tools).toEqual([]); - expect(upstream).toHaveBeenCalledTimes(6); - }); - - it('uses a JSON budget fallback answer when upstream does not stream', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async (body) => { - call += 1; - - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"query":"loop 0"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - if (body.tools?.length) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - function: { - arguments: `{"query":"loop ${call - 1}"}`, - name: 'web_search', - }, - }, - ], - }, - }, - ], - usage: { total_tokens: 1 }, - }); - } - - // A non-streaming upstream still has to produce a usable answer once - // the budget is spent. - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'JSON fallback.' } }, - ], - usage: { total_tokens: 4 }, - }); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const text = await result!.response!.text(); - - expect(text).toContain('JSON fallback.'); - // Usage accumulates across every iteration of the loop. - expect(text).toContain('"total_tokens":8'); - expect(upstream).toHaveBeenCalledTimes(6); - }); - - it('hands a client tool call from the budget fallback back to the client', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async (body) => { - call += 1; - - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"query":"loop 0"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - if (body.tools?.length) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - function: { - arguments: `{"query":"loop ${call - 1}"}`, - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - } - - // Every server tool was stripped, so a tool call here can only be the - // client's own: it has to be handed back rather than executed. - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_client', - index: 0, - function: { arguments: '{}', name: 'client_tool' }, - type: 'function', - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const text = await result!.response!.text(); - - expect(text).toContain('client_tool'); - expect(text).toContain('"finish_reason":"tool_calls"'); - expect(upstream).toHaveBeenCalledTimes(6); - }); - - it('stops the budget fallback when the client disconnects', async () => { - await enableSearxngSearch(); - const encoder = new TextEncoder(); - let call = 0; - let resolveCancel: (() => void) | undefined; - let upstreamCancelled: Promise | undefined; - - const upstream = vi.fn(async (body) => { - call += 1; - - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"query":"loop 0"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - if (body.tools?.length) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - function: { - arguments: `{"query":"loop ${call - 1}"}`, - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - } - - // The fallback answer never finishes, so only a client-side - // cancellation can end this turn. - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [{ delta: { content: 'Fallback.' }, index: 0 }], - })}\n\n`, - ), - ); - upstreamCancelled = new Promise((resolve) => { - resolveCancel = () => resolve(true); - }); - }, - cancel: () => { - resolveCancel?.(); - }, - }), - { headers: { 'Content-Type': 'text/event-stream' } }, - ); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const reader = result!.response!.body!.getReader(); - const decoder = new TextDecoder(); - let seen = ''; - - while (!seen.includes('Fallback.')) { - const chunk = await reader.read(); - expect(chunk.done).toBe(false); - seen += decoder.decode(chunk.value); - } - - await reader.cancel(); - - // The disconnect has to reach the parked upstream read, not just the - // downstream stream: a stalled upstream would otherwise stay alive. - await expect(upstreamCancelled).resolves.toBe(true); - expect(upstream).toHaveBeenCalledTimes(6); - }); - - it('returns an error when the final budget fallback fails upstream', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async (body) => { - call += 1; - - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_initial', - index: 0, - function: { - arguments: '{"query":"fallback error"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - if (body.tools?.length) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: `call_${call}`, - function: { - arguments: '{"query":"again"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - } - - return makeJsonResponse({ error: { message: 'fallback failed' } }, 502); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - - await expect(result!.response!.text()).resolves.toContain( - 'fallback failed', - ); - expect(upstream).toHaveBeenCalledTimes(6); - }); - - it('leaves an empty non-SSE initial response untouched', async () => { - await enableSearxngSearch(); - const response = new Response(null, { status: 204 }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: async () => response, - }); - - expect(result?.response).toBe(response); - await expect(result!.response!.text()).resolves.toBe(''); - }); - - it('always asks upstream to stream and buffers the response', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - - const upstreamBodies: Array> = []; - let call = 0; - - const fetchMock = vi.fn( - async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - - if (url.includes('searx.test')) { - return makeJsonResponse({ - results: [{ content: 'snip', title: 'T', url: 'https://t.test' }], - }); - } - - call += 1; - upstreamBodies.push(JSON.parse(String(init?.body ?? '{}'))); - - // Upstream only ever speaks SSE. - const chunk = - call === 1 - ? { - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_1', - index: 0, - type: 'function', - function: { - arguments: '{"query":"q1"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - } - : { - choices: [ - { delta: { content: 'Done.' }, finish_reason: 'stop' }, - ], - }; - - return new Response( - `data: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`, - { - status: 200, - headers: { - 'Content-Type': 'text/event-stream; charset=utf-8', - }, - }, - ); - }, - ); - - vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - stream: false, - tools: [{ type: 'web_search_preview' }], - }, - callUpstream: (loopBody) => - proxyChatCompletions( - new NextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - loopBody, - createProxyContextFromCredential({ - data: { - bearer_token: 'stream-token', - user_id: 'stream@example.com', - }, - filePath: '/tmp/stream.json', - filename: 'stream.json', - }), - ), - }); - - // Upstream must never receive stream:false — it answers 11101. - expect(upstreamBodies.length).toBeGreaterThan(0); - for (const body of upstreamBodies) { - expect(body.stream).toBe(true); - } - - // The buffered result is still a normal JSON completion for the loop. - const payload = (await readPayload(result)) as { - choices: Array<{ message: { content: string } }>; - }; - expect(payload.choices[0]?.message.content).toBe('Done.'); - }); - }); - - describe('config gating', () => { - it('labels the backend selector in every locale', () => { - expect(getSettingLabels('en-US').CODEBUDDY_WEB_SEARCH_BACKEND).toBe( - 'Web search backend', - ); - expect(getSettingLabels('zh-CN').CODEBUDDY_WEB_SEARCH_BACKEND).toBe( - 'WebSearch 后端', - ); - expect(getSettingLabels('ja-JP').CODEBUDDY_WEB_SEARCH_BACKEND).toBe( - 'Web 検索バックエンド', - ); - }); - - it('has no separate enable switch', () => { - // `none` is the off state, so a second control could only contradict it. - expect(getSettingLabels('en-US')).not.toHaveProperty( - 'CODEBUDDY_WEB_SEARCH_ENABLED', - ); - }); - - it('keeps the setting disabled when the backend is none', async () => { - // No SEARXNG_URL here, so `searxng` cannot be built — but `isWebSearchEnabled` - // only reflects the configured choice; the provider resolution is what - // degrades it to nothing. - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough' }); - - await expect(isWebSearchEnabled()).resolves.toBe(false); - }); - - it('degrades to no provider when the chosen backend is unconfigured', async () => { - // `searxng` is selected but SEARXNG_URL is unset, so no provider can be - // built and the tool is not advertised to the model. - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - - expect( - resolveSearchProvider('searxng', async () => 'https://cb.test'), - ).toBeNull(); - }); - - it('accepts the backend values from the console', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - await expect(isWebSearchEnabled()).resolves.toBe(true); - - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough' }); - await expect(isWebSearchEnabled()).resolves.toBe(false); - - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - await expect(isWebSearchEnabled()).resolves.toBe(true); - }); - - it('reads the setting from the environment when nothing is persisted', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - process.env.CODEBUDDY_WEB_SEARCH_BACKEND = 'searxng'; - resetWebSearchProviders(); - - await expect(isWebSearchEnabled()).resolves.toBe(true); - - delete process.env.CODEBUDDY_WEB_SEARCH_BACKEND; - }); - - it('reflects the backend choice', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - await expect(isWebSearchEnabled()).resolves.toBe(true); - - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough' }); - await expect(isWebSearchEnabled()).resolves.toBe(false); - }); - }); -}); - -describe('responses tool translation', () => { - beforeEach(() => { - for (const name of SEARXNG_ENV_NAMES) { - delete process.env[name]; - } - resetWebSearchProviders(); - }); - - afterEach(() => { - for (const name of SEARXNG_ENV_NAMES) { - delete process.env[name]; - } - resetWebSearchProviders(); - }); - - it('translates web_search_preview without requiring SearXNG', () => { - const tools = translateResponsesToolsToChat([ - { type: 'web_search_preview' }, - ]) as Array<{ function: { name: string } }>; - - expect(tools.map((tool) => tool.function.name)).toEqual(['web_search']); - }); - - it('translates web_search_preview into a callable function when configured', () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - - const tools = translateResponsesToolsToChat([ - { type: 'web_search_preview' }, - { type: 'function', name: 'read_file' }, - ]) as Array<{ function: { name: string } }>; - - expect(tools.map((tool) => tool.function.name)).toEqual([ - 'web_search', - 'read_file', - ]); - }); - - it('accepts dated Anthropic-style server tool types', () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - - const tools = translateResponsesToolsToChat([ - { type: 'web_search_20260209', name: 'web_search' }, - ]) as Array<{ function: { name: string } }>; - - expect(tools.map((tool) => tool.function.name)).toEqual(['web_search']); - }); - - it('preserves search tools nested in a namespace', () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - - const tools = translateResponsesToolsToChat([ - { - type: 'namespace', - name: 'docs', - tools: [{ type: 'web_search_preview' }], - }, - ]) as Array<{ function: { name: string } }>; - - expect(tools.map((tool) => tool.function.name)).toEqual(['web_search']); - }); -}); - -describe('chat proxy web search integration', () => { - const repoRoot = process.cwd(); - const tempRootDir = path.join(repoRoot, '.tmp-websearch-proxy-root'); - const tempAccessKeysPath = path.join( - tempRootDir, - '.codebuddy_data', - 'access-keys.json', - ); - - const cleanup = (): void => { - fs.rmSync(tempRootDir, { force: true, recursive: true, maxRetries: 5 }); - }; - - const makeNextRequest = ( - url: string, - init?: ConstructorParameters[1], - ): NextRequest => new NextRequest(url, init); - - beforeEach(async () => { - cleanup(); - resetCredentialRuntimeState(); - await resetUsageStats(); - vi.restoreAllMocks(); - vi.spyOn(console, 'error').mockImplementation(() => undefined); - vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); - fs.rmSync(tempAccessKeysPath, { force: true }); - await addCredential({ - bearer_token: 'websearch-test-token', - first_message_role_to_system: false, - responses_passthrough: false, - user_id: 'websearch@example.com', - }); - process.env.CODEBUDDY_AUTH_MODE = 'token'; - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - }); - - afterEach(() => { - cleanup(); - for (const name of SEARXNG_ENV_NAMES) { - delete process.env[name]; - } - resetWebSearchProviders(); - vi.useRealTimers(); - }); - - it('serves a synthesized SSE stream when a streaming client triggers search', async () => { - const upstreamCalls: string[] = []; - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - - if (url.includes('searx.test')) { - return makeJsonResponse({ - results: [ - { content: 'Found it', title: 'Docs', url: 'https://docs.test' }, - ], - }) as unknown as Response; - } - - upstreamCalls.push(url); - - return makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { content: 'It is sunny.', role: 'assistant' }, - }, - ], - model: 'glm-5.1', - }) as unknown as Response; - }, - ); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ role: 'user', content: 'weather today?' }], - stream: true, - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toContain('text/event-stream'); - - const text = await response.text(); - expect(text).toContain('"content":"It is sunny."'); - expect(text).toContain('data: [DONE]'); - // The loop ran before the stream was synthesized. - expect(upstreamCalls.length).toBeGreaterThan(0); - }); - - it('keeps ordinary replies live when server web tools are available', async () => { - const encoder = new TextEncoder(); - let releaseFinalChunk: (() => void) | undefined; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - throw new Error('Search should not run for an ordinary reply'); - } - - return new Response( - new ReadableStream({ - start: (controller) => { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [{ delta: { content: 'First chunk.' }, index: 0 }], - })}\n\n`, - ), - ); - releaseFinalChunk = () => { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { - delta: { content: ' Final chunk.' }, - finish_reason: 'stop', - index: 0, - }, - ], - })}\n\ndata: [DONE]\n\n`, - ), - ); - controller.close(); - }; - }, - }), - { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } }, - ); - }); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ role: 'user', content: 'say hello' }], - stream: true, - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - ); - const reader = response.body!.getReader(); - const first = await reader.read(); - - expect(new TextDecoder().decode(first.value)).toContain('First chunk.'); - expect(releaseFinalChunk).toBeTypeOf('function'); - - releaseFinalChunk!(); - - const remainder: string[] = []; - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - remainder.push(new TextDecoder().decode(chunk.value)); - } - - expect(remainder.join('')).toContain('Final chunk.'); - expect(remainder.join('')).toContain('data: [DONE]'); - }); - - it('keeps a passthrough fetch live when search executes locally', async () => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - const encoder = new TextEncoder(); - let releaseFinalChunk: (() => void) | undefined; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - throw new Error('Search should not run for a passthrough fetch'); - } - - return new Response( - new ReadableStream({ - start: (controller) => { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_fetch', - index: 0, - function: { - arguments: '{"url":"https://page.test"}', - name: 'web_fetch', - }, - }, - ], - }, - index: 0, - }, - ], - })}\n\n`, - ), - ); - releaseFinalChunk = () => { - controller.enqueue(encoder.encode('data: [DONE]\n\n')); - controller.close(); - }; - }, - }), - { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } }, - ); - }); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ role: 'user', content: 'read the page' }], - stream: true, - tools: [ - { type: 'web_search_20260209', name: 'web_search' }, - { type: 'web_fetch_20250910', name: 'web_fetch' }, - ], - }, - ); - const reader = response.body!.getReader(); - const first = await reader.read(); - - expect(new TextDecoder().decode(first.value)).toContain('web_fetch'); - expect(releaseFinalChunk).toBeTypeOf('function'); - releaseFinalChunk!(); - await reader.cancel(); - }); - - it('replays ignorable SSE frames before ordinary content', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response( - [ - ': keepalive\n\n', - 'data: \n\n', - 'data: {invalid\n\n', - `data: ${JSON.stringify({})}\n\n`, - `data: ${JSON.stringify({ - choices: [{ delta: { tool_calls: [{ function: {} }] } }], - })}\n\n`, - `data: ${JSON.stringify({ - choices: [{ delta: { content: 'Ordinary answer.' }, index: 0 }], - })}\n\n`, - 'data: [DONE]\n\n', - ].join(''), - { headers: { 'Content-Type': 'text/event-stream' } }, - ), - ); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ role: 'user', content: 'Say hello' }], - stream: true, - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - ); - - expect(await response.text()).toContain('Ordinary answer.'); - }); - - it('passes through a stream that ends before meaningful content', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response('data: [DONE]\n\n', { - headers: { 'Content-Type': 'text/event-stream' }, - }), - ); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ role: 'user', content: 'Say nothing' }], - stream: true, - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - ); - - expect(await response.text()).toContain('data: [DONE]'); - }); - - it.each([ - ['id', { id: 'call_search' }], - ['position', {}], - ])('detects fragmented local tool names keyed by %s', async (_label, key) => { - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return makeJsonResponse({ results: [] }); - } - - upstreamCalls++; - return upstreamCalls === 1 - ? makeSseResponse( - { - choices: [ - { - delta: { - tool_calls: [{ ...key, function: { name: 'web_' } }], - }, - finish_reason: null, - index: 0, - }, - ], - }, - { - choices: [ - { - delta: { - tool_calls: [ - { - ...key, - function: { - arguments: '{"query":"fragments"}', - name: 'search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }, - ) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Found.' } }, - ], - }); - }); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ role: 'user', content: 'Search fragments' }], - stream: true, - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - ); - - expect(await response.text()).toContain('Found.'); - expect(upstreamCalls).toBe(2); - }); - - it('preserves an upstream SSE response without a body', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(null, { - headers: { 'Content-Type': 'text/event-stream' }, - status: 204, - }), - ); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ role: 'user', content: 'No body' }], - stream: true, - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - ); - - expect(response.status).toBe(204); - expect(response.body).toBeNull(); - }); - - it('propagates an upstream failure after replay starts', async () => { - const encoder = new TextEncoder(); - let failStream: (() => void) | undefined; - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response( - new ReadableStream({ - start: (controller) => { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [{ delta: { content: 'Partial.' }, index: 0 }], - })}\n\n`, - ), - ); - failStream = () => controller.error(new Error('stream failed')); - }, - }), - { headers: { 'Content-Type': 'text/event-stream' } }, - ), - ); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ role: 'user', content: 'Fail later' }], - stream: true, - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - ); - - expect(failStream).toBeTypeOf('function'); - failStream!(); - await expect(response.text()).rejects.toThrow('stream failed'); - }); - - it('emits Responses web_search_call lifecycle before the final message', async () => { - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { - content: 'Current result', - title: 'News', - url: 'https://news.test', - }, - ], - }); - } - - upstreamCalls++; - if (upstreamCalls === 1) { - return makeSseResponse( - { - choices: [ - { - delta: { reasoning_content: 'I need current information.' }, - finish_reason: null, - index: 0, - }, - ], - }, - { - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { name: 'web_' }, - }, - ], - }, - finish_reason: null, - index: 0, - }, - ], - }, - { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { - arguments: '{"query":"latest news"}', - name: 'search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }, - ); - } - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Latest answer.' } }, - ], - }); - }); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'What is new?', - instructions: 'Use current sources.', - stream: true, - tools: [{ type: 'web_search_preview' }], - }, - ); - const text = await response.text(); - const events = text - .split('\n\n') - .flatMap((frame) => - frame - .split('\n') - .filter((line) => line.startsWith('data: ')) - .map((line) => line.slice(6)), - ) - .filter((payload) => payload !== '[DONE]') - .map((payload) => JSON.parse(payload) as Record); - const addedItems = events.filter( - (event) => event.type === 'response.output_item.added', - ) as Array<{ - item: { type: string }; - output_index: number; - }>; - const completed = events.find( - (event) => event.type === 'response.completed', - ) as { - response: { output: Array<{ type: string }> }; - }; - - expect(text).toContain('"type":"web_search_call"'); - expect(text).toContain('"type":"response.web_search_call.in_progress"'); - expect(text).toContain('"type":"response.web_search_call.searching"'); - expect(text).toContain('"type":"response.web_search_call.completed"'); - expect(text).toContain('"action":{"type":"search","query":"latest news"}'); - expect(text).not.toContain( - '"type":"function_call","call_id":"call_search"', - ); - expect(text.indexOf('"type":"web_search_call"')).toBeLessThan( - text.indexOf('Latest answer.'), - ); - expect(new Set(addedItems.map((event) => event.output_index)).size).toBe( - addedItems.length, - ); - expect(completed.response.output.map((item) => item.type)).toEqual( - addedItems.map((event) => event.item.type), - ); - }); - - it('leaves a client-declared web_fetch to the client on the Responses route', async () => { - // The Responses API has no `web_fetch` server tool — only `web_search`. A - // client that declares one as a plain function owns it and resolves it - // itself, and no backend setting changes that: the setting chooses who runs - // the *proxy's* tool, not whether the proxy may take the client's. - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - let pageFetches = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/webfetch') || url.includes('page.test')) { - pageFetches += 1; - - return makeJsonResponse({ content: 'Fetched body.' }); - } - - upstreamCalls += 1; - - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - role: 'assistant', - tool_calls: [ - { - id: 'call_fetch', - function: { - arguments: '{"url":"https://page.test/a"}', - name: 'web_fetch', - }, - }, - ], - }, - }, - ], - }); - }); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Fetch the page', - tools: [{ type: 'function', name: 'web_fetch' }], - }, - ); - const body = await response.text(); - - // The proxy neither fetched nor re-asked upstream: the call is handed back - // for the client to resolve. - expect(pageFetches).toBe(0); - expect(upstreamCalls).toBe(1); - expect(body).not.toContain('open_page'); - }); - - it('streams a Responses open_page lifecycle for local fetch', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/webfetch')) { - return makeJsonResponse({ content: 'Fetched stream.' }); - } - - if (url.includes('stream.test')) { - throw new Error('The endpoint result should win the local fallback'); - } - - upstreamCalls++; - return upstreamCalls === 1 - ? makeSseResponse( - { - choices: [ - { - delta: { reasoning_content: 'I need to read the page.' }, - finish_reason: null, - index: 0, - }, - ], - }, - { - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"url":"https://stream.test/page"}', - name: 'webfetch', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }, - ) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Fetched.' } }, - ], - }); - }); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Fetch the page', - stream: true, - tools: [{ type: 'web_fetch_20250910', name: 'web_fetch' }], - }, - ); - const text = await response.text(); - - expect(text).toContain('response.web_search_call.in_progress'); - expect(text).toContain('response.web_search_call.completed'); - expect(text).toContain( - '"action":{"type":"open_page","url":"https://stream.test/page"}', - ); - expect(text).not.toContain('"type":"function_call","call_id"'); - }); - - it('streams ordinary Responses output with instructions', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - makeSseResponse({ - choices: [ - { - delta: { content: 'Instruction answer.' }, - finish_reason: 'stop', - index: 0, - }, - ], - }), - ); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Answer normally', - instructions: 'Be concise.', - stream: true, - }, - ); - - expect(await response.text()).toContain('Instruction answer.'); - }); - - it('streams Responses search progress before the backend finishes', async () => { - let finishSearch: ((response: Response) => void) | undefined; - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return await new Promise((resolve) => { - finishSearch = resolve; - }); - } - - upstreamCalls++; - return upstreamCalls === 1 - ? makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"live query"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Live answer.' } }, - ], - }); - }); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Search live', - stream: true, - tools: [{ type: 'web_search_preview' }], - }, - ); - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let beforeResult = ''; - - while (!beforeResult.includes('response.web_search_call.searching')) { - const chunk = await reader.read(); - expect(chunk.done).toBe(false); - beforeResult += decoder.decode(chunk.value); - } - - expect(finishSearch).toBeTypeOf('function'); - expect(beforeResult).not.toContain('response.web_search_call.completed'); - finishSearch!(makeJsonResponse({ results: [] })); - - let afterResult = ''; - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - afterResult += decoder.decode(chunk.value); - } - - expect(afterResult).toContain('response.web_search_call.completed'); - expect(afterResult).toContain('Live answer.'); - }); - - it('streams a Responses error when local tool upstream execution fails', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - makeJsonResponse({ error: { message: 'upstream failed' } }, 502), - ); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Search live', - stream: true, - tools: [{ type: 'web_search_preview' }], - }, - ); - const text = await response.text(); - - expect(text).toContain('"type":"response.error"'); - expect(text).toContain('data: [DONE]'); - }); - - it('terminates Responses with an error when a post-tool request fails', async () => { - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return makeJsonResponse({ results: [] }); - } - - upstreamCalls++; - return upstreamCalls === 1 - ? makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"error after search"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }) - : makeJsonResponse({ error: { message: 'follow-up failed' } }, 502); - }); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Search and fail', - stream: true, - tools: [{ type: 'web_search_preview' }], - }, - ); - const text = await response.text(); - - expect(text).toContain('"type":"response.error"'); - // The upstream said why it failed, so its own words are passed through - // rather than the proxy's generic failure message. - expect(text).toContain('follow-up failed'); - expect(text).not.toContain('"type":"response.completed"'); - }); - - it.each([ - ['string', 'follow-up string', 'follow-up string'], - [ - 'message-less object', - { code: 'upstream_error' }, - // No message exists anywhere in the payload, so the raw JSON is kept: - // it still carries `code`, which the generic fallback would have lost. - // The quotes are escaped because the frame is JSON-encoded for SSE. - '{\\"error\\":{\\"code\\":\\"upstream_error\\"}}', - ], - ])( - 'maps a %s post-tool error payload to a terminal Responses error', - async (_label, error, expectedMessage) => { - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return makeJsonResponse({ results: [] }); - } - - upstreamCalls++; - return upstreamCalls === 1 - ? makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"error payload"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }) - : makeJsonResponse({ error }); - }); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Search and report the error', - stream: true, - tools: [{ type: 'web_search_preview' }], - }, - ); - const text = await response.text(); - - expect(text).toContain('"type":"response.error"'); - expect(text).toContain(expectedMessage); - expect(text).not.toContain('"type":"response.completed"'); - }, - ); - - it('does not resume a Responses server-tool loop after disconnect', async () => { - let finishSearch: ((response: Response) => void) | undefined; - let upstreamCalls = 0; - const cancelSpy = vi.spyOn(ReadableStream.prototype, 'cancel'); - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return await new Promise((resolve) => { - finishSearch = resolve; - }); - } - - upstreamCalls++; - if (upstreamCalls === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"query":"cancel response"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Late answer.' } }, - ], - }); - }); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Search and disconnect', - stream: true, - tools: [{ type: 'web_search_preview' }], - }, - ); - - await vi.waitFor(() => expect(finishSearch).toBeTypeOf('function')); - await response.body!.cancel(); - const callsAfterClientCancel = cancelSpy.mock.calls.length; - expect(callsAfterClientCancel).toBeGreaterThan(0); - finishSearch!(makeJsonResponse({ results: [] })); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(upstreamCalls).toBe(1); - expect(cancelSpy.mock.calls.length).toBe(callsAfterClientCancel); - }); - - it('drops an unexecutable Anthropic server tool but keeps a client function', async () => { - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - const upstreamBodies: Array> = []; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { - upstreamBodies.push( - JSON.parse(String(init?.body)) as Record, - ); - - return makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'Done.' } }], - }); - }); - - await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 256, - messages: [{ role: 'user', content: 'Search' }], - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 256, - messages: [{ role: 'user', content: 'Use my function' }], - tools: [{ name: 'web_search', input_schema: {} }], - }, - ); - - expect(upstreamBodies[0]?.tools).toEqual([]); - expect(upstreamBodies[1]?.tools).toEqual([ - expect.objectContaining({ - type: 'function', - function: expect.objectContaining({ name: 'web_search' }), - }), - ]); - }); - - it('replays Anthropic server-tool history as paired tool messages', async () => { - let upstreamBody: Record | undefined; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { - upstreamBody = JSON.parse(String(init?.body)) as Record; - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Follow-up.' } }, - ], - }); - }); - const encryptedContent = btoa( - JSON.stringify({ - content: 'Search snippet', - title: 'Result title', - url: 'https://result.test', - }), - ); - - await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 256, - messages: [ - { role: 'user', content: 'Research this' }, - { - role: 'assistant', - content: [ - { - type: 'server_tool_use', - id: 'srv_search', - name: 'web_search', - input: { query: 'current topic' }, - }, - { - type: 'web_search_tool_result', - tool_use_id: 'srv_search', - content: [ - { - type: 'web_search_result', - title: 'Result title', - url: 'https://result.test', - encrypted_content: encryptedContent, - }, - ], - }, - { - type: 'server_tool_use', - id: 'srv_fetch', - name: 'web_fetch', - input: { url: 'https://result.test' }, - }, - { - type: 'web_fetch_tool_result', - tool_use_id: 'srv_fetch', - content: { - type: 'web_fetch_result', - url: 'https://result.test', - content: { - type: 'document', - source: { - type: 'text', - media_type: 'text/plain', - data: 'Fetched body', - }, - }, - }, - }, - { type: 'text', text: 'Initial answer.' }, - ], - }, - { role: 'user', content: 'Continue' }, - ], - }, - ); - - const messages = upstreamBody?.messages as Array>; - expect(messages.slice(1, 6)).toEqual([ - expect.objectContaining({ - role: 'assistant', - content: null, - tool_calls: [expect.objectContaining({ id: 'srv_search' })], - }), - expect.objectContaining({ - role: 'tool', - tool_call_id: 'srv_search', - content: expect.stringContaining('Search snippet'), - }), - expect.objectContaining({ - role: 'assistant', - content: null, - tool_calls: [expect.objectContaining({ id: 'srv_fetch' })], - }), - expect.objectContaining({ - role: 'tool', - tool_call_id: 'srv_fetch', - content: expect.stringContaining('Fetched body'), - }), - expect.objectContaining({ - role: 'assistant', - content: 'Initial answer.', - }), - ]); - expect(JSON.stringify(messages)).not.toContain('encrypted_content'); - expect(JSON.stringify(messages)).not.toContain('server_tool_use'); - }); - - it('replays partial Anthropic server-tool results without leaking opaque data', async () => { - let upstreamBody: Record | undefined; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { - upstreamBody = JSON.parse(String(init?.body)) as Record; - - return makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'Handled.' } }], - }); - }); - - await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 256, - messages: [ - { - role: 'assistant', - content: [ - { type: 'server_tool_use' }, - { - type: 'web_search_tool_result', - content: [ - null, - { - url: 'https://fallback.test', - snippet: 'Visible snippet', - }, - { title: 'Text title', text: 'Visible text' }, - { - encrypted_content: btoa( - JSON.stringify({ content: 'Decoded only' }), - ), - title: 'Fallback title', - url: 'https://item.test', - }, - { encrypted_content: 'not-base64' }, - ], - }, - { - type: 'server_tool_use', - id: 'fetch_partial', - name: 'web_fetch', - }, - { - type: 'web_fetch_tool_result', - tool_use_id: 'fetch_partial', - content: { url: 42, content: 'missing document' }, - }, - { type: 'text' }, - ], - }, - { role: 'user', content: 'Continue' }, - ], - }, - ); - - const serialized = JSON.stringify(upstreamBody?.messages); - expect(serialized).toContain('Visible snippet'); - expect(serialized).toContain('Visible text'); - expect(serialized).toContain('Decoded only'); - expect(serialized).toContain('"name":"unknown"'); - expect(serialized).not.toContain('encrypted_content'); - }); - - it('returns Anthropic server tool and fetch result blocks', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/webfetch')) { - return makeJsonResponse({ - content: 'Fetched article body.', - }); - } - - if (url.includes('page.test')) { - throw new Error('The endpoint result should win the local fallback'); - } - - upstreamCalls++; - return upstreamCalls === 1 - ? makeSseResponse( - { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { name: 'web_' }, - }, - ], - }, - index: 0, - }, - ], - }, - { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { - arguments: '{"url":"https://page.test/article"}', - name: 'fetch', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }, - ) - : makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { content: 'Article summary.' }, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [ - { - role: 'user', - content: 'Read https://page.test/article', - }, - ], - stream: true, - tools: [ - { type: 'web_fetch_20250910', name: 'web_fetch', input_schema: {} }, - ], - }, - ); - const text = await response.text(); - - expect(text).toContain('"type":"server_tool_use"'); - expect(text).toContain('"name":"web_fetch"'); - expect(text).toContain('"type":"web_fetch_tool_result"'); - expect(text).toContain('"type":"web_fetch_result"'); - expect(text).toContain('"url":"https://page.test/article"'); - expect(text).toContain('Fetched article body.'); - expect(text.indexOf('"type":"server_tool_use"')).toBeLessThan( - text.indexOf('Article summary.'), - ); - }); - - it('keeps ordinary Messages replies live when WebSearch is available', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - const encoder = new TextEncoder(); - let releaseFinalChunk: (() => void) | undefined; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - expect(String(input)).toContain('/v2/chat/completions'); - - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { delta: { content: 'Live first chunk.' }, index: 0 }, - ], - })}\n\n`, - ), - ); - releaseFinalChunk = () => { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { - delta: { content: ' Final chunk.' }, - finish_reason: 'stop', - index: 0, - }, - ], - })}\n\ndata: [DONE]\n\n`, - ), - ); - controller.close(); - }; - }, - }), - { headers: { 'Content-Type': 'text/event-stream' } }, - ); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Say hello' }], - stream: true, - tools: [ - { - description: 'Search the web', - input_schema: { type: 'object' }, - name: 'WebSearch', - }, - ], - }, - ); - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let firstText = ''; - - while (!firstText.includes('Live first chunk.')) { - const chunk = await reader.read(); - expect(chunk.done).toBe(false); - firstText += decoder.decode(chunk.value); - } - - expect(releaseFinalChunk).toBeTypeOf('function'); - releaseFinalChunk!(); - - let remainder = ''; - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - remainder += decoder.decode(chunk.value); - } - - expect(remainder).toContain('Final chunk.'); - }); - - it('keeps pre-tool Messages text live and executes a later CodeBuddy search', async () => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy', - CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy', - }); - const upstreamBodies: Array> = []; - let searchCalls = 0; - let upstreamCalls = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - searchCalls++; - return makeJsonResponse({ - results: [ - { - snippet: 'Current iOS news.', - title: 'Latest iOS', - url: 'https://news.test/ios', - }, - ], - }); - } - - upstreamCalls++; - upstreamBodies.push( - JSON.parse(String(init?.body)) as Record, - ); - - return upstreamCalls === 1 - ? makeSseResponse( - { - choices: [ - { - delta: { content: 'I will search now.' }, - index: 0, - }, - ], - }, - { - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"latest iOS news"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }, - ) - : makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { content: 'Here is the latest iOS news.' }, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Search for iOS news' }], - stream: true, - tools: [ - { - description: 'Search the web', - input_schema: { - type: 'object', - properties: { query: { type: 'string' } }, - required: ['query'], - }, - name: 'WebSearch', - }, - ], - }, - ); - const text = await response.text(); - const firstTools = upstreamBodies[0]?.tools as Array<{ - function?: { name?: string }; - }>; - const secondMessages = upstreamBodies[1]?.messages as Array<{ - role?: string; - tool_call_id?: string; - }>; - - expect(text).toContain('I will search now.'); - expect(text).toContain('"type":"server_tool_use"'); - expect(text).toContain('"type":"web_search_tool_result"'); - expect(text).toContain('Here is the latest iOS news.'); - expect(text).not.toContain('"type":"tool_use"'); - expect(firstTools[0]?.function?.name).toBe('web_search'); - expect(secondMessages).toContainEqual( - expect.objectContaining({ role: 'tool', tool_call_id: 'call_search' }), - ); - expect(searchCalls).toBe(1); - expect(upstreamCalls).toBe(2); - }); - - it('keeps the text a multi-hop turn wrote between two searches', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - upstreamCalls++; - - if (upstreamCalls === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_first', - index: 0, - function: { - arguments: '{"query":"first hop"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - // The model speaks and reasons before searching again. That text is part - // of the visible turn, so it must not be swallowed by the loop. - if (upstreamCalls === 2) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'First hop was inconclusive.', - reasoning_content: 'Narrowing the query.', - tool_calls: [ - { - id: 'call_second', - function: { - arguments: '{"query":"second hop"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - } - - return makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { content: 'Two hops later.' }, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Two hop question' }], - stream: true, - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - const text = await response.text(); - - expect(text).toContain('First hop was inconclusive.'); - expect(text).toContain('Narrowing the query.'); - expect(text).toContain('Two hops later.'); - expect(text.indexOf('First hop was inconclusive.')).toBeLessThan( - text.indexOf('Two hops later.'), - ); - expect((text.match(/"type":"server_tool_use"/g) ?? []).length).toBe(2); - expect((text.match(/"type":"web_search_tool_result"/g) ?? []).length).toBe( - 2, - ); - // A buffered iteration is re-emitted from the payload, so it must appear - // exactly once — not once from the payload and once from the fold. - expect((text.match(/First hop was inconclusive\./g) ?? []).length).toBe(1); - expect((text.match(/Narrowing the query\./g) ?? []).length).toBe(1); - expect(upstreamCalls).toBe(3); - }); - - it('does not repeat text a streamed iteration already forwarded', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - const encoder = new TextEncoder(); - let upstreamCalls = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - upstreamCalls++; - - if (upstreamCalls === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_first', - index: 0, - function: { - arguments: '{"query":"first hop"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - if (upstreamCalls === 2) { - // A streamed iteration: its deltas are forwarded as they arrive, so - // re-emitting the accumulated text afterwards would duplicate it. - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { delta: { content: 'Spoken between hops.' }, index: 0 }, - ], - })}\n\n`, - ), - ); - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { - delta: { reasoning_content: 'Thinking between hops.' }, - index: 0, - }, - ], - })}\n\n`, - ), - ); - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_second', - index: 0, - function: { - arguments: '{"query":"second hop"}', - name: 'web_search', - }, - }, - ], - }, - index: 0, - }, - ], - })}\n\n`, - ), - ); - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { delta: {}, finish_reason: 'tool_calls', index: 0 }, - ], - })}\n\n`, - ), - ); - controller.close(); - }, - }), - { headers: { 'Content-Type': 'text/event-stream' } }, - ); - } - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Two hops later.' } }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Two hop question' }], - stream: true, - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - const text = await response.text(); - - expect(text).toContain('Spoken between hops.'); - expect(text).toContain('Thinking between hops.'); - expect(text).toContain('Two hops later.'); - expect((text.match(/Spoken between hops\./g) ?? []).length).toBe(1); - expect((text.match(/Thinking between hops\./g) ?? []).length).toBe(1); - expect(upstreamCalls).toBe(3); - }); - - it('streams the final Messages answer as upstream produces it', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - const encoder = new TextEncoder(); - let upstreamCalls = 0; - let releaseSecondChunk: (() => void) | undefined; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - upstreamCalls++; - - if (upstreamCalls === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"streamed answer"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - // The final answer arrives in two pieces, the second only after the test - // releases it — so a buffered replay collapses both into one instant. - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [{ delta: { content: 'First half.' }, index: 0 }], - })}\n\n`, - ), - ); - releaseSecondChunk = () => { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { delta: { content: ' Second half.' }, index: 0 }, - ], - })}\n\n`, - ), - ); - controller.close(); - }; - }, - }), - { headers: { 'Content-Type': 'text/event-stream' } }, - ); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Stream the answer' }], - stream: true, - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let before = ''; - - while (!before.includes('First half.')) { - const chunk = await reader.read(); - expect(chunk.done).toBe(false); - before += decoder.decode(chunk.value); - } - - // The first half has to arrive before the second is even produced; a - // buffered final iteration would withhold it until the whole answer was in. - expect(before).not.toContain('Second half.'); - expect(releaseSecondChunk).toBeTypeOf('function'); - releaseSecondChunk!(); - - let remainder = ''; - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - remainder += decoder.decode(chunk.value); - } - - expect(remainder).toContain('Second half.'); - expect(before).toContain('"type":"web_search_tool_result"'); - }); - - it('streams Messages server_tool_use before the backend result', async () => { - let finishSearch: ((response: Response) => void) | undefined; - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return await new Promise((resolve) => { - finishSearch = resolve; - }); - } - - upstreamCalls++; - return upstreamCalls === 1 - ? makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"live messages"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { content: 'Messages answer.' }, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Search live' }], - stream: true, - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let beforeResult = ''; - - while (!beforeResult.includes('"type":"server_tool_use"')) { - const chunk = await reader.read(); - expect(chunk.done).toBe(false); - beforeResult += decoder.decode(chunk.value); - } - - expect(finishSearch).toBeTypeOf('function'); - expect(beforeResult).not.toContain('"type":"web_search_tool_result"'); - finishSearch!( - makeJsonResponse({ - results: [ - { content: 'Live result', title: 'Live', url: 'https://live.test' }, - ], - }), - ); - - let afterResult = ''; - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - afterResult += decoder.decode(chunk.value); - } - - expect(afterResult).toContain('"type":"web_search_tool_result"'); - expect(afterResult).toContain('Messages answer.'); - }); - - it('streams a Messages error when local tool upstream execution fails', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - makeJsonResponse({ error: { message: 'upstream failed' } }, 502), - ); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Search live' }], - stream: true, - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - - expect(await response.text()).toContain('"type":"error"'); - }); - - it.each([ - [ - 'an empty body', - new Response(null, { status: 502 }), - 'Upstream CodeBuddy request failed', - ], - ['a JSON string detail', makeJsonResponse({ detail: '123' }, 502), '123'], - ])( - 'maps %s from a non-streaming Messages failure', - async (_label, failure, message) => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue(failure); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Fail normally' }], - }, - ); - const payload = (await response.json()) as { - error: { message: string }; - }; - - expect(response.status).toBe(502); - expect(payload.error.message).toBe(message); - }, - ); - - it('stops the follow-up iteration when the client disconnects mid-answer', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - const encoder = new TextEncoder(); - let upstreamCalls = 0; - let resolveCancel: (() => void) | undefined; - let upstreamCancelled: Promise | undefined; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - upstreamCalls++; - - if (upstreamCalls === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"cancel mid answer"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - // The answer never finishes on its own, so only a cancellation can end - // this iteration. - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [{ delta: { content: 'Partial.' }, index: 0 }], - })}\n\n`, - ), - ); - upstreamCancelled = new Promise((resolve) => { - resolveCancel = () => resolve(true); - }); - }, - cancel: () => { - resolveCancel?.(); - }, - }), - { headers: { 'Content-Type': 'text/event-stream' } }, - ); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Cancel mid answer' }], - stream: true, - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let seen = ''; - - while (!seen.includes('Partial.')) { - const chunk = await reader.read(); - expect(chunk.done).toBe(false); - seen += decoder.decode(chunk.value); - } - - await reader.cancel(); - - // The disconnect must reach the parked upstream read, not only the - // downstream stream, so a stalled upstream does not stay alive. - await expect(upstreamCancelled).resolves.toBe(true); - expect(upstreamCalls).toBe(2); - }); - - it('cancels a late Messages upstream stream after disconnect', async () => { - let finishSearch: ((response: Response) => void) | undefined; - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return await new Promise((resolve) => { - finishSearch = resolve; - }); - } - - upstreamCalls++; - if (upstreamCalls === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"query":"cancel messages"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Late answer.' } }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Search and disconnect' }], - stream: true, - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - - await vi.waitFor(() => expect(finishSearch).toBeTypeOf('function')); - await response.body!.cancel(); - finishSearch!(makeJsonResponse({ results: [] })); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(upstreamCalls).toBe(1); - }); - - it('folds multi-hop text into a non-streaming Messages answer', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - upstreamCalls++; - - if (upstreamCalls === 1) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_first', - function: { - arguments: '{"query":"first hop"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - } - - if (upstreamCalls === 2) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'First hop was inconclusive.', - reasoning_content: 'Narrowing the query.', - tool_calls: [ - { - id: 'call_second', - function: { - arguments: '{"query":"second hop"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - } - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Two hops later.' } }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Two hop question' }], - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - const payload = (await response.json()) as { - content: Array<{ text?: string; thinking?: string; type: string }>; - }; - const text = payload.content - .filter((block) => block.type === 'text') - .map((block) => block.text ?? '') - .join(''); - const thinking = payload.content - .filter((block) => block.type === 'thinking') - .map((block) => block.thinking ?? '') - .join(''); - - // A non-streaming turn has no place to emit intermediate deltas, so the - // text is folded in ahead of the final answer rather than dropped. - expect(text).toContain('First hop was inconclusive.'); - expect(text).toContain('Two hops later.'); - expect(text.indexOf('First hop was inconclusive.')).toBeLessThan( - text.indexOf('Two hops later.'), - ); - expect(thinking).toContain('Narrowing the query.'); - expect(upstreamCalls).toBe(3); - }); - - it('does not repeat the current text in a non-streaming mixed turn', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - upstreamCalls++; - - if (upstreamCalls === 1) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_first', - function: { - arguments: '{"query":"first hop"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - } - - // A turn that carries its own text, asks for another search, and also - // calls a client-owned tool: the mixed payload already includes the - // current text, so folding it in again would duplicate it. - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Checking both.', - reasoning_content: 'Weighing the results.', - tool_calls: [ - { - id: 'call_second', - function: { - arguments: '{"query":"second hop"}', - name: 'web_search', - }, - }, - { - id: 'call_client', - function: { arguments: '{}', name: 'client_tool' }, - type: 'function', - }, - ], - }, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Mixed turn' }], - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - const payload = (await response.json()) as { - content: Array<{ text?: string; thinking?: string; type: string }>; - }; - const serialized = JSON.stringify(payload); - - expect((serialized.match(/Checking both\./g) ?? []).length).toBe(1); - expect((serialized.match(/Weighing the results\./g) ?? []).length).toBe(1); - expect(upstreamCalls).toBe(2); - }); - - it('does not fold findings into the text when a structured block carries them', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - upstreamCalls++; - // One turn mixing a local search with a client-owned call, so the loop - // cannot continue and has to hand the outstanding call back. - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: null, - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"two results"}', - name: 'web_search', - }, - }, - { - id: 'call_client', - function: { arguments: '{}', name: 'client_tool' }, - type: 'function', - }, - ], - }, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Mixed turn' }], - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - const payload = (await response.json()) as { - content: Array<{ text?: string; type: string }>; - }; - - // The result block is how this route reports the findings, so the prose - // must not repeat them: a second copy reads as the model reciting its own - // search output, and the "Cite the URL" line is an instruction to the - // model rather than something the user ever asked to see. - expect(upstreamCalls).toBeGreaterThan(0); - expect(payload.content.map((block) => block.type)).toContain( - 'web_search_tool_result', - ); - expect( - payload.content - .filter((block) => block.type === 'text') - .map((block) => block.text ?? '') - .join(''), - ).not.toContain('https://r.test'); - expect(JSON.stringify(payload)).not.toContain('Cite the URL'); - }); - - it('does not stream folded findings when a structured block carries them', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - // The very first upstream turn mixes the local search with a client - // call. That branch emits its own text delta rather than going through - // `buildMixedTurnPayload`, so it needs the same opt-out. - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"two results"}', - name: 'web_search', - }, - }, - { - id: 'call_client', - index: 1, - function: { arguments: '{}', name: 'client_tool' }, - type: 'function', - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Mixed turn' }], - stream: true, - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - const text = await response.text(); - - // Structured result block present, findings not repeated as prose. - expect(text).toContain('"type":"web_search_tool_result"'); - - // `handleMessagesRequest` answers in Anthropic SSE, so the text lives in - // `content_block_delta` frames as `text_delta` — not in `choices`. - const contentDeltas = ( - await readSseEvents( - new Response(text, { - headers: { 'Content-Type': 'text/event-stream' }, - }), - ) - ) - .flatMap((payload) => { - try { - const parsed = JSON.parse(payload) as { - delta?: { text?: string; type?: string }; - }; - - return parsed.delta?.type === 'text_delta' && parsed.delta.text - ? [parsed.delta.text] - : []; - } catch { - return []; - } - }) - .join(''); - - expect(contentDeltas).not.toContain('https://r.test'); - expect(contentDeltas).not.toContain('Cite the URL'); - }); - - it('keeps folding findings for routes without a structured channel', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - upstreamCalls++; - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: null, - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"two results"}', - name: 'web_search', - }, - }, - { - id: 'call_client', - function: { arguments: '{}', name: 'client_tool' }, - type: 'function', - }, - ], - }, - }, - ], - }); - }); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ content: 'Mixed turn', role: 'user' }], - model: 'glm-5.1', - tools: [{ type: 'web_search_preview' }], - } as never, - ); - const payload = (await response.json()) as { - choices: Array<{ message: { content: string | null } }>; - }; - - // /v1/chat/completions has no structured channel for the findings, so the - // fold has to stay: it is the only way the results reach the caller. - expect(upstreamCalls).toBeGreaterThan(0); - expect(payload.choices[0]?.message.content).toContain('https://r.test'); - }); - - it('maps a completed fetch to a Responses open_page call', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/webfetch')) { - return makeJsonResponse({ - content: 'Fetched documentation.', - }); - } - - if (url.includes('docs.test')) { - throw new Error('The endpoint result should win the local fallback'); - } - - upstreamCalls++; - return upstreamCalls === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_fetch', - function: { - arguments: '{"url":"https://docs.test/start"}', - name: 'web_fetch', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Documentation.' } }, - ], - }); - }); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Read https://docs.test/start', - tools: [{ type: 'web_fetch_20250910', name: 'web_fetch' }], - }, - ); - const payload = (await response.json()) as { - output: Array>; - }; - - expect(payload.output[0]).toMatchObject({ - type: 'web_search_call', - status: 'completed', - action: { type: 'open_page', url: 'https://docs.test/start' }, - }); - expect(payload.output[1]).toMatchObject({ type: 'message' }); - }); - - it('includes completed search blocks in a non-streaming Messages response', async () => { - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return makeJsonResponse({ - results: [ - { - content: 'Search snippet', - }, - ], - }); - } - - upstreamCalls++; - return upstreamCalls === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"current status"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { content: 'Current answer.' }, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'What is current?' }], - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - const payload = (await response.json()) as { - content: Array>; - usage: Record; - }; - - expect(payload.content[0]).toMatchObject({ - type: 'server_tool_use', - name: 'web_search', - input: { query: 'current status' }, - }); - expect(payload.content[1]).toMatchObject({ - type: 'web_search_tool_result', - content: [ - { - type: 'web_search_result', - title: '', - url: '', - }, - ], - }); - expect(payload.content[2]).toMatchObject({ - type: 'text', - text: 'Current answer.', - }); - expect(payload.usage.server_tool_use).toEqual({ - web_search_requests: 1, - web_fetch_requests: 0, - }); - }); -}); - -describe('proxy integration', () => { - const tempRootDir = path.join( - process.cwd(), - '.tmp-test-websearch-proxy-root', - ); - const tempDataDir = path.join(tempRootDir, '.codebuddy_data'); - - const makeProxyRequest = () => - new NextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - headers: { authorization: 'Bearer test-token' }, - }); - - beforeEach(async () => { - for (const name of SEARXNG_ENV_NAMES) { - delete process.env[name]; - } - resetWebSearchProviders(); - resetCredentialRuntimeState(); - cleanupDir(); - fs.mkdirSync(tempDataDir, { recursive: true }); - vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); - vi.spyOn(console, 'error').mockImplementation(() => undefined); - process.env.CODEBUDDY_AUTH_MODE = 'auto'; - await addCredential({ - bearer_token: 'websearch-proxy-token', - responses_passthrough: false, - user_id: 'websearch@example.com', - }); - }); - - afterEach(() => { - for (const name of SEARXNG_ENV_NAMES) { - delete process.env[name]; - } - resetWebSearchProviders(); - cleanupDir(); - vi.restoreAllMocks(); - }); - - const cleanupDir = (): void => { - fs.rmSync(tempRootDir, { force: true, recursive: true, maxRetries: 5 }); - }; - - it('runs a local search end to end for a non-streaming request', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - - const fetchMock = vi.spyOn(globalThis, 'fetch'); - let upstreamCalls = 0; - fetchMock.mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return makeJsonResponse({ - results: [ - { content: 'A snippet', title: 'Docs', url: 'https://docs.test' }, - ], - }); - } - - upstreamCalls += 1; - - return upstreamCalls === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: null, - role: 'assistant', - tool_calls: [ - { - id: 'call_1', - type: 'function', - function: { - arguments: '{"query":"latest release"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - usage: { total_tokens: 30 }, - }) - : makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { content: 'It shipped yesterday.' }, - }, - ], - usage: { total_tokens: 40 }, - }); - }); - - const response = await proxyChatCompletions(makeProxyRequest(), { - messages: [{ role: 'user', content: 'when did it ship?' }], - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }); - - expect(response.ok).toBe(true); - const payload = (await response.json()) as { - choices: Array<{ message: { content: string | null } }>; - usage?: { total_tokens?: number }; - }; - expect(payload.choices[0]?.message.content).toBe('It shipped yesterday.'); - // Usage from both iterations is summed. - expect(payload.usage?.total_tokens).toBe(70); - expect(upstreamCalls).toBe(2); - }); - - it('serves a synthesized stream when a search request asks to stream', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - - const fetchMock = vi.spyOn(globalThis, 'fetch'); - let upstreamCalls = 0; - fetchMock.mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return makeJsonResponse({ results: [] }); - } - - upstreamCalls += 1; - - return upstreamCalls === 1 - ? makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_1', - index: 0, - function: { - arguments: '{"query":"weather"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Sunny today.' } }, - ], - }); - }); - - const response = await proxyChatCompletions(makeProxyRequest(), { - messages: [{ role: 'user', content: 'weather?' }], - stream: true, - tools: [{ type: 'web_search_preview' }], - }); - - expect(response.headers.get('content-type')).toContain('text/event-stream'); - const text = await response.text(); - expect(text).toContain('Sunny today.'); - expect(text).toContain('data: [DONE]'); - }); - - it('interleaves thinking and text around each fetch in a non-streaming reply', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/webfetch')) { - // Backends report the URL they actually read, which follows redirects - // and so can differ from the one the model asked for. - return makeJsonResponse({ - content: 'Fetched body.', - url: 'https://page.test/a?redirected=1', - }); - } - - upstreamCalls += 1; - - // The model thinks, speaks, then fetches — twice over. Anthropic lays a - // turn out as thinking → text → tool_use → tool_result → thinking → - // text, so each hop's reasoning stays attached to the text it justifies. - if (upstreamCalls === 1) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Looking it up.', - reasoning_content: 'I should check the page.', - role: 'assistant', - tool_calls: [ - { - id: 'call_first', - function: { - arguments: '{"url":"https://page.test/a"}', - name: 'web_fetch', - }, - }, - ], - }, - }, - ], - }); - } - - return makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { - content: 'Here is what it said.', - reasoning_content: 'The page confirms it.', - }, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - new NextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Read https://page.test/a' }], - tools: [ - { type: 'web_fetch_20260209', name: 'web_fetch', input_schema: {} }, - ], - }, - ); - - const payload = (await response.json()) as { - content: Array<{ - thinking?: string; - text?: string; - type: string; - content?: { url?: string }; - }>; - }; - const blocks = payload.content.map((block) => - block.type === 'thinking' - ? `thinking:${block.thinking}` - : block.type === 'text' - ? `text:${block.text}` - : block.type, - ); - - // Each hop keeps its own reasoning ahead of its own text, and the fetch - // sits between the two hops rather than ahead of both. - expect(blocks).toEqual([ - 'thinking:I should check the page.', - 'text:Looking it up.', - 'server_tool_use', - 'web_fetch_tool_result', - 'thinking:The page confirms it.', - 'text:Here is what it said.', - ]); - // The result carries the URL the backend read, not the one requested. - expect(payload.content[3]?.content?.url).toBe( - 'https://page.test/a?redirected=1', - ); - expect(upstreamCalls).toBe(2); - }); - - it('keeps the tool blocks first when a hop calls a tool without speaking', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/webfetch')) { - return makeJsonResponse({ content: 'Fetched body.' }); - } - - upstreamCalls += 1; - - return upstreamCalls === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - role: 'assistant', - tool_calls: [ - { - id: 'call_fetch', - function: { - arguments: '{"url":"https://page.test/a"}', - name: 'web_fetch', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'Done.' } }], - }); - }); - - const response = await handleMessagesRequest( - new NextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Read https://page.test/a' }], - tools: [ - { type: 'web_fetch_20260209', name: 'web_fetch', input_schema: {} }, - ], - }, - ); - - const payload = (await response.json()) as { - content: Array<{ text?: string; type: string }>; - }; - - // The model went straight to the tool, so there is no prose to put first: - // the fetch opens the turn and the answer closes it. - expect( - payload.content.map((block) => - block.type === 'text' ? `text:${block.text}` : block.type, - ), - ).toEqual(['server_tool_use', 'web_fetch_tool_result', 'text:Done.']); - }); - - it('keeps earlier hops grouped when a later hop mixes in a client tool', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/webfetch')) { - return makeJsonResponse({ content: 'Fetched body.' }); - } - - upstreamCalls += 1; - - // The first hop searches on its own; the second runs a server tool and - // also asks the client for one of its own. The loop has to hand the - // client's call back, and the hop metadata still has to reach the - // block renderer — losing it flattens every hop's prose ahead of the - // tool blocks, which is the bug this guards. - return upstreamCalls === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Checking first.', - role: 'assistant', - tool_calls: [ - { - id: 'call_first', - function: { - arguments: '{"url":"https://page.test/a"}', - name: 'web_fetch', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Now yours.', - role: 'assistant', - tool_calls: [ - { - id: 'call_second', - function: { - arguments: '{"url":"https://page.test/b"}', - name: 'web_fetch', - }, - }, - { - id: 'call_client', - function: { - arguments: '{"city":"Berlin"}', - name: 'weather', - }, - }, - ], - }, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - new NextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Read both' }], - tools: [ - { type: 'web_fetch_20260209', name: 'web_fetch', input_schema: {} }, - { name: 'weather', input_schema: {}, type: 'custom' }, - ], - }, - ); - - const payload = (await response.json()) as { - content: Array<{ text?: string; type: string }>; - }; - - // The first hop stays ahead of the second hop's fetch instead of both - // fetches collapsing to the end, and the client's own call survives as a - // tool_use the client has to resolve. - expect( - payload.content.map((block) => - block.type === 'text' ? `text:${block.text}` : block.type, - ), - ).toEqual([ - 'text:Checking first.', - 'server_tool_use', - 'web_fetch_tool_result', - 'text:Now yours.', - 'server_tool_use', - 'web_fetch_tool_result', - 'tool_use', - ]); - }); - - it('keeps hop metadata off the OpenAI chat-completions response', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/webfetch')) { - return makeJsonResponse({ content: 'Fetched body.' }); - } - - upstreamCalls += 1; - - return upstreamCalls === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Looking it up.', - role: 'assistant', - tool_calls: [ - { - id: 'call_fetch', - function: { - arguments: '{"url":"https://page.test/a"}', - name: 'web_fetch', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'Done.' } }], - }); - }); - - const response = await proxyChatCompletions(makeProxyRequest(), { - messages: [{ role: 'user', content: 'Read https://page.test/a' }], - tools: [ - { name: 'web_fetch', type: 'function' }, - { type: 'web_fetch_20260209', name: 'web_fetch' }, - ], - }); - - const payload = (await response.json()) as Record; - - // The per-hop grouping is not part of the OpenAI protocol. Serializing it - // here would hand chat clients a field that names internal tool inputs and - // results, which strict validators reject and every other client receives - // as duplicated tool data. - expect(Object.keys(payload)).not.toContain('turns'); - expect(JSON.stringify(payload)).not.toContain('web_fetch_tool_result'); - // Guards against the assertion passing because the loop never ran: a - // two-hop turn is what would have carried the grouping in the first place. - expect(upstreamCalls).toBe(2); - }); - - it('keeps the prose of a first hop that mixes in a client tool', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/webfetch')) { - return makeJsonResponse({ content: 'Fetched body.' }); - } - - // The very first response already carries both a locally executed tool - // and a client-owned one. There is no earlier hop to fold, so the hop - // metadata has to be built from this iteration alone. - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Let me check, then you decide.', - reasoning_content: 'I need the page first.', - role: 'assistant', - tool_calls: [ - { - id: 'call_fetch', - function: { - arguments: '{"url":"https://page.test/a"}', - name: 'web_fetch', - }, - }, - { - id: 'call_client', - function: { - arguments: '{"city":"Berlin"}', - name: 'weather', - }, - }, - ], - }, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - new NextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Read it' }], - tools: [ - { type: 'web_fetch_20260209', name: 'web_fetch', input_schema: {} }, - { name: 'weather', input_schema: {}, type: 'custom' }, - ], - }, - ); - - const payload = (await response.json()) as { - content: Array<{ text?: string; thinking?: string; type: string }>; - }; - - // A block renderer renders purely from the hop metadata once it is - // non-empty, so this hop's prose has to be on the turn: leaving it off - // drops everything the model said here, not just reorders it. - expect( - payload.content.map((block) => - block.type === 'thinking' - ? `thinking:${block.thinking}` - : block.type === 'text' - ? `text:${block.text}` - : block.type, - ), - ).toEqual([ - 'thinking:I need the page first.', - 'text:Let me check, then you decide.', - 'server_tool_use', - 'web_fetch_tool_result', - 'tool_use', - ]); - }); - - it('passes through untouched when no search tool is declared', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - - const fetchMock = vi.spyOn(globalThis, 'fetch'); - fetchMock.mockImplementation(async () => - makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'plain' } }], - }), - ); - - const response = await proxyChatCompletions(makeProxyRequest(), { - messages: [{ role: 'user', content: 'hello' }], - }); - - expect(response.ok).toBe(true); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it('returns the upstream failure when the search request errors', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - - const fetchMock = vi.spyOn(globalThis, 'fetch'); - fetchMock.mockImplementation(async () => - makeJsonResponse({ error: { message: 'upstream down' } }, 502), - ); - - const response = await proxyChatCompletions(makeProxyRequest(), { - messages: [{ role: 'user', content: 'hello' }], - tools: [{ type: 'web_search_preview' }], - }); - - expect(response.status).toBe(502); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 6d56f34..e14932f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -33,6 +33,10 @@ const vitestConfig = defineConfig({ 'dist/**', 'e2e/**', 'node_modules/**', + // A git worktree is a full checkout, so it carries its own copy of the + // suite — and, once built, a second copy under `.next/standalone`. + // Collecting those runs stale tests against the current tree. + '.worktrees/**', ], coverage: { provider: 'v8', From aa808206e449969e8fd2942705ef5e99ce1b99e6 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Fri, 18 Sep 2026 00:55:44 +0800 Subject: [PATCH 2/9] test(server-tools): cover the plumbing the rewrite introduced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raises changed-branch coverage to 91.75%, past the 90% floor. `rewriteServerTools` can only be reached once declarations have been found, which already guarantees a non-empty array, so its nullable return was unreachable — it now takes `unknown[]` and the caller drops the guard. The unused `readJsonResponse` helper goes too. --- lib/server/proxy/server-tools/classify.ts | 8 +- lib/server/proxy/server-tools/turn.ts | 44 +-- tests/server/server-tools.test.ts | 426 ++++++++++++++++++++-- 3 files changed, 419 insertions(+), 59 deletions(-) diff --git a/lib/server/proxy/server-tools/classify.ts b/lib/server/proxy/server-tools/classify.ts index 76ef0f8..9a1d809 100644 --- a/lib/server/proxy/server-tools/classify.ts +++ b/lib/server/proxy/server-tools/classify.ts @@ -186,12 +186,8 @@ export const rewriteServerTools = ({ declarations: ServerToolDeclarations; fetchProvider: unknown; searchProvider: unknown; - tools: unknown; -}): RewrittenServerTools | null => { - if (!Array.isArray(tools)) { - return null; - } - + tools: unknown[]; +}): RewrittenServerTools => { // Ambiguity is resolved in the client's favour; see // {@link hasAmbiguousServerToolName}. const ambiguous = hasAmbiguousServerToolName(tools); diff --git a/lib/server/proxy/server-tools/turn.ts b/lib/server/proxy/server-tools/turn.ts index a580eeb..dd23e26 100644 --- a/lib/server/proxy/server-tools/turn.ts +++ b/lib/server/proxy/server-tools/turn.ts @@ -6,7 +6,7 @@ import { } from '../../domain/config'; import { resolveFetchProvider, resolveSearchProvider } from '../../search'; import type { WebFetchProvider, WebSearchProvider } from '../../search/types'; -import { asRecord, readReasoning } from '../../shared/content'; +import { readReasoning } from '../../shared/content'; import type { ChatRequestBody } from '../codebuddy'; import { buildServerToolInvocation, @@ -15,6 +15,7 @@ import { import { findServerToolDeclarations, getForcedToolName, + type RewrittenServerTools, rewriteServerTools, } from './classify'; import { readBufferedChatCompletionPayload } from './payload'; @@ -99,7 +100,7 @@ export const prepareServerToolTurn = async ( fetchProvider: WebFetchProvider | null; searchProvider: WebSearchProvider | null; }; - rewrite: NonNullable>; + rewrite: RewrittenServerTools; } | null> => { const declarations = findServerToolDeclarations(tools); @@ -107,19 +108,21 @@ export const prepareServerToolTurn = async ( return null; } - const { fetchProvider, searchProvider } = await resolveServerToolBackends(); - const rewrite = rewriteServerTools({ - declarations, - fetchProvider, - searchProvider, - tools, - }); + // `declarations` is only non-null when `tools` is a non-empty array, so the + // rewrite cannot decline. + const toolList = tools as unknown[]; - if (!rewrite) { - return null; - } + const { fetchProvider, searchProvider } = await resolveServerToolBackends(); - return { providers: { fetchProvider, searchProvider }, rewrite }; + return { + providers: { fetchProvider, searchProvider }, + rewrite: rewriteServerTools({ + declarations, + fetchProvider, + searchProvider, + tools: toolList, + }), + }; }; /** @@ -360,18 +363,3 @@ export const foldIntermediateTexts = ( ], }; }; - -/** Reads the payload of a buffered upstream response. */ -export const readJsonResponse = async ( - response: Response, -): Promise> => { - const text = await response.text(); - - try { - return asRecord(JSON.parse(text)) ?? {}; - } catch { - return {}; - } -}; - -export type { ChatCompletionMessage }; diff --git a/tests/server/server-tools.test.ts b/tests/server/server-tools.test.ts index 4d12c8d..a839d85 100644 --- a/tests/server/server-tools.test.ts +++ b/tests/server/server-tools.test.ts @@ -1,14 +1,26 @@ import { + buildServerToolInvocation, classifyServerToolDeclaration, + executeServerToolInvocations, findServerToolDeclarations, foldIntermediateTexts, getForcedToolName, + attachServerToolExecutions, + getServerToolExecutions, + prepareServerToolTurn, hasAmbiguousServerToolName, hasExecutableServerTool, + parseBufferedPayload, + readBufferedChatCompletionPayload, + resolveServerToolBackends, rewriteServerTools, runServerToolTurn, type ServerToolInvocation, } from '@/lib/server/proxy/server-tools'; +import { isEventStream } from '@/lib/server/shared/sse'; +import { updateSettings } from '@/lib/server/domain/config'; +import { resetWebSearchProviders } from '@/lib/server/search'; +import type { ChatRequestBody } from '@/lib/server/proxy/codebuddy'; import type { WebFetchProvider, WebSearchProvider, @@ -214,17 +226,6 @@ describe('server tool classification', () => { }); describe('rewriteServerTools', () => { - it('returns null when there is no tool list', () => { - expect( - rewriteServerTools({ - declarations: { fetch: false, search: true }, - fetchProvider: null, - searchProvider: makeSearchProvider(), - tools: undefined, - }), - ).toBeNull(); - }); - it('swaps a runnable search declaration for a function upstream can call', () => { const rewrite = rewriteServerTools({ declarations: { fetch: false, search: true }, @@ -313,6 +314,15 @@ describe('server tool classification', () => { }); }); + describe('prepareServerToolTurn', () => { + it('declines when no provider-executed tool is declared', async () => { + await expect( + prepareServerToolTurn([claudeCodeWebSearch]), + ).resolves.toBeNull(); + await expect(prepareServerToolTurn(undefined)).resolves.toBeNull(); + }); + }); + describe('getForcedToolName', () => { it('reads the name from either protocol shape', () => { expect(getForcedToolName({ type: 'tool', name: 'web_search' })).toBe( @@ -333,21 +343,21 @@ describe('server tool classification', () => { }); }); -describe('server tool turn', () => { - const body = { - messages: [{ role: 'user', content: 'when did it ship?' }], - model: 'test-model', - stream: false, - }; - - const makeRewrite = (searchProvider: WebSearchProvider | null) => - rewriteServerTools({ - declarations: { fetch: false, search: true }, - fetchProvider: null, - searchProvider, - tools: [{ type: SEARCH_TYPE, name: 'web_search' }], - })!; +const body = { + messages: [{ role: 'user', content: 'when did it ship?' }], + model: 'test-model', + stream: false, +}; +const makeRewrite = (searchProvider: WebSearchProvider | null) => + rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider, + tools: [{ type: SEARCH_TYPE, name: 'web_search' }], + })!; + +describe('server tool turn', () => { it('asks upstream once when the model does not call a server tool', async () => { const callUpstream = vi.fn(async () => makeJsonResponse({ @@ -600,3 +610,369 @@ describe('foldIntermediateTexts', () => { expect(foldIntermediateTexts({}, ['text'])).toEqual({}); }); }); + +describe('server tool plumbing', () => { + describe('buildServerToolInvocation', () => { + it('reads a fetch call and keeps the tool call id', () => { + expect( + buildServerToolInvocation( + { + id: 'call_fetch', + function: { + arguments: '{"url":"https://a.test","prompt":"the price"}', + name: 'web_fetch', + }, + }, + 0, + ), + ).toEqual({ + id: 'call_fetch', + input: { prompt: 'the price', url: 'https://a.test' }, + type: 'web_fetch', + }); + }); + + it('falls back to a positional id when the model sends none', () => { + expect( + buildServerToolInvocation({ function: { name: 'web_search' } }, 3), + ).toEqual({ + id: 'server_tool_3', + input: { query: '' }, + type: 'web_search', + }); + }); + }); + + describe('executeServerToolInvocations', () => { + it('runs a fetch through the fetch provider', async () => { + const results = await executeServerToolInvocations({ + fetchProvider: makeFetchProvider(), + invocations: [ + { + id: 'call_fetch', + input: { url: 'https://a.test' }, + type: 'web_fetch', + }, + ], + searchProvider: null, + }); + + expect(results).toEqual([ + { + content: 'Fetched https://a.test', + execution: { + id: 'call_fetch', + input: { url: 'https://a.test' }, + result: { + content: 'Fetched https://a.test', + url: 'https://a.test', + }, + type: 'web_fetch', + }, + tool_call_id: 'call_fetch', + }, + ]); + }); + + it('reports an unconfigured fetch backend as text rather than failing', async () => { + const results = await executeServerToolInvocations({ + fetchProvider: null, + invocations: [ + { + id: 'call_fetch', + input: { url: 'https://a.test' }, + type: 'web_fetch', + }, + ], + searchProvider: null, + }); + + expect(results[0]?.content).toContain('no web fetch backend'); + }); + }); + + describe('parseBufferedPayload', () => { + it('returns the parsed payload', () => { + expect(parseBufferedPayload('{"choices":[]}', true)).toEqual({ + choices: [], + }); + }); + + it('rethrows a malformed body the upstream called successful', () => { + expect(() => parseBufferedPayload('not json', true)).toThrow(); + }); + + it('tolerates a malformed body on a failure status', () => { + expect(parseBufferedPayload('not json', false)).toEqual({}); + }); + }); + + describe('readBufferedChatCompletionPayload', () => { + it('falls back to the raw body when a failure carries no message', () => { + // A payload with only a code has nothing to extract, and the JSON is + // still the only record of what happened, so it beats the generic + // "Upstream request failed" — which says only that something failed. + const response = new Response('{"error":{"code":6004}}', { + headers: { 'Content-Type': 'application/json' }, + status: 429, + }); + + return expect( + readBufferedChatCompletionPayload(response), + ).resolves.toEqual({ + error: { message: '{"error":{"code":6004}}', status: 429 }, + }); + }); + + it('uses the generic message when the failure body is empty', () => { + const response = new Response('', { + headers: { 'Content-Type': 'application/json' }, + status: 429, + }); + + return expect( + readBufferedChatCompletionPayload(response), + ).resolves.toEqual({ + error: { + message: 'Upstream request failed with status 429', + status: 429, + }, + }); + }); + + it('prefers the nested upstream message over the generic one', () => { + const response = new Response('{"error":{"message":"quota exceeded"}}', { + headers: { 'Content-Type': 'application/json' }, + status: 429, + }); + + return expect( + readBufferedChatCompletionPayload(response), + ).resolves.toEqual({ + error: { message: 'quota exceeded', status: 429 }, + }); + }); + + it('reports an error payload on a successful status', () => { + const response = new Response('{"error":{"message":"nope"}}', { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }); + + return expect( + readBufferedChatCompletionPayload(response), + ).resolves.toEqual({ error: { message: 'nope' } }); + }); + }); + + describe('getServerToolExecutions', () => { + it('reports none for a response that ran no tools', () => { + expect(getServerToolExecutions(new Response('{}'))).toEqual([]); + }); + }); + + describe('isEventStream', () => { + it('tells an SSE response from a buffered one', () => { + expect( + isEventStream( + new Response('{}', { + headers: { 'Content-Type': 'text/event-stream' }, + }), + ), + ).toBe(true); + expect( + isEventStream( + new Response('{}', { + headers: { 'Content-Type': 'application/json' }, + }), + ), + ).toBe(false); + // No content-type at all: upstream omitted it on a failure. + expect(isEventStream(new Response('{}'))).toBe(false); + }); + }); + + describe('resolveServerToolBackends', () => { + afterEach(() => { + vi.restoreAllMocks(); + resetWebSearchProviders(); + }); + + it('resolves nothing when both backends are passthrough', async () => { + await updateSettings({ + CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', + CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough', + }); + + await expect(resolveServerToolBackends()).resolves.toEqual({ + fetchProvider: null, + searchProvider: null, + }); + }); + + it('resolves the configured backends', async () => { + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ + CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy2api', + CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', + }); + + const { fetchProvider, searchProvider } = + await resolveServerToolBackends(); + delete process.env.SEARXNG_URL; + + expect(fetchProvider?.id).toBe('local'); + expect(searchProvider?.id).toBe('searxng'); + }); + }); + + describe('relaxToolChoice', () => { + const runWith = async (toolChoice: unknown) => { + const sentBodies: ChatRequestBody[] = []; + let calls = 0; + + await runServerToolTurn({ + body: { + ...body, + tool_choice: toolChoice, + } as never, + callUpstream: async (nextBody) => { + calls += 1; + sentBodies.push(nextBody); + + return calls === 1 + ? makeJsonResponse( + assistantToolCall('web_search', '{"query":"ship"}'), + ) + : makeJsonResponse({ choices: [] }); + }, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + stream: false, + }); + + return sentBodies[1]?.tool_choice; + }; + + it('leaves an unrelated tool_choice alone', async () => { + await expect(runWith('auto')).resolves.toBe('auto'); + }); + + it('leaves a forced client tool alone', async () => { + await expect( + runWith({ type: 'function', function: { name: 'Read' } }), + ).resolves.toEqual({ type: 'function', function: { name: 'Read' } }); + }); + + it('carries no tool_choice through when none was set', async () => { + await expect(runWith(undefined)).resolves.toBeUndefined(); + }); + }); + + describe('foldIntermediateTexts', () => { + it('ignores extra text when the payload has no choices', () => { + expect(foldIntermediateTexts({ choices: [] }, ['orphan'])).toEqual({ + choices: [], + }); + }); + }); +}); + +describe('server tool edge cases', () => { + it('builds a search invocation from a call with no name at all', () => { + expect(buildServerToolInvocation({}, 2)).toEqual({ + id: 'server_tool_2', + input: { query: '' }, + type: 'web_search', + }); + }); + + it('reports no executions when a turn ran but upstream sent no message', async () => { + let calls = 0; + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => { + calls += 1; + + return calls === 1 + ? makeJsonResponse({ choices: [{ finish_reason: 'stop' }] }) + : makeJsonResponse({ choices: [] }); + }, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + stream: false, + }); + + expect(outcome.executions).toEqual([]); + expect(outcome.preamble).toEqual({ reasoning: '', text: '' }); + }); + + it('runs a turn whose body carries no messages', async () => { + let calls = 0; + const sentBodies: ChatRequestBody[] = []; + const outcome = await runServerToolTurn({ + body: { model: 'test-model', stream: false } as ChatRequestBody, + callUpstream: async (nextBody) => { + calls += 1; + sentBodies.push(nextBody); + + return calls === 1 + ? makeJsonResponse( + assistantToolCall('web_search', '{"query":"ship"}'), + ) + : makeJsonResponse({ choices: [] }); + }, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + stream: false, + }); + + expect(outcome.executions).toHaveLength(1); + // Only the assistant message and the tool result were appended. + expect((sentBodies[1] as { messages: unknown[] }).messages).toHaveLength(2); + }); + + it('leaves a tool_choice with no name to the follow-up', async () => { + let calls = 0; + const sentBodies: ChatRequestBody[] = []; + + await runServerToolTurn({ + body: { ...body, tool_choice: { type: 'auto' } } as never, + callUpstream: async (nextBody) => { + calls += 1; + sentBodies.push(nextBody); + + return calls === 1 + ? makeJsonResponse( + assistantToolCall('web_search', '{"query":"ship"}'), + ) + : makeJsonResponse({ choices: [] }); + }, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + stream: false, + }); + + expect(sentBodies[1].tool_choice).toEqual({ type: 'auto' }); + }); + + it('folds extra text into a choice that carries no message', () => { + const folded = foldIntermediateTexts({ choices: [{ index: 0 }] }, [ + 'earlier', + ]); + + expect(folded.choices?.[0]?.message?.content).toBe('earlier'); + }); + + it('records nothing when a turn attaches no executions', () => { + const response = new Response('{}'); + + expect(attachServerToolExecutions(response, [])).toBe(response); + expect(getServerToolExecutions(response)).toEqual([]); + }); +}); From 45c428c31e29aef5e7bccc45c71e331f42dd937e Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Fri, 18 Sep 2026 04:34:48 +0800 Subject: [PATCH 3/9] fix(server-tools): run the server-tool loop the spec describes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server tools are now answered by a genuine loop, bounded by the `max_uses` the client declared, instead of a fixed two-call turn: while model requests web_search: execute search append tool result call chat again The loop is over *server* tools only. Claude Code's own `WebSearch` is an ordinary client function and a call to it is never picked up — that is the distinction the whole feature turns on, and the reason classification keys off the declared type rather than the name. - Translation carries the whole declaration through, so `max_uses` reaches the loop (it was dropped before, leaving the budget pinned at 5). - The budget is per tool kind: a fetch's allowance no longer caps searches. - Calls are clamped to the budget before running, and the transcript only carries calls that were actually executed, so no assistant message ever promises a result it does not have. - A server tool nothing here can run is withdrawn rather than offered; offering it handed the client a `tool_use` it had no handler for. - Every exit reports the searches already run and the usage accrued, including when a later hop fails. - On Responses the preamble lands ahead of the searches, and the stream carries one id, with the answer — not the preamble — as text deltas. --- lib/server/proxy/anthropic.ts | 16 +- lib/server/proxy/anthropic/request.ts | 11 +- lib/server/proxy/responses.ts | 82 ++- lib/server/proxy/responses/event-stream.ts | 247 ++++--- lib/server/proxy/responses/payload.ts | 74 +- lib/server/proxy/responses/stream.ts | 1 + lib/server/proxy/responses/tools.ts | 17 +- lib/server/proxy/responses/types.ts | 8 + lib/server/proxy/server-tools/classify.ts | 176 ++++- lib/server/proxy/server-tools/execute.ts | 12 +- lib/server/proxy/server-tools/index.ts | 1 + lib/server/proxy/server-tools/turn.ts | 527 +++++++++++--- lib/server/proxy/server-tools/types.ts | 71 +- tests/server/image-generation.test.ts | 69 ++ tests/server/server-tools.test.ts | 593 ++++++++++++++-- tests/server/web-search.test.ts | 762 ++++++++++++++++++++- 16 files changed, 2292 insertions(+), 375 deletions(-) diff --git a/lib/server/proxy/anthropic.ts b/lib/server/proxy/anthropic.ts index c7dc180..90a4b81 100644 --- a/lib/server/proxy/anthropic.ts +++ b/lib/server/proxy/anthropic.ts @@ -62,12 +62,19 @@ export const handleMessagesRequest = async ( ? rewrite.tools : ((chatBody.tools as unknown[] | undefined) ?? undefined); + /** + * One round trip upstream. + * + * The tools come from `turnBody`, never pinned back on here: the turn + * decides what to offer on each hop, and overriding it would undo the + * withdrawal it does once the search budget is spent. + */ const callUpstream = (context?: ProxyContext) => (turnBody: ChatRequestBody, stream: boolean): Promise => proxyChatCompletions( request, - { ...turnBody, tools: upstreamTools, stream }, + { ...turnBody, stream }, context, debugTrace, '/v1/messages', @@ -94,7 +101,6 @@ export const handleMessagesRequest = async ( fetchProvider, rewrite, searchProvider, - stream: Boolean(body.stream), }), ); @@ -119,7 +125,11 @@ export const handleMessagesRequest = async ( } const upstreamResponse = await callUpstream()( - chatBody as ChatRequestBody, + // `upstreamTools` matters here even though no turn runs: when a server + // tool is declared but nothing on this deployment can execute it, the + // declaration still has to be rewritten, or upstream is sent a + // `web_search_20250305` type it has never heard of. + { ...chatBody, tools: upstreamTools } as ChatRequestBody, Boolean(body.stream), ); diff --git a/lib/server/proxy/anthropic/request.ts b/lib/server/proxy/anthropic/request.ts index b38b4ae..510cbf1 100644 --- a/lib/server/proxy/anthropic/request.ts +++ b/lib/server/proxy/anthropic/request.ts @@ -352,8 +352,17 @@ export const mapAnthropicToolsToChat = ( normalizeToolName(type).startsWith(normalizeToolName(prefix)), ); + if (serverDeclared) { + // Everything the client declared travels with it — `max_uses`, + // `allowed_domains`, `user_location`. Only the *shape* changes: upstream + // is a Chat API, so the declaration has to look like a function, while + // the declared type is kept on `type` so the proxy can still recognise + // it as a server tool downstream. + return { ...tool, type, function: { name: tool.name } }; + } + return { - type: serverDeclared ? type : 'function', + type: 'function', function: { name: tool.name, description: tool.description, diff --git a/lib/server/proxy/responses.ts b/lib/server/proxy/responses.ts index 032333f..dd7ee0f 100644 --- a/lib/server/proxy/responses.ts +++ b/lib/server/proxy/responses.ts @@ -47,6 +47,7 @@ import { hasExecutableServerTool, prepareServerToolTurn, runServerToolTurn, + type ServerToolPreamble, } from './server-tools'; export const handleResponsesRequest = async ( @@ -213,39 +214,55 @@ export const handleResponsesRequest = async ( * executable; otherwise the request goes upstream as it stands, with every * tool call coming back to the client. */ + // What the model wrote before its first search. The image loop drives + // upstream through `callUpstream`, so the preamble has to be captured + // here rather than at a single call site. + let turnPreamble: ServerToolPreamble | undefined; + const callUpstream = async ( loopBody: Record, stream: boolean, - ): Promise => - willRunServerTool && rewrite - ? ( - await withCodeBuddyToken( - () => Promise.resolve(proxyContext.auth.bearerToken), - () => - runServerToolTurn({ - body: loopBody as never, - callUpstream: (turnBody, turnStream) => - proxyChatCompletions( - request, - { ...turnBody, stream: turnStream } as never, - proxyContext, - debugTrace, - '/v1/responses', - ), - fetchProvider: serverTools!.providers.fetchProvider, - rewrite, - searchProvider: serverTools!.providers.searchProvider, - stream, - }), - ) - ).response - : proxyChatCompletions( - request, - { ...loopBody, stream } as never, - proxyContext, - debugTrace, - '/v1/responses', - ); + ): Promise => { + if (!willRunServerTool || !rewrite) { + return proxyChatCompletions( + request, + { ...loopBody, stream } as never, + proxyContext, + debugTrace, + '/v1/responses', + ); + } + + const outcome = await withCodeBuddyToken( + () => Promise.resolve(proxyContext.auth.bearerToken), + () => + runServerToolTurn({ + body: loopBody as never, + callUpstream: (turnBody, turnStream) => + proxyChatCompletions( + request, + { ...turnBody, stream: turnStream } as never, + proxyContext, + debugTrace, + '/v1/responses', + ), + fetchProvider: serverTools!.providers.fetchProvider, + rewrite, + searchProvider: serverTools!.providers.searchProvider, + }), + ); + + // First non-empty wins. The image loop calls this repeatedly, and a + // later iteration that ran no server tool returns an empty preamble — + // which would erase the prose an earlier one captured. + const spoken = outcome.preamble.text || outcome.preamble.reasoning; + + if (spoken && !turnPreamble) { + turnPreamble = outcome.preamble; + } + + return outcome.response; + }; // Image generation has no chat-protocol equivalent, so the model's call is // executed here and replayed with the image folded in. Only meaningful when @@ -289,6 +306,8 @@ export const handleResponsesRequest = async ( // nothing is keyed under this response object any more. serverToolExecutions, executions, + undefined, + turnPreamble, ), ); } @@ -319,6 +338,9 @@ export const handleResponsesRequest = async ( prepared.previousResponseId, upstreamPayload, serverToolExecutions, + [], + undefined, + turnPreamble, ), ); } catch (error) { diff --git a/lib/server/proxy/responses/event-stream.ts b/lib/server/proxy/responses/event-stream.ts index cc6b11f..a32d446 100644 --- a/lib/server/proxy/responses/event-stream.ts +++ b/lib/server/proxy/responses/event-stream.ts @@ -10,7 +10,11 @@ import type { NextRequest } from 'next/server'; import type { DebugTrace } from '../../domain/debug'; import { withCodeBuddyToken } from '../../search/token'; -import { createSseResponse, encodeDoneFrame } from '../../shared/sse'; +import { + createSseResponse, + encodeDoneFrame, + isEventStream, +} from '../../shared/sse'; import { proxyChatCompletions, type ProxyContext } from '../codebuddy'; import { executeImageGenerationLoop } from '../image-generation'; import { @@ -18,6 +22,7 @@ import { mapChatResponseToResponsesStream, } from './payload'; import { createResponseId } from './ids'; +import { getUpstreamErrorMessage } from '../anthropic/errors'; import { mapChatStreamToResponsesEventStream } from './stream'; import { @@ -31,6 +36,7 @@ import type { ResponseSessionDefaults, TranscriptMessage, } from './types'; +import type { ServerToolExecution, ServerToolPreamble } from '../server-tools'; import { hasExecutableServerTool, prepareServerToolTurn, @@ -47,6 +53,10 @@ export const createResponsesEventStream = async ( proxyContext: ProxyContext, debugTrace?: DebugTrace, ): Promise => { + // The image loop drives upstream through `callUpstream`, so prose written + // before a search has to be captured there rather than at one call site. + let streamPreamble: ServerToolPreamble | undefined; + const translatedTools = translateResponsesToolsToChat(defaults.tools); // Classified on the translated tools, which keep a provider-executed @@ -87,36 +97,47 @@ export const createResponsesEventStream = async ( const callUpstream = async ( loopBody: Record, stream: boolean, - ): Promise => - willRunServerTool && rewrite - ? ( - await withCodeBuddyToken( - () => Promise.resolve(proxyContext.auth.bearerToken), - () => - runServerToolTurn({ - body: loopBody as never, - callUpstream: (turnBody, turnStream) => - proxyChatCompletions( - request, - { ...turnBody, stream: turnStream } as never, - proxyContext, - debugTrace, - '/v1/responses', - ), - fetchProvider: prepared!.providers.fetchProvider, - rewrite, - searchProvider: prepared!.providers.searchProvider, - stream, - }), - ) - ).response - : proxyChatCompletions( - request, - { ...loopBody, stream } as never, - proxyContext, - debugTrace, - '/v1/responses', - ); + ): Promise => { + if (!willRunServerTool || !rewrite) { + return proxyChatCompletions( + request, + { ...loopBody, stream } as never, + proxyContext, + debugTrace, + '/v1/responses', + ); + } + + const outcome = await withCodeBuddyToken( + () => Promise.resolve(proxyContext.auth.bearerToken), + () => + runServerToolTurn({ + body: loopBody as never, + callUpstream: (turnBody, turnStream) => + proxyChatCompletions( + request, + { ...turnBody, stream: turnStream } as never, + proxyContext, + debugTrace, + '/v1/responses', + ), + fetchProvider: prepared!.providers.fetchProvider, + rewrite, + searchProvider: prepared!.providers.searchProvider, + }), + ); + + // First non-empty wins: the image loop calls this repeatedly, and a later + // iteration that ran no server tool returns an empty preamble, which + // would erase the prose an earlier one captured. + const spoken = outcome.preamble.text || outcome.preamble.reasoning; + + if (spoken && !streamPreamble) { + streamPreamble = outcome.preamble; + } + + return outcome.response; + }; // Image generation is executed locally, so a streaming request has to be // buffered first to see whether the model asked for an image. Without this @@ -155,6 +176,8 @@ export const createResponsesEventStream = async ( proxyContext, executions, serverToolExecutions, + undefined, + streamPreamble, ); } @@ -179,13 +202,36 @@ export const createResponsesEventStream = async ( const encoder = new TextEncoder(); const responseId = createResponseId(); - const serverToolItems: ResponsesServerToolItem[] = []; - const itemsByInvocationId = new Map(); let nextOutputIndex = 0; const allocateOutputIndex = (): number => nextOutputIndex++; let activeReader: ReadableStreamDefaultReader | null = null; let cancelled = false; + /** + * `web_search_call` items for the searches a turn already ran. + * + * Nothing announces them live — the turn is buffered throughout, since + * whether the model wants another search is only knowable once a hop has + * finished — so the mapper emits the whole lifecycle in one pass when the + * answer is replayed. + */ + const buildSearchItems = ( + executions: ServerToolExecution[], + ): ResponsesServerToolItem[] => + executions.map((execution) => { + const id = `ws_${crypto.randomUUID().replaceAll('-', '')}`; + + return { + completed: buildResponsesWebSearchCallItem(execution, 'completed', id), + inProgress: buildResponsesWebSearchCallItem( + execution, + 'in_progress', + id, + ), + outputIndex: allocateOutputIndex(), + }; + }); + const stream = new ReadableStream({ start: (controller) => { const enqueueEvent = ( @@ -199,6 +245,10 @@ export const createResponsesEventStream = async ( ); }; + // Announced now, under the id the replay will reuse. The turn is + // buffered throughout, so without this the client would see nothing + // until every search and every hop had finished — long enough that a + // client with an idle timeout would drop the connection. enqueueEvent({ type: 'response.created', response: { @@ -207,6 +257,7 @@ export const createResponsesEventStream = async ( created_at: Math.floor(Date.now() / 1000), model, output: [], + status: 'in_progress', }, }); enqueueEvent({ @@ -217,10 +268,13 @@ export const createResponsesEventStream = async ( const run = async (): Promise => { const { fetchProvider, searchProvider } = prepared!.providers; - const { response } = await withCodeBuddyToken( + const { executions, preamble, response } = await withCodeBuddyToken( () => Promise.resolve(proxyContext.auth.bearerToken), () => runServerToolTurn({ + // No onCall/onResult: the turn is buffered, so the lifecycle is + // replayed from `executions` in one consistent pass instead of + // being emitted live and then again by the replay. body: chatBody as never, callUpstream: (body, stream) => proxyChatCompletions( @@ -231,69 +285,8 @@ export const createResponsesEventStream = async ( '/v1/responses', ), fetchProvider, - onCall: (invocation) => { - const outputIndex = allocateOutputIndex(); - const id = `ws_${crypto.randomUUID().replaceAll('-', '')}`; - const item = { - completed: buildResponsesWebSearchCallItem( - invocation, - 'completed', - id, - ), - inProgress: buildResponsesWebSearchCallItem( - invocation, - 'in_progress', - id, - ), - outputIndex, - }; - serverToolItems.push(item); - itemsByInvocationId.set(invocation.id, item); - enqueueEvent({ - type: 'response.output_item.added', - item: item.inProgress, - output_index: outputIndex, - response_id: responseId, - }); - enqueueEvent({ - type: 'response.web_search_call.in_progress', - item_id: id, - output_index: outputIndex, - }); - enqueueEvent({ - type: 'response.web_search_call.searching', - item_id: id, - output_index: outputIndex, - }); - }, - onResult: (execution) => { - const item = itemsByInvocationId.get(execution.id); - - if (!item) { - return; - } - - const id = String(item.inProgress.id); - item.completed = buildResponsesWebSearchCallItem( - execution, - 'completed', - id, - ); - enqueueEvent({ - type: 'response.web_search_call.completed', - item_id: id, - output_index: item.outputIndex, - }); - enqueueEvent({ - type: 'response.output_item.done', - item: item.completed, - output_index: item.outputIndex, - response_id: responseId, - }); - }, rewrite: rewrite!, searchProvider, - stream: true, }), ); @@ -303,29 +296,63 @@ export const createResponsesEventStream = async ( } if (!response.ok) { + // The upstream's own words: a rate limit has to arrive as one, or a + // client that retries on that alone stops retrying. enqueueEvent({ type: 'response.error', - error: { message: 'Upstream request failed' }, + error: { + message: await getUpstreamErrorMessage(response).catch( + () => 'Upstream request failed', + ), + }, }); controller.enqueue(encodeDoneFrame()); controller.close(); return; } - const mappedResponse = mapChatStreamToResponsesEventStream( - response, - defaults, - transcript, - model, - previousResponseId, - proxyContext, - responseId, - serverToolItems, - false, - false, - allocateOutputIndex, - true, - ); + /** + * The turn is finished before this point, so `response` is a buffered + * payload, not a live stream — every hop had to complete to know + * whether the model wanted another search. Handing that to the SSE + * mapper would find no `data:` frames and drop the answer entirely, so + * a buffered response is replayed through the buffered→Responses + * mapper instead. + */ + const mappedResponse = await (isEventStream(response) + ? mapChatStreamToResponsesEventStream( + response, + defaults, + transcript, + model, + previousResponseId, + proxyContext, + responseId, + buildSearchItems(executions), + false, + true, + allocateOutputIndex, + true, + ) + : mapChatResponseToResponsesStream( + (await response.json()) as Record, + defaults, + transcript, + model, + previousResponseId, + proxyContext, + [], + executions, + // The id already announced to the client. The mapper persists + // the session under whatever id it emits, so without this the + // client is handed an id nothing was stored against, and a + // follow-up carrying `previous_response_id` fails. + responseId, + preamble, + // Already announced above: the replay must not emit a + // second `response.created` under the same id. + false, + )); const reader = mappedResponse.body!.getReader(); activeReader = reader; diff --git a/lib/server/proxy/responses/payload.ts b/lib/server/proxy/responses/payload.ts index 6bf738f..4f897e5 100644 --- a/lib/server/proxy/responses/payload.ts +++ b/lib/server/proxy/responses/payload.ts @@ -36,6 +36,7 @@ import type { import type { ServerToolExecution, ServerToolInvocation, + ServerToolPreamble, } from '../server-tools'; export const mapChatResponseToResponsesPayload = async ( @@ -48,8 +49,10 @@ export const mapChatResponseToResponsesPayload = async ( upstreamPayload: Record, serverToolExecutions: ServerToolExecution[], imageExecutions: ImageGenerationExecution[] = [], + pinnedResponseId?: string, + preamble?: ServerToolPreamble, ): Promise> => { - const responseId = createResponseId(); + const responseId = pinnedResponseId ?? createResponseId(); const choices = Array.isArray(upstreamPayload.choices) ? upstreamPayload.choices : []; @@ -61,7 +64,39 @@ export const mapChatResponseToResponsesPayload = async ( : []; const outputText = stringifyContent(firstChoice.message?.content); const createdAt = Math.floor(Date.now() / 1000); + // What the model said before it reached for a search, ahead of the searches + // themselves — the order it was written in. Only the closing hop's prose and + // reasoning live in `upstreamPayload`, so without this a Responses client + // never sees the first half of the turn. + const preambleItems: Array> = [ + ...(preamble?.reasoning + ? [ + { + id: createResponseReasoningId(), + type: 'reasoning', + summary: [{ type: 'summary_text', text: preamble.reasoning }], + encrypted_content: `${REASONING_PREFIX}${preamble.reasoning}`, + status: 'completed', + }, + ] + : []), + ...(preamble?.text + ? [ + { + id: createMessageId(), + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { type: 'output_text', text: preamble.text, annotations: [] }, + ], + }, + ] + : []), + ]; + const output: Array> = [ + ...preambleItems, ...serverToolExecutions.map((execution) => buildResponsesWebSearchCallItem(execution, 'completed'), ), @@ -208,6 +243,9 @@ export const mapChatResponseToResponsesStream = async ( proxyContext: ProxyContext, imageExecutions: ImageGenerationExecution[], serverToolExecutions: ServerToolExecution[] = [], + pinnedResponseId?: string, + preamble?: ServerToolPreamble, + emitOpeningEvents = true, ): Promise => { const payload = await mapChatResponseToResponsesPayload( proxyContext.accessKeyId, @@ -219,13 +257,22 @@ export const mapChatResponseToResponsesStream = async ( upstreamPayload, serverToolExecutions, imageExecutions, + pinnedResponseId, + preamble, ); // The mapper creates and persists the session id, so the stream has to reuse // it: advertising a different one would leave a client unable to continue the // turn, because nothing was stored under the id it was given. const responseId = String(payload.id); const output = payload.output as Array>; - const messageIndex = output.findIndex((item) => item.type === 'message'); + // The last message, not the first: a preamble is a message too, and + // streaming the pre-search prose as the answer would drop the real one. + let messageIndex = -1; + output.forEach((item, index) => { + if (item.type === 'message') { + messageIndex = index; + } + }); const messageItem = messageIndex === -1 ? null @@ -288,14 +335,21 @@ export const mapChatResponseToResponsesStream = async ( }; const frames: Array> = [ - { - response: { ...payload, output: [], status: 'in_progress' }, - type: 'response.created', - }, - { - response: { id: responseId, status: 'in_progress' }, - type: 'response.in_progress', - }, + // Skipped when the caller already announced the opening: a streaming + // server-tool turn emits it up front so the connection is not idle for + // the whole turn, and a second copy would give the client two ids. + ...(emitOpeningEvents + ? [ + { + response: { ...payload, output: [], status: 'in_progress' }, + type: 'response.created', + }, + { + response: { id: responseId, status: 'in_progress' }, + type: 'response.in_progress', + }, + ] + : []), ...otherItems.flatMap(({ item, output_index }) => serverToolFrames({ item, output_index }), ), diff --git a/lib/server/proxy/responses/stream.ts b/lib/server/proxy/responses/stream.ts index 6f8543e..5636936 100644 --- a/lib/server/proxy/responses/stream.ts +++ b/lib/server/proxy/responses/stream.ts @@ -203,6 +203,7 @@ export const mapChatStreamToResponsesEventStream = ( created_at: Math.floor(Date.now() / 1000), model, output: [], + status: 'in_progress', }, }); enqueueEvent({ diff --git a/lib/server/proxy/responses/tools.ts b/lib/server/proxy/responses/tools.ts index b8dd969..686d839 100644 --- a/lib/server/proxy/responses/tools.ts +++ b/lib/server/proxy/responses/tools.ts @@ -145,6 +145,7 @@ export const toSupportedChatTool = ( return [ { chatName: WEB_SEARCH_TOOL_NAME, + declaration: tool as unknown as Record, kind: 'function', originalName: WEB_SEARCH_TOOL_NAME, serverType: toolType, @@ -166,6 +167,7 @@ export const toSupportedChatTool = ( return [ { chatName: WEB_FETCH_TOOL_NAME, + declaration: tool as unknown as Record, kind: 'function', originalName: WEB_FETCH_TOOL_NAME, serverType: toolType, @@ -390,11 +392,18 @@ export const translateResponsesToolsToChat = ( } return supported.map((tool) => { + if (!tool.serverType) { + // An ordinary function, which upstream understands as it stands. + return { type: 'function', function: tool.tool }; + } + + // A provider-executed declaration keeps its declared type — the only thing + // that tells it apart from the client's own function of the same name — and + // carries the rest of what the client declared (`max_uses`, + // `allowed_domains`, …), which the turn reads before it rewrites the shape. return { - // A provider-executed declaration keeps its declared type so it is still - // recognisable downstream. Everything else is an ordinary function, which - // upstream understands. - type: tool.serverType ?? 'function', + ...(tool.declaration ?? {}), + type: tool.serverType, function: tool.tool, }; }); diff --git a/lib/server/proxy/responses/types.ts b/lib/server/proxy/responses/types.ts index 6dd1f99..1c890a7 100644 --- a/lib/server/proxy/responses/types.ts +++ b/lib/server/proxy/responses/types.ts @@ -42,6 +42,14 @@ export interface SupportedChatTool { */ serverType?: string; serverLabel?: string; + /** + * The client's declaration, untouched. + * + * `toSupportedChatTool` synthesises a fresh object, so anything the client + * set that the proxy has no field for — `max_uses`, `allowed_domains`, + * `user_location` — would otherwise vanish before the turn reads it. + */ + declaration?: Record; tool: Record; } diff --git a/lib/server/proxy/server-tools/classify.ts b/lib/server/proxy/server-tools/classify.ts index 9a1d809..388d028 100644 --- a/lib/server/proxy/server-tools/classify.ts +++ b/lib/server/proxy/server-tools/classify.ts @@ -35,6 +35,54 @@ import type { ChatCompletionToolCall, ServerToolKind } from './types'; * the server type, and that sub-request is the one that runs here. */ +/** + * How many searches one request may run when the client does not say. + * + * Anthropic's own default. A client that cares sends `max_uses` on the + * declaration; this is only the fallback for one that does not. + */ +export const DEFAULT_MAX_SEARCH_USES = 5; + +/** + * Ceiling on a client-declared `max_uses`. + * + * Every use is a sequential upstream round trip, so an unbounded value is an + * unbounded request: `max_uses: 1000` would hold the connection for a thousand + * calls. Generous enough that no real client is constrained. + */ +export const MAX_SEARCH_USES_CEILING = 20; + +/** + * The search budget the client asked for. + * + * Anthropic declares it as `max_uses` on the server tool, and it bounds the + * whole turn rather than each hop: a model that refines its query twice has + * used two of the eight, not two of eight per round. + */ +/** + * The `max_uses` the client declared for one server tool. + * + * Read per kind, never merged: `max_uses` is declared on the individual tool, + * so a fetch's budget must not cap the searches, nor the other way round. + */ +export const readMaxUses = (tools: unknown, kind: ServerToolKind): number => { + if (!Array.isArray(tools)) { + return DEFAULT_MAX_SEARCH_USES; + } + + const declared = tools + .filter((tool) => classifyServerToolDeclaration(tool) === kind) + .map((tool) => asRecord(tool)?.max_uses) + .filter((value): value is number => typeof value === 'number') + .filter((value) => Number.isFinite(value) && value >= 0); + + if (!declared.length) { + return DEFAULT_MAX_SEARCH_USES; + } + + return Math.min(MAX_SEARCH_USES_CEILING, Math.floor(Math.min(...declared))); +}; + const SERVER_TOOL_PREFIXES: ReadonlyArray<{ kind: ServerToolKind; prefix: string; @@ -130,7 +178,12 @@ export const hasAmbiguousServerToolName = (tools: unknown): boolean => { const clientNames = new Set(); tools.forEach((tool) => { - const name = normalizeToolName(declarationName(tool)); + // Exact, not normalised. Normalising makes `WebSearch` and `web_search` + // the same string, so a client declaring its own `WebSearch` next to the + // server tool looked like a collision and had the whole server-tool + // feature switched off — the declarations are already told apart by + // their declared type, so the names never needed comparing loosely. + const name = declarationName(tool); if (!name) { return; @@ -143,12 +196,17 @@ export const hasAmbiguousServerToolName = (tools: unknown): boolean => { } }); - return [...serverNames].some((name) => - [...clientNames].some((client) => name === client), - ); + return [...serverNames].some((name) => clientNames.has(name)); }; export interface RewrittenServerTools { + /** How many calls of each kind this turn may make; see {@link readMaxUses}. */ + maxUses: { web_fetch: number; web_search: number }; + /** + * Which server tool a call names, or `null` when the call is not one the + * proxy injected. Matched exactly — see the note in {@link rewriteServerTools}. + */ + classifyCall: (toolCall: ChatCompletionToolCall) => ServerToolKind | null; /** * Which declared server tools the proxy will execute. A declaration the proxy * cannot run — no backend configured — is still rewritten upstream, but is @@ -157,13 +215,6 @@ export interface RewrittenServerTools { executable: ServerToolDeclarations; /** Sorts a tool call into one the proxy runs and one the client resolves. */ isExecutableCall: (toolCall: ChatCompletionToolCall) => boolean; - /** - * Declarations for the follow-up call, with the executed server tools - * removed. They are dropped rather than left callable because the follow-up - * exists to write the answer, and a second search there would be a second - * turn this proxy does not run. - */ - followUpTools: unknown[]; tools: unknown[]; } @@ -192,25 +243,48 @@ export const rewriteServerTools = ({ // {@link hasAmbiguousServerToolName}. const ambiguous = hasAmbiguousServerToolName(tools); + const maxUses = { + web_fetch: readMaxUses(tools, 'web_fetch'), + web_search: readMaxUses(tools, 'web_search'), + }; + const executable: ServerToolDeclarations = { fetch: declarations.fetch && Boolean(fetchProvider) && !ambiguous, search: declarations.search && Boolean(searchProvider) && !ambiguous, }; - const injectedNames = new Set(); + /** + * Which server tool each name the proxy injected belongs to. + * + * Keyed by the exact name first. `search/tool.ts` records that upstream + * echoes these back respelled — `WebSearch`, `Web Fetch` — so the normalised + * form is registered too, but only when the client declared no colliding + * name of its own; see the note at the registration site. + * + * A miss is not an error to be recovered from. It means the call is not + * ours, and it goes back to the client — which is the safe direction to be + * wrong in. + */ const definitions = new Map(); - const followUpTools: unknown[] = []; /** Whether the proxy runs `kind`, as opposed to leaving it to the client. */ const runsLocally = (kind: ServerToolKind): boolean => kind === 'web_search' ? executable.search : executable.fetch; - const rewritten = tools.map((tool) => { + const rewritten = tools.flatMap((tool) => { const kind = classifyServerToolDeclaration(tool); if (!kind) { - followUpTools.push(tool); - return tool; + return [tool]; + } + + // Withdrawn rather than offered when nothing here can run it. Offering it + // anyway would have the model call it and hand the client a `tool_use` for + // a name it declared as a *provider-executed* tool and has no handler for + // — no search, and a turn the client cannot complete. Answering from + // memory is the honest degradation. + if (!runsLocally(kind)) { + return []; } const definition = @@ -218,34 +292,72 @@ export const rewriteServerTools = ({ ? buildWebSearchToolDefinition() : buildWebFetchToolDefinition(); - injectedNames.add(normalizeToolName(definition.name)); - definitions.set(normalizeToolName(definition.name), kind); + definitions.set(definition.name, kind); - // A declaration the proxy is not running stays callable on the follow-up: - // the client is the one that answers it, and dropping it would silently - // remove a tool the client asked for. - if (!runsLocally(kind)) { - followUpTools.push({ type: 'function', function: definition }); + // Stays callable on every hop: the model decides when it has enough, and + // removing it here would forbid exactly the follow-up search that makes a + // server tool worth having. + return [{ type: 'function', function: definition }]; + }); + + /** + * Which server tool `toolCall` names, or `null` when it is not one of ours. + * + * Compared exactly, for the reason above. + */ + const classifyCall = ( + toolCall: ChatCompletionToolCall, + ): ServerToolKind | null => { + const name = toolCall.function?.name; + + if (typeof name !== 'string') { + return null; } - return { type: 'function', function: definition }; - }); + // Exact first; the normalised spelling is only a fallback, registered + // solely when nothing collides, for an upstream that respells the name it + // was given. Looking up the raw name alone would make that fallback dead. + return ( + definitions.get(name) ?? definitions.get(normalizeToolName(name)) ?? null + ); + }; /** - * Only a name the proxy injected, and only for a tool it has a backend for. + * Respelled names are only safe to claim when no client function normalises + * onto one of ours. * - * Matched in canonical form because the name comes back from the model, which - * is under no obligation to repeat the spelling it was given: upstream echoes - * `web_fetch` as `WebFetch` often enough to matter here. + * A *normalised* test even though `hasAmbiguousServerToolName` is an exact + * one, and deliberately so: the fallback matches in normalised space, so + * that is where its safety has to be judged. A client declaring its own + * `WebSearch` must keep it — otherwise the proxy would answer exactly the + * call that client declared the tool to handle itself. */ + const clientNormalised = new Set( + tools + .filter((tool) => !classifyServerToolDeclaration(tool)) + .map((tool) => normalizeToolName(declarationName(tool))), + ); + + for (const [name, kind] of [...definitions]) { + if (!clientNormalised.has(normalizeToolName(name))) { + definitions.set(normalizeToolName(name), kind); + } + } + + /** Whether the proxy runs this call, as opposed to leaving it to the client. */ const isExecutableCall = (toolCall: ChatCompletionToolCall): boolean => { - const name = normalizeToolName(toolCall.function?.name ?? ''); - const kind = definitions.get(name); + const kind = classifyCall(toolCall); return kind ? runsLocally(kind) : false; }; - return { executable, followUpTools, isExecutableCall, tools: rewritten }; + return { + classifyCall, + executable, + isExecutableCall, + maxUses, + tools: rewritten, + }; }; /** Whether the proxy will run any server tool at all. */ diff --git a/lib/server/proxy/server-tools/execute.ts b/lib/server/proxy/server-tools/execute.ts index e940f2d..31aee48 100644 --- a/lib/server/proxy/server-tools/execute.ts +++ b/lib/server/proxy/server-tools/execute.ts @@ -5,23 +5,25 @@ import type { ChatCompletionToolCall, ServerToolExecution, ServerToolInvocation, + ServerToolKind, } from './types'; /** * Turns one tool call into the invocation a backend runs. * - * The name decides which tool, so it is read in canonical form: the model is - * under no obligation to repeat the spelling it was given, and upstream echoes - * `web_fetch` back as `WebFetch` often enough to matter. + * `kind` comes from the classifier that already recognised the call, rather + * than being re-derived from the name here: re-deriving it means re-spelling + * it, and respelling tool names is exactly how the client's own `WebSearch` + * came to be mistaken for the server tool. */ export const buildServerToolInvocation = ( toolCall: ChatCompletionToolCall, + kind: ServerToolKind, index: number, ): ServerToolInvocation => { - const name = (toolCall.function?.name ?? '').toLowerCase(); const id = toolCall.id ?? `server_tool_${index}`; - return name.includes('fetch') + return kind === 'web_fetch' ? { id, input: extractFetchQuery(toolCall.function?.arguments), diff --git a/lib/server/proxy/server-tools/index.ts b/lib/server/proxy/server-tools/index.ts index 285fc36..b7a1a9c 100644 --- a/lib/server/proxy/server-tools/index.ts +++ b/lib/server/proxy/server-tools/index.ts @@ -4,6 +4,7 @@ export { getForcedToolName, hasAmbiguousServerToolName, hasExecutableServerTool, + readMaxUses, rewriteServerTools, } from './classify'; export type { RewrittenServerTools, ServerToolDeclarations } from './classify'; diff --git a/lib/server/proxy/server-tools/turn.ts b/lib/server/proxy/server-tools/turn.ts index dd23e26..0d83f2b 100644 --- a/lib/server/proxy/server-tools/turn.ts +++ b/lib/server/proxy/server-tools/turn.ts @@ -6,7 +6,7 @@ import { } from '../../domain/config'; import { resolveFetchProvider, resolveSearchProvider } from '../../search'; import type { WebFetchProvider, WebSearchProvider } from '../../search/types'; -import { readReasoning } from '../../shared/content'; +import { asRecord, readReasoning } from '../../shared/content'; import type { ChatRequestBody } from '../codebuddy'; import { buildServerToolInvocation, @@ -26,24 +26,24 @@ import type { JsonRecord, ServerToolExecution, ServerToolInvocation, + ServerToolKind, ServerToolPreamble, ServerToolTurnOutcome, } from './types'; -import { attachServerToolExecutions, EMPTY_PREAMBLE } from './types'; +import { attachServerToolExecutions, EMPTY_PREAMBLE, sumUsage } from './types'; /** * One server-tool turn. * - * The model is asked for a search, the search runs here, and upstream is asked - * once more — without the server tools it could call again — to write the - * answer. Two calls at most, and the second is unconditional, which is why this - * is not a loop: there is no "until the model stops asking", because the - * follow-up cannot ask. + * A loop, bounded by the `max_uses` the client declared: ask upstream, run + * whatever server tools it reached for, feed the findings back, and ask again + * until the model stops asking or the budget is spent — then one closing call + * with the server tools withdrawn so it answers with what it has. * - * A client that wants several searches sends several requests. Claude Code is - * the reference: it resolves its own `WebSearch` tool, and only opens a - * sub-request carrying the server type once it has a result to fill in. That - * sub-request asks for exactly one search, and this answers it. + * The loop is over the *server* tools only. Claude Code's own `WebSearch` is an + * ordinary client function; a call to it is never picked up, because Claude + * Code wants to resolve it itself. That is the whole distinction this module + * exists to protect. */ /** @@ -169,17 +169,102 @@ const rebuildResponse = (response: Response, body: string): Response => { }); }; +/** + * Rewrites a payload so only the calls the client still has to answer survive. + * + * The executed ones have already been answered — their findings travel as + * `executions`, which each renderer turns into real protocol blocks. Leaving + * them in `tool_calls` too would hand the client a second, unresolved copy. + */ +const keepOutstandingCalls = ( + payload: ChatCompletionPayload, + outstanding: ChatCompletionToolCall[], +): ChatCompletionPayload => { + const [first, ...rest] = payload.choices ?? []; + + if (!first) { + return payload; + } + + return { + ...payload, + choices: [ + { + ...first, + // Only `tool_calls` when something really is outstanding: stamping it + // onto an answer makes every renderer report `stop_reason: 'tool_use'` + // with nothing to satisfy, and the client discards the answer. + finish_reason: outstanding.length ? 'tool_calls' : 'stop', + message: { ...(first.message ?? {}), tool_calls: outstanding }, + }, + ...rest, + ], + }; +}; + +/** + * Reads a hop's payload, converting a malformed body into an error. + * + * A body that will not parse on a successful status is an upstream failure, + * not a reason to throw out of the turn: everything already searched would be + * billed and lost, and the client would get a JSON-parse message instead of + * whatever upstream actually said. + */ +const readBufferedPayloadSafely = async ( + response: Response, + buffered: string, +): Promise => { + try { + return await readBufferedChatCompletionPayload( + rebuildResponse(response, buffered), + buffered, + ); + } catch { + return { error: { message: 'Upstream returned a malformed response' } }; + } +}; + +/** + * Drops a hop's prose and reasoning, which have already been captured as the + * preamble. Leaving them on the payload too renders them twice — once ahead of + * the searches, once after. + */ +const withoutContent = ( + payload: ChatCompletionPayload, +): ChatCompletionPayload => { + const [first, ...rest] = payload.choices ?? []; + + if (!first) { + return payload; + } + + return { + ...payload, + choices: [ + { + ...first, + message: { + ...(first.message ?? {}), + content: null, + reasoning: undefined, + reasoning_content: undefined, + }, + }, + ...rest, + ], + }; +}; + const asMessages = (body: ChatRequestBody): JsonRecord[] => (Array.isArray(body.messages) ? body.messages : []) as JsonRecord[]; /** - * Runs one server-tool turn. + * Runs the server-tool loop; see the module note above. * - * Upstream is asked for a search, the search runs here, and upstream is asked - * once more — without the server tools, so it cannot ask again — to write the - * answer. Two calls at most. The response is always the one to render: the - * first has already been spent reading the tool calls, so a caller that - * re-issued it would be billed twice for the same turn. + * Every hop is buffered rather than streamed, because whether the model wants + * another search is only knowable once the hop has finished. A streaming client + * gets the finished turn replayed as SSE instead of a live stream — unavoidable + * here, since the first search has to complete before there is anything to say. */ export const runServerToolTurn = async ({ body, @@ -189,14 +274,9 @@ export const runServerToolTurn = async ({ onResult, rewrite, searchProvider, - stream, }: { body: ChatRequestBody; - /** - * One round trip to upstream. `stream` asks for SSE rather than a buffered - * payload; the first call is always buffered, because the tool calls are only - * visible once it has finished. - */ + /** One round trip to upstream. Always buffered. */ callUpstream: (body: ChatRequestBody, stream: boolean) => Promise; fetchProvider: WebFetchProvider | null; onCall?: (invocation: ServerToolInvocation) => void; @@ -204,118 +284,339 @@ export const runServerToolTurn = async ({ /** Output of {@link rewriteServerTools} for this request. */ rewrite: NonNullable>; searchProvider: WebSearchProvider | null; - stream: boolean; }): Promise => { - const { executable, followUpTools, isExecutableCall, tools } = rewrite; - - const first = await callUpstream({ ...body, tools }, false); - const buffered = await first.text(); - const payload = await readBufferedChatCompletionPayload( - rebuildResponse(first, buffered), - buffered, - ); - - // A failure is handed back untouched: whatever the turn would have done with - // the tool calls, the request did not succeed, and the client needs the real - // status and detail rather than a summary. - if (!first.ok || payload.error) { - return { - executions: [], - preamble: EMPTY_PREAMBLE, - response: rebuildResponse(first, buffered), - }; + const { classifyCall, executable, isExecutableCall, maxUses, tools } = + rewrite; + + const executions: ServerToolExecution[] = []; + let preamble = EMPTY_PREAMBLE; + let transcript = asMessages(body); + let usage: unknown = null; + // Counted separately: the client declares `max_uses` on each server tool, so + // a fetch must not spend the search budget — but both need a bound, or a + // turn that only fetches would never terminate. + let searches = 0; + let fetches = 0; + let callCounter = 0; + let firstHop = true; + + while (true) { + const response = await callUpstream( + { + ...body, + messages: transcript, + tools, + // Only the first hop honours a forced server tool; after that the + // model chooses, or it would never stop searching. + tool_choice: firstHop + ? body.tool_choice + : relaxToolChoice(body.tool_choice, classifyCall), + }, + false, + ); + + const buffered = await response.text(); + const payload = await readBufferedPayloadSafely(response, buffered); + usage = sumUsage(usage, payload.usage); + + // A failure ends the turn: the request did not succeed, and the client + // needs the real status and detail rather than a summary. + if (!response.ok || payload.error) { + return { + executions, + preamble, + // Attached even on failure: the earlier hops really ran and were + // really billed, and the Responses and image paths recover them from + // the response rather than from the return value. + response: attachServerToolExecutions( + rebuildResponse(response, JSON.stringify(withUsage(payload, usage))), + executions, + ), + usage, + }; + } + + const message = payload.choices?.[0]?.message; + const toolCalls: ChatCompletionToolCall[] = message?.tool_calls ?? []; + // Each call is resolved to its kind once, here, rather than being + // re-derived from its name further down. + const localCalls = toolCalls.flatMap((toolCall) => { + const kind = classifyCall(toolCall); + + return kind && isExecutableCall(toolCall) ? [{ kind, toolCall }] : []; + }); + const remainingCalls = toolCalls.filter( + (toolCall) => !isExecutableCall(toolCall), + ); + + // The model stopped asking. This hop is the answer. + if (!localCalls.length) { + return { + executions, + preamble, + response: attachServerToolExecutions( + rebuildResponse(response, JSON.stringify(withUsage(payload, usage))), + executions, + ), + usage, + }; + } + + // Captured on every hop, up to the first one that actually speaks: the + // model may explain itself before each search, and only the closing + // answer lives in the payload the renderer sees. + // First hop that actually speaks wins. The preamble is rendered ahead of + // every search, so a later hop's prose here would appear to precede the + // search it was written after. + if (!preamble.text && !preamble.reasoning) { + preamble = readPreamble(message); + } + + // Clamped to the budget before executing: the bound is only testable + // between hops, so a hop emitting k parallel searches would otherwise run + // them all and overshoot by up to k-1 — billed to the client either way. + const affordable = takeWithinBudget(localCalls, { + fetches, + maxUses, + searches, + }); + + // A turn-scoped counter: indexing within a hop made two hops that omitted + // ids both produce `server_tool_0`, so the transcript carried two calls + // sharing one id. + const invocations = affordable.map(({ kind, toolCall }) => + buildServerToolInvocation(toolCall, kind, callCounter++), + ); + + const results = await executeServerToolInvocations({ + fetchProvider, + invocations, + ...(onCall ? { onCall } : {}), + ...(onResult ? { onResult } : {}), + searchProvider, + }); + + executions.push(...results.map((result) => result.execution)); + searches += results.filter( + (result) => result.execution.type === 'web_search', + ).length; + fetches += results.filter( + (result) => result.execution.type === 'web_fetch', + ).length; + + // Only the calls that were actually run: an assistant message promising + // more calls than there are results for violates the chat protocol, and + // upstream rejects the next hop. + const runCalls = invocations.map((invocation, index) => ({ + ...(affordable[index].toolCall as JsonRecord), + id: invocation.id, + })); + + if (runCalls.length) { + transcript = [ + ...transcript, + { + ...(message as JsonRecord), + content: message?.content ?? null, + role: message?.role ?? 'assistant', + tool_calls: runCalls, + }, + ...results.map((result) => ({ + role: 'tool', + content: result.content, + tool_call_id: result.tool_call_id, + })), + ]; + } + + /** + * A hop that also asked for something the client owns cannot be continued + * here: replaying the transcript would leave the client's own calls in an + * assistant message with no result behind them, which upstream rejects. + * The findings go back as they are and the outstanding calls stay the + * client's to resolve — and every protocol this proxy serves can carry + * those findings structurally, so nothing is folded into the text. + */ + if (remainingCalls.length) { + return { + executions, + preamble, + response: attachServerToolExecutions( + rebuildResponse( + response, + JSON.stringify( + withUsage( + keepOutstandingCalls(withoutContent(payload), remainingCalls), + usage, + ), + ), + ), + executions, + ), + usage, + }; + } + + firstHop = false; + + // Budget spent. One last call with the server tools withdrawn, so the + // model answers with what it has instead of asking for a search it will + // not get. + // Only kind the turn can actually run counts, and a kind is spent only + // when it has run out. OR-ing the two, or counting every declared kind, + // would end the turn while one of them still had allowance left — or + // never end it at all for a kind that was declared but never used. + const spent = + (!executable.search || searches >= maxUses.web_search) && + (!executable.fetch || fetches >= maxUses.web_fetch); + + if (spent) { + const finalResponse = await callUpstream( + { + ...body, + messages: transcript, + ...withoutServerTools(tools, isExecutableCall), + }, + false, + ); + + const finalBuffered = await finalResponse.text(); + const finalPayload = await readBufferedChatCompletionPayload( + rebuildResponse(finalResponse, finalBuffered), + finalBuffered, + ); + + usage = sumUsage(usage, finalPayload.usage); + + if (!finalResponse.ok || finalPayload.error) { + return { + executions, + preamble, + // Attached even on failure: the searches really ran and were really + // billed, and the Responses path recovers them from the response. + response: attachServerToolExecutions( + rebuildResponse( + finalResponse, + JSON.stringify(withUsage(finalPayload, usage)), + ), + executions, + ), + usage, + }; + } + + /** + * The model may still ask, even with the tool withdrawn. Those calls are + * dropped rather than passed on: the budget is spent, so nothing will + * answer them, and the client never declared a `web_search` it could + * resolve itself. Anything it *does* own survives. + */ + const finalCalls = ( + finalPayload.choices?.[0]?.message?.tool_calls ?? [] + ).filter((toolCall) => !isExecutableCall(toolCall)); + + return { + executions, + preamble, + response: attachServerToolExecutions( + rebuildResponse( + finalResponse, + JSON.stringify( + withUsage(keepOutstandingCalls(finalPayload, finalCalls), usage), + ), + ), + executions, + ), + usage, + }; + } } +}; - const message = payload.choices?.[0]?.message; - const toolCalls: ChatCompletionToolCall[] = message?.tool_calls ?? []; - const localCalls = toolCalls.filter(isExecutableCall); - - // The model answered without reaching for a server tool — the ordinary case - // for a request that merely *declares* one. Its answer is the whole turn, so - // the caller renders this response directly. - if (!localCalls.length) { - return { - executions: [], - preamble: readPreamble(message), - response: rebuildResponse(first, buffered), - }; - } +/** + * Trims a hop's calls to what the budget still allows. + * + * `searches`/`fetches` are the running totals and `maxUses` the bound for + * each kind; a hop may ask for more than is left, and the excess is dropped + * rather than executed and billed. + */ +const takeWithinBudget = ( + calls: T[], + budget: { + fetches: number; + maxUses: { web_fetch: number; web_search: number }; + searches: number; + }, +): T[] => { + const left = { + web_fetch: budget.maxUses.web_fetch - budget.fetches, + web_search: budget.maxUses.web_search - budget.searches, + }; - const invocations = localCalls.map((toolCall, index) => - buildServerToolInvocation(toolCall, index), - ); + return calls.filter((call) => (left[call.kind] -= 1) >= 0); +}; - const results = await executeServerToolInvocations({ - fetchProvider, - invocations, - ...(onCall ? { onCall } : {}), - ...(onResult ? { onResult } : {}), - searchProvider, +/** Drops the server tools from a tool list, leaving the client's own. */ +const withoutServerTools = ( + tools: unknown[], + isExecutableCall: (toolCall: ChatCompletionToolCall) => boolean, +): { tool_choice: unknown; tools: unknown[] } => { + const remaining = tools.filter((tool) => { + const name = asRecord(asRecord(tool)?.function)?.name; + + return !isExecutableCall({ + function: { name: typeof name === 'string' ? name : '' }, + }); }); - const executions: ServerToolExecution[] = results.map( - (result) => result.execution, - ); - - const messages: JsonRecord[] = [ - ...asMessages(body), - { - ...(message as JsonRecord), - content: message?.content ?? null, - role: message?.role ?? 'assistant', - }, - ...results.map((result) => ({ - role: 'tool', - content: result.content, - tool_call_id: result.tool_call_id, - })), - ]; - - const response = await callUpstream( - { - ...body, - messages, - tools: followUpTools, - tool_choice: relaxToolChoice(body.tool_choice, executable), - }, - stream, - ); - + // No `tool_choice` once nothing is left to choose: naming a tool that is not + // on offer is a contradiction some upstreams reject outright, and every + // search already run lives in this hop's transcript. return { - // Published on the response as well as returned, so a caller that drives - // upstream itself — the image-generation loop — can pick up searches that - // ran on a hop it did not produce. - executions, - preamble: readPreamble(message), - response: attachServerToolExecutions(response, executions), + tool_choice: remaining.length ? 'auto' : undefined, + tools: remaining, }; }; /** - * Keeps the follow-up from being forced back into a search. + * Writes the running total back onto a payload. * - * A `tool_choice` naming a server tool the proxy has just run would make the - * follow-up call it again — and the follow-up has no server tool to call, so - * upstream would reject it. `required` has the same effect by another route: it - * obliges the model to call something when the turn needs an answer. + * Each hop reports only its own usage, and the client is billed for the whole + * turn — every search plus the answer. + */ +const withUsage = ( + payload: ChatCompletionPayload, + usage: unknown, +): ChatCompletionPayload => + usage === null || usage === undefined ? payload : { ...payload, usage }; + +/** + * Stops a forced server tool from compelling another search. + * + * Claude Code's side request arrives with `tool_choice` pinned to + * `web_search`, and that pin has to hold for the first hop — it is what makes + * the model produce a query instead of answering from memory. Left in place it + * would then force a search on every hop forever, so afterwards it becomes + * `auto`: the model may search again, but it does not have to. + * + * `required` becomes `auto` for the same reason by another route. */ const relaxToolChoice = ( toolChoice: unknown, - executable: { fetch: boolean; search: boolean }, + classifyCall: (toolCall: ChatCompletionToolCall) => ServerToolKind | null, ): unknown => { if (!toolChoice) { return toolChoice; } const name = getForcedToolName(toolChoice); - const canonical = name ? name.toLowerCase().replace(/[_\-\s]+/g, '') : ''; - - if ( - canonical && - ((executable.search && canonical.startsWith('websearch')) || - (executable.fetch && canonical.startsWith('webfetch'))) - ) { - return 'none'; + + // Compared exactly, not by normalised prefix: `tool_choice` carries only a + // name, and a client pinning its own `WebSearch` normalises to the same + // string as the server tool — loosening that would quietly stop the model + // from calling the tool the client pinned. + if (name && classifyCall({ function: { name } }) !== null) { + return 'auto'; } if (toolChoice === 'required') { diff --git a/lib/server/proxy/server-tools/types.ts b/lib/server/proxy/server-tools/types.ts index 3368245..5bb2014 100644 --- a/lib/server/proxy/server-tools/types.ts +++ b/lib/server/proxy/server-tools/types.ts @@ -7,15 +7,17 @@ * executes the call against a configured backend and hands the findings back * as if upstream had produced them. * - * What is deliberately absent here is a loop. A server tool is answered in one - * bounded turn: the model asks for a search, the proxy runs it, and upstream is - * asked once more — without the server tools available to call again — to write - * the answer. Iterating until the model stops asking would be a loop, and the - * corrected flow does not need one: a client that wants several searches issues - * several requests, which is exactly what Claude Code does when it answers its - * own `WebSearch` tool. + * The loop lives *inside* one request, which is what a server tool means to + * the client: the model asks for a search, the proxy runs it, feeds the + * findings back, and upstream decides whether to search again or write the + * answer. Only the finished turn crosses the wire. + * + * What is deliberately absent is a loop over the *client's* tools. Claude Code + * declares `WebSearch` as an ordinary function and resolves it itself, so a + * call to it goes straight back — the proxy never picks it up. */ +import { asRecord } from '../../shared/content'; import type { WebFetchQuery, WebFetchResponse, @@ -115,6 +117,12 @@ export interface ServerToolTurnOutcome { /** What the model wrote before those calls. Empty when it spoke only after. */ preamble: ServerToolPreamble; response: Response; + /** + * Token usage for the whole turn, summed across every hop. Carried here + * rather than read off `response` because a hop only ever reports its own + * usage, and the client is billed for all of them. + */ + usage: unknown; } /** @@ -141,3 +149,52 @@ export const attachServerToolExecutions = ( export const getServerToolExecutions = ( response: Response, ): ServerToolExecution[] => serverToolExecutions.get(response) ?? []; + +/** + * Adds two usage blocks together. + * + * A turn iterates: every hop upstream is a real request, and the client is + * billed for all of them. Adding only the last would under-report the turn by + * every search that preceded it. + * + * Fields present on either side are summed when both are numbers and taken + * from the right otherwise — a later response supersedes an earlier count for + * the same key rather than inventing a total from two partial readings. + */ +const asUsageRecord = (value: unknown): Record | null => + asRecord(value); + +const isPlainObject = (value: unknown): value is Record => + asRecord(value) !== null; + +export const sumUsage = (accumulated: unknown, incoming: unknown): unknown => { + const left = asUsageRecord(accumulated); + const right = asUsageRecord(incoming); + + if (!left) { + return incoming ?? null; + } + + if (!right) { + return accumulated; + } + + const merged: Record = { ...left }; + + for (const [key, value] of Object.entries(right)) { + const previous = left[key]; + + if (typeof value === 'number' && typeof previous === 'number') { + merged[key] = previous + value; + } else if (isPlainObject(value) && isPlainObject(previous)) { + // Nested blocks such as `prompt_tokens_details` are summed field by + // field. Taking the later hop's object instead would report the cache + // tokens of the last hop only, under-counting the turn. + merged[key] = sumUsage(previous, value); + } else if (value !== undefined) { + merged[key] = value; + } + } + + return merged; +}; diff --git a/tests/server/image-generation.test.ts b/tests/server/image-generation.test.ts index 84b4412..9c5f0c9 100644 --- a/tests/server/image-generation.test.ts +++ b/tests/server/image-generation.test.ts @@ -1478,6 +1478,75 @@ describe('Responses image support', () => { expect(text).toContain('event: response.web_search_call.completed'); }); + it('carries prose written before the search through the image path', async () => { + delete process.env.SEARXNG_URL; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); + const secret = await addCredentialWith(); + let chatCall = 0; + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('/v2/images/generations')) { + return makeImageResponse([{ b64_json: 'QUJD' }]); + } + // The search backend has to answer for an execution to be recorded. + if (url.includes('/agenttool/v1/search')) { + return makeImageResponse({ + results: [ + { + content: 'Current result', + title: 'News', + url: 'https://news.test', + }, + ], + }); + } + + chatCall += 1; + + return makeChatResponse( + chatCall === 1 + ? { + // Speaks before asking, so the turn has a preamble to carry. + content: 'Let me search for that first.', + tool_calls: [ + { + function: { + arguments: '{"query":"cats"}', + name: 'web_search', + }, + id: 'search_1', + type: 'function', + }, + { + function: { + arguments: '{"prompt":"a cat"}', + name: 'image_generation', + }, + id: 'call_1', + type: 'function', + }, + ], + } + : { content: 'Here it is.' }, + ); + }); + + const response = await handleResponsesRequest(makeRequest(secret), { + input: 'search then draw', + model: 'claude-sonnet-4.6', + stream: true, + tools: [{ type: 'image_generation' }, { type: 'web_search_preview' }], + } as never); + + const text = await response.text(); + + expect(text).toContain('Let me search for that first.'); + expect(text).toContain('event: response.web_search_call.completed'); + }); + it('replays a buffered stream whose turn produced no message', async () => { // No prose anywhere and a surviving client call on the capped hop means // the mapper emits no message item, so the replay has nothing to diff --git a/tests/server/server-tools.test.ts b/tests/server/server-tools.test.ts index a839d85..2f752e8 100644 --- a/tests/server/server-tools.test.ts +++ b/tests/server/server-tools.test.ts @@ -7,15 +7,16 @@ import { getForcedToolName, attachServerToolExecutions, getServerToolExecutions, - prepareServerToolTurn, hasAmbiguousServerToolName, hasExecutableServerTool, + readMaxUses, parseBufferedPayload, readBufferedChatCompletionPayload, resolveServerToolBackends, rewriteServerTools, runServerToolTurn, type ServerToolInvocation, + type ServerToolTurnOutcome, } from '@/lib/server/proxy/server-tools'; import { isEventStream } from '@/lib/server/shared/sse'; import { updateSettings } from '@/lib/server/domain/config'; @@ -185,12 +186,25 @@ describe('server tool classification', () => { }); describe('name collisions', () => { - it('flags a client function that collides with an injected server tool', () => { + it('does not treat the client’s WebSearch as a collision', () => { + // This pairing is Claude Code's normal shape in a single request, so + // calling it ambiguous disabled the server tool outright — the client + // then received a `web_search` tool_use it had no handler for. expect( hasAmbiguousServerToolName([ { type: SEARCH_TYPE, name: 'web_search' }, claudeCodeWebSearch, ]), + ).toBe(false); + }); + + it('flags a genuine name clash: the same name, two kinds', () => { + // Here the model really could not say which it meant, so neither runs. + expect( + hasAmbiguousServerToolName([ + { type: SEARCH_TYPE, name: 'web_search' }, + { name: 'web_search', input_schema: {} }, + ]), ).toBe(true); }); @@ -212,7 +226,7 @@ describe('server tool classification', () => { * way to say which it meant, so the call goes to the client rather than * being guessed at. */ - it('declines to execute either tool when the names collide', () => { + it('runs the server tool when the client’s own WebSearch is present', () => { const rewrite = rewriteServerTools({ declarations: { fetch: false, search: true }, fetchProvider: null, @@ -220,6 +234,31 @@ describe('server tool classification', () => { tools: [{ type: SEARCH_TYPE, name: 'web_search' }, claudeCodeWebSearch], }); + // The declared type already tells them apart, so the search runs and the + // client keeps its own tool. + expect(rewrite?.executable).toEqual({ fetch: false, search: true }); + expect(hasExecutableServerTool(rewrite!.executable)).toBe(true); + expect(rewrite?.classifyCall({ function: { name: 'web_search' } })).toBe( + 'web_search', + ); + // The client's own tool, left for it: it declared `WebSearch` itself, so + // the respelled-name fallback is deliberately not registered. + expect(rewrite?.classifyCall({ function: { name: 'WebSearch' } })).toBe( + null, + ); + }); + + it('declines both tools when the names are genuinely identical', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [ + { type: SEARCH_TYPE, name: 'web_search' }, + { name: 'web_search', input_schema: {} }, + ], + }); + expect(rewrite?.executable).toEqual({ fetch: false, search: false }); expect(hasExecutableServerTool(rewrite!.executable)).toBe(false); }); @@ -241,11 +280,9 @@ describe('server tool classification', () => { function: expect.objectContaining({ name: 'web_search' }), }, ]); - // Dropped from the follow-up, or the model could search again there. - expect(rewrite?.followUpTools).toEqual([]); }); - it('keeps a declaration with no backend callable for the client', () => { + it('withdraws a declaration no backend can run, rather than offering it', () => { const rewrite = rewriteServerTools({ declarations: { fetch: false, search: true }, fetchProvider: null, @@ -254,7 +291,10 @@ describe('server tool classification', () => { }); expect(rewrite?.executable).toEqual({ fetch: false, search: false }); - expect(rewrite?.followUpTools).toHaveLength(1); + // Offering the tool anyway would have the model call it and hand the + // client a `tool_use` for a name it declared as provider-executed and + // has no handler for: no search, and a turn it cannot complete. + expect(rewrite?.tools).toEqual([]); }); it('leaves a client function untouched in both tool lists', () => { @@ -269,15 +309,25 @@ describe('server tool classification', () => { }); // The client's own function is forwarded verbatim; only the server - // declaration is rewritten, and only the rewritten one is dropped from - // the follow-up. + // declaration is rewritten. + expect(rewrite?.tools[0]).toEqual({ + type: 'function', + function: expect.objectContaining({ name: 'web_search' }), + }); expect(rewrite?.tools[1]).toEqual({ name: 'Read', input_schema: {} }); - expect(rewrite?.followUpTools).toEqual([ - { name: 'Read', input_schema: {} }, - ]); }); - it('recognises the model’s own spelling of a call it injected', () => { + /** + * The regression, from the other side. `WebSearch` is the tool *Claude + * Code* declares and resolves itself; normalised it is indistinguishable + * from the server tool, so a loose match here is what let the proxy answer + * calls the client meant to handle. + * + * The names the proxy injected are its own, so an exact match is all that + * is needed — and a miss means the call is the client's, which is the safe + * direction to be wrong in. + */ + it('recognises its own calls, including when upstream respells them', () => { const rewrite = rewriteServerTools({ declarations: { fetch: true, search: true }, fetchProvider: makeFetchProvider(), @@ -288,39 +338,29 @@ describe('server tool classification', () => { ], }); - // Upstream echoes these back in camel case often enough to matter. + // Ours, exactly as handed to upstream. + expect( + rewrite?.isExecutableCall({ function: { name: 'web_search' } }), + ).toBe(true); + expect( + rewrite?.isExecutableCall({ function: { name: 'web_fetch' } }), + ).toBe(true); + + // Respelled by upstream. Safe to accept precisely because this request + // declares no client function of that name, so a call spelled `WebSearch` + // can only be our own tool coming back in another hand. expect( rewrite?.isExecutableCall({ function: { name: 'WebSearch' } }), ).toBe(true); expect( - rewrite?.isExecutableCall({ function: { name: 'WebFetch' } }), + rewrite?.isExecutableCall({ function: { name: 'Web Fetch' } }), ).toBe(true); + + // Still not ours. expect(rewrite?.isExecutableCall({ function: { name: 'Read' } })).toBe( false, ); }); - - it('does not claim a call when the tool has no backend', () => { - const rewrite = rewriteServerTools({ - declarations: { fetch: false, search: true }, - fetchProvider: null, - searchProvider: null, - tools: [{ type: SEARCH_TYPE, name: 'web_search' }], - }); - - expect( - rewrite?.isExecutableCall({ function: { name: 'web_search' } }), - ).toBe(false); - }); - }); - - describe('prepareServerToolTurn', () => { - it('declines when no provider-executed tool is declared', async () => { - await expect( - prepareServerToolTurn([claudeCodeWebSearch]), - ).resolves.toBeNull(); - await expect(prepareServerToolTurn(undefined)).resolves.toBeNull(); - }); }); describe('getForcedToolName', () => { @@ -346,7 +386,6 @@ describe('server tool classification', () => { const body = { messages: [{ role: 'user', content: 'when did it ship?' }], model: 'test-model', - stream: false, }; const makeRewrite = (searchProvider: WebSearchProvider | null) => @@ -373,13 +412,16 @@ describe('server tool turn', () => { fetchProvider: null, rewrite: makeRewrite(makeSearchProvider()), searchProvider: makeSearchProvider(), - stream: false, }); expect(callUpstream).toHaveBeenCalledTimes(1); expect(outcome.executions).toEqual([]); - // The answer it already wrote is the whole turn. - expect(outcome.preamble.text).toBe('Yesterday.'); + // It answered outright, so there is no preamble — the answer stays in the + // payload where the renderer will find it. + expect(outcome.preamble).toEqual({ reasoning: '', text: '' }); + expect((await outcome.response.json()).choices[0].message.content).toBe( + 'Yesterday.', + ); }); it('runs the search and asks upstream once more for the answer', async () => { @@ -402,7 +444,6 @@ describe('server tool turn', () => { fetchProvider: null, rewrite: makeRewrite(makeSearchProvider()), searchProvider: makeSearchProvider(), - stream: false, }); expect(callUpstream).toHaveBeenCalledTimes(2); @@ -434,7 +475,6 @@ describe('server tool turn', () => { fetchProvider: null, rewrite: makeRewrite(makeSearchProvider()), searchProvider: makeSearchProvider(), - stream: false, }); const followUp = sentBodies[1] as { messages: unknown[] }; @@ -446,7 +486,7 @@ describe('server tool turn', () => { }); }); - it('drops the executed tool so the follow-up cannot search again', async () => { + it('keeps the server tool callable so the model can refine its query', async () => { let calls = 0; const sentBodies: Record[] = []; const callUpstream = vi.fn(async (nextBody) => { @@ -464,10 +504,14 @@ describe('server tool turn', () => { fetchProvider: null, rewrite: makeRewrite(makeSearchProvider()), searchProvider: makeSearchProvider(), - stream: false, }); - expect((sentBodies[1] as { tools: unknown[] }).tools).toEqual([]); + // Still there: whether to search again is the model's call, not ours. + expect((sentBodies[1] as { tools: unknown[] }).tools).toEqual([ + expect.objectContaining({ + function: expect.objectContaining({ name: 'web_search' }), + }), + ]); }); it('relaxes a tool_choice that would force another search', async () => { @@ -488,13 +532,12 @@ describe('server tool turn', () => { fetchProvider: null, rewrite: makeRewrite(makeSearchProvider()), searchProvider: makeSearchProvider(), - stream: false, }); expect(sentBodies[1].tool_choice).toBe('auto'); }); - it('turns a forced server tool into no tool at all on the follow-up', async () => { + it('loosens a forced server tool to let the model choose', async () => { let calls = 0; const sentBodies: Record[] = []; const callUpstream = vi.fn(async (nextBody) => { @@ -515,10 +558,11 @@ describe('server tool turn', () => { fetchProvider: null, rewrite: makeRewrite(makeSearchProvider()), searchProvider: makeSearchProvider(), - stream: false, }); - expect(sentBodies[1].tool_choice).toBe('none'); + // Not `none`: the model may still want a second search, it just must not + // be compelled into one forever. + expect(sentBodies[1].tool_choice).toBe('auto'); }); it('hands a failed upstream call back untouched', async () => { @@ -532,7 +576,6 @@ describe('server tool turn', () => { fetchProvider: null, rewrite: makeRewrite(makeSearchProvider()), searchProvider: makeSearchProvider(), - stream: false, }); expect(callUpstream).toHaveBeenCalledTimes(1); @@ -558,7 +601,6 @@ describe('server tool turn', () => { onCall: (invocation) => invocations.push(invocation), rewrite: makeRewrite(makeSearchProvider()), searchProvider: makeSearchProvider(), - stream: false, }); expect(invocations).toEqual([ @@ -582,7 +624,6 @@ describe('server tool turn', () => { fetchProvider: null, rewrite: makeRewrite(makeSearchProvider()), searchProvider: makeSearchProvider(), - stream: false, }); // Not ours, so nothing runs and the call goes back exactly as it arrived. @@ -623,6 +664,7 @@ describe('server tool plumbing', () => { name: 'web_fetch', }, }, + 'web_fetch', 0, ), ).toEqual({ @@ -634,7 +676,11 @@ describe('server tool plumbing', () => { it('falls back to a positional id when the model sends none', () => { expect( - buildServerToolInvocation({ function: { name: 'web_search' } }, 3), + buildServerToolInvocation( + { function: { name: 'web_search' } }, + 'web_search', + 3, + ), ).toEqual({ id: 'server_tool_3', input: { query: '' }, @@ -850,7 +896,6 @@ describe('server tool plumbing', () => { fetchProvider: null, rewrite: makeRewrite(makeSearchProvider()), searchProvider: makeSearchProvider(), - stream: false, }); return sentBodies[1]?.tool_choice; @@ -882,7 +927,7 @@ describe('server tool plumbing', () => { describe('server tool edge cases', () => { it('builds a search invocation from a call with no name at all', () => { - expect(buildServerToolInvocation({}, 2)).toEqual({ + expect(buildServerToolInvocation({}, 'web_search', 2)).toEqual({ id: 'server_tool_2', input: { query: '' }, type: 'web_search', @@ -891,6 +936,7 @@ describe('server tool edge cases', () => { it('reports no executions when a turn ran but upstream sent no message', async () => { let calls = 0; + outcomeCalls = 0; const outcome = await runServerToolTurn({ body, callUpstream: async () => { @@ -903,7 +949,6 @@ describe('server tool edge cases', () => { fetchProvider: null, rewrite: makeRewrite(makeSearchProvider()), searchProvider: makeSearchProvider(), - stream: false, }); expect(outcome.executions).toEqual([]); @@ -928,7 +973,6 @@ describe('server tool edge cases', () => { fetchProvider: null, rewrite: makeRewrite(makeSearchProvider()), searchProvider: makeSearchProvider(), - stream: false, }); expect(outcome.executions).toHaveLength(1); @@ -955,7 +999,6 @@ describe('server tool edge cases', () => { fetchProvider: null, rewrite: makeRewrite(makeSearchProvider()), searchProvider: makeSearchProvider(), - stream: false, }); expect(sentBodies[1].tool_choice).toEqual({ type: 'auto' }); @@ -976,3 +1019,435 @@ describe('server tool edge cases', () => { expect(getServerToolExecutions(response)).toEqual([]); }); }); + +describe('budget and call-matching edges', () => { + it('falls back to the default budget when there is no tool list', () => { + expect(readMaxUses(undefined, 'web_search')).toBe(5); + expect(readMaxUses('not-an-array', 'web_fetch')).toBe(5); + }); + + it('ignores a declared budget that is not a usable number', () => { + const tools = [{ type: SEARCH_TYPE, name: 'web_search', max_uses: 'many' }]; + + expect(readMaxUses(tools, 'web_search')).toBe(5); + }); + + it('claims nothing for a call that has no name', () => { + const rewrite = makeRewrite(makeSearchProvider()); + + expect(rewrite?.classifyCall({})).toBeNull(); + expect(rewrite?.classifyCall({ function: {} })).toBeNull(); + }); +}); + +/** + * The searches have already run and been billed by the time the closing hop + * fails, so they must not vanish along with it. + */ +it('keeps the searches and the usage when the closing hop fails', async () => { + closingCalls = 0; + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => { + const hop = + closingCalls++ === 0 + ? assistantToolCall('web_search', '{"query":"q"}') + : { error: { message: 'rate limited' } }; + + return makeJsonResponse(hop, closingCalls === 1 ? 200 : 429); + }, + fetchProvider: null, + rewrite: rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [{ type: SEARCH_TYPE, name: 'web_search', max_uses: 1 }], + }), + searchProvider: makeSearchProvider(), + }); + + expect(outcome.executions).toHaveLength(1); + // Accrued before the failure, and the only record the client gets. + expect(outcome.usage).toEqual({ total_tokens: 10 }); + expect(getServerToolExecutions(outcome.response)).toHaveLength(1); +}); + +/** + * A hop that asks for a server tool *and* something the client owns. The + * search runs, but the turn cannot continue here — the client has to answer + * its own call first — so the findings go back with that call outstanding. + */ +it('runs the search but hands a client call back unresolved', async () => { + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => + makeJsonResponse({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: 'Let me check that file first.', + role: 'assistant', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + arguments: '{"query":"q"}', + name: 'web_search', + }, + }, + { + id: 'call_2', + type: 'function', + function: { arguments: '{}', name: 'Read' }, + }, + ], + }, + }, + ], + }), + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + }); + + // One search ran, and the client's call survives for it to answer. + expect(outcome.executions).toHaveLength(1); + expect(outcome.preamble.text).toBe('Let me check that file first.'); + + const payload = (await outcome.response.json()) as { + choices: Array<{ + finish_reason: string | null; + message: { + content: string | null; + tool_calls?: Array<{ function?: { name?: string } }>; + }; + }>; + }; + + expect( + payload.choices[0]?.message.tool_calls?.map((c) => c.function?.name), + ).toEqual(['Read']); + expect(payload.choices[0]?.finish_reason).toBe('tool_calls'); + // The prose travels as the preamble, not duplicated on the payload. + expect(payload.choices[0]?.message.content).toBeNull(); +}); + +it('copes with a hop that carries no message at all', async () => { + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => makeJsonResponse({ choices: [{}] }), + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + }); + + // No call to answer and no prose to keep: the hop is the turn. + expect(outcome.executions).toEqual([]); + expect(outcome.preamble).toEqual({ reasoning: '', text: '' }); +}); + +describe('server tool loop', () => { + /** Answers each hop in turn, so a test can script a multi-hop model. */ + const scripted = ( + hops: Array>, + maxUses = 5, + ): { + run: () => Promise; + sentBodies: () => ChatRequestBody[]; + } => { + const sentBodies: ChatRequestBody[] = []; + let calls = 0; + + const run = (): Promise => + runServerToolTurn({ + body, + callUpstream: async (nextBody) => { + sentBodies.push(nextBody); + const hop = hops[Math.min(calls, hops.length - 1)]; + calls += 1; + + return makeJsonResponse(hop); + }, + fetchProvider: null, + rewrite: rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [{ type: SEARCH_TYPE, name: 'web_search', max_uses: maxUses }], + })!, + searchProvider: makeSearchProvider(), + }); + + return { run, sentBodies: () => sentBodies }; + }; + + const ask = (query: string, id = 'call_1') => + assistantToolCall('web_search', `{"query":"${query}"}`, id); + + /** + * The point of a server tool: the model refines its query and searches + * again, all inside the one request the client sent. + */ + it('searches again when the model is not satisfied', async () => { + const { run, sentBodies } = scripted([ + ask('quantum computing', 'call_1'), + ask('IBM quantum 2026', 'call_2'), + { + choices: [ + { finish_reason: 'stop', message: { content: 'Here it is.' } }, + ], + }, + ]); + + const outcome = await run(); + + expect(sentBodies()).toHaveLength(3); + expect(outcome.executions).toHaveLength(2); + expect( + outcome.executions.map((execution) => + execution.type === 'web_search' ? execution.input.query : '', + ), + ).toEqual(['quantum computing', 'IBM quantum 2026']); + // Every search plus the answer is one turn from the client's side. + expect((await outcome.response.json()).choices[0].message.content).toBe( + 'Here it is.', + ); + }); + + it('grows the transcript by one tool result per search', async () => { + const { run, sentBodies } = scripted([ + ask('one', 'call_1'), + ask('two', 'call_2'), + { choices: [{ finish_reason: 'stop', message: { content: 'done' } }] }, + ]); + + await run(); + + // user, assistant+tool, assistant+tool + expect(sentBodies()[1].messages).toHaveLength(3); + expect(sentBodies()[2].messages).toHaveLength(5); + expect( + (sentBodies()[2].messages as Array<{ tool_call_id?: string }>).map( + (message) => message.tool_call_id, + ), + // user, assistant(tool_calls), tool(call_1), assistant(tool_calls), tool(call_2) + ).toEqual([undefined, undefined, 'call_1', undefined, 'call_2']); + }); + + /** + * `max_uses` bounds the turn, not the hop. Once it is spent the server tools + * are withdrawn for one last call so the model answers with what it has + * instead of asking for a search it will not get. + */ + it('stops at max_uses and asks once more without the server tool', async () => { + const { run, sentBodies } = scripted( + [ask('one', 'call_1'), ask('two', 'call_2'), ask('three', 'call_3')], + 2, + ); + + const outcome = await run(); + + expect(outcome.executions).toHaveLength(2); + // The closing call has no server tool left to call. + expect((sentBodies()[2] as { tools: unknown[] }).tools).toEqual([]); + // Nothing is left to choose from, so no tool_choice is sent at all: naming + // a tool that is not on offer is a contradiction some upstreams reject. + expect(sentBodies()[2].tool_choice).toBeUndefined(); + }); + + it('reports usage for every hop, not just the last', async () => { + const hops = [ + { ...ask('one', 'call_1'), usage: { total_tokens: 100 } }, + { ...ask('two', 'call_2'), usage: { total_tokens: 240 } }, + { + choices: [{ finish_reason: 'stop', message: { content: 'done' } }], + usage: { total_tokens: 80 }, + }, + ]; + + const outcome = await scripted(hops).run(); + + // Under-reporting the turn by every search that preceded the answer would + // bill the client for one hop out of three. + expect(outcome.usage).toEqual({ total_tokens: 420 }); + expect((await outcome.response.json()).usage).toEqual({ + total_tokens: 420, + }); + }); + + it('hands a failed hop back with the usage accrued so far', async () => { + const { run } = scripted([ + { ...ask('one', 'call_1'), usage: { total_tokens: 100 } }, + { error: { message: 'rate limited' } }, + ]); + + const outcome = await run(); + + expect(outcome.response.status).toBe(200); + expect(outcome.usage).toEqual({ total_tokens: 100 }); + }); +}); + +/** + * These pin the behaviours that were fixed last and had no test at all: a + * client-declared `max_uses` per tool kind, nested usage blocks, a client + * pinning its *own* tool through `tool_choice`, and the id pairing between an + * assistant tool call and its result. + */ +let outcomeCalls = 0; +let closingCalls = 0; + +describe('server tool budgets and wire shape', () => { + const bothDeclarations = (searchUses: number, fetchUses: number) => + rewriteServerTools({ + declarations: { fetch: true, search: true }, + fetchProvider: makeFetchProvider(), + searchProvider: makeSearchProvider(), + tools: [ + { type: SEARCH_TYPE, name: 'web_search', max_uses: searchUses }, + { type: FETCH_TYPE, name: 'web_fetch', max_uses: fetchUses }, + ], + }); + + it('reads max_uses per declared tool, not one merged number', () => { + const rewrite = bothDeclarations(8, 2); + + // The fetch's budget used to cap the searches too. + expect(rewrite?.maxUses).toEqual({ web_fetch: 2, web_search: 8 }); + }); + + it('clamps an absurd declared budget', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [{ type: SEARCH_TYPE, name: 'web_search', max_uses: 5000 }], + }); + + // Every use is a sequential upstream round trip, so this has to be bounded. + expect(rewrite?.maxUses.web_search).toBeLessThanOrEqual(20); + }); + + it('leaves a tool_choice naming the client’s own WebSearch alone', async () => { + const sentBodies: ChatRequestBody[] = []; + let calls = 0; + + await runServerToolTurn({ + body: { + ...body, + tools: [ + { type: SEARCH_TYPE, name: 'web_search', max_uses: 8 }, + { name: 'WebSearch', input_schema: {}, type: 'function' }, + ], + tool_choice: { type: 'function', function: { name: 'WebSearch' } }, + } as never, + callUpstream: async (nextBody) => { + sentBodies.push(nextBody); + calls += 1; + + return calls === 1 + ? makeJsonResponse(assistantToolCall('web_search', '{"query":"q"}')) + : makeJsonResponse({ choices: [] }); + }, + fetchProvider: null, + // The same tool set: the client's `WebSearch` is what makes the respelled + // spelling unsafe to claim, so it has to be part of the rewrite too. + rewrite: rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [ + { type: SEARCH_TYPE, name: 'web_search', max_uses: 8 }, + { name: 'WebSearch', input_schema: {}, type: 'function' }, + ], + }), + searchProvider: makeSearchProvider(), + }); + + // Loosening this would stop the model calling the tool the client pinned. + expect(sentBodies[1]?.tool_choice).toEqual({ + type: 'function', + function: { name: 'WebSearch' }, + }); + }); + + it('pairs each assistant tool call with its result, id included', async () => { + let calls = 0; + const sent: ChatRequestBody[] = []; + + await runServerToolTurn({ + body, + callUpstream: async (nextBody) => { + sent.push(nextBody); + calls += 1; + + return calls === 1 + ? makeJsonResponse({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + role: 'assistant', + // No id: this is the case the fallback exists for. + tool_calls: [ + { + type: 'function', + function: { + arguments: '{"query":"q"}', + name: 'web_search', + }, + }, + ], + }, + }, + ], + }) + : makeJsonResponse({ choices: [] }); + }, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + }); + + const assistant = sent[1]?.messages?.find( + (message: { role?: string }) => message.role === 'assistant', + ) as { tool_calls?: Array<{ id?: string }> } | undefined; + const tool = sent[1]?.messages?.find( + (message: { role?: string }) => message.role === 'tool', + ) as { tool_call_id?: string } | undefined; + + // An assistant call with no id behind it cannot be paired by upstream. + expect(assistant?.tool_calls?.[0]?.id).toBeTruthy(); + expect(tool?.tool_call_id).toBe(assistant?.tool_calls?.[0]?.id); + }); + + it('sums nested usage blocks across hops', async () => { + const nested = (cached: number) => ({ + prompt_tokens: 10, + prompt_tokens_details: { cached_tokens: cached }, + }); + + outcomeCalls = 0; + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => { + const call = assistantToolCall('web_search', '{"query":"q"}'); + const hop = outcomeCalls++ === 0 ? call : { choices: [] }; + + return makeJsonResponse({ ...hop, usage: nested(5) }); + }, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + }); + + // The nested block used to be replaced by the last hop's, halving it. + expect(outcome.usage).toEqual({ + prompt_tokens: 20, + prompt_tokens_details: { cached_tokens: 10 }, + }); + }); +}); diff --git a/tests/server/web-search.test.ts b/tests/server/web-search.test.ts index 11b060c..b068571 100644 --- a/tests/server/web-search.test.ts +++ b/tests/server/web-search.test.ts @@ -499,7 +499,6 @@ describe('server local web search', () => { tools: [{ type: 'web_search_preview' }], })!, searchProvider: resolveSearchProvider('searxng'), - stream: false, }); if (!fetchMock.mock.calls.length) { @@ -892,6 +891,482 @@ describe('server tool routing', () => { }); }); + /** + * These go through `handleMessagesRequest`, not straight into + * `rewriteServerTools`: the spec's phase-2 shape only ever arrives as an + * Anthropic request, and translation is where `max_uses` and `tool_choice` + * used to be dropped — which no unit test could see. + */ + describe('phase 2: the side request, end to end', () => { + /** Answers each hop from `hops`, falling through on the last. */ + const routed = ( + hops: Array>, + request: Record, + ) => { + const sent: Array> = []; + let calls = 0; + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { + const url = String(_input); + + if (url.includes('searx.test')) { + return makeJsonResponse({ + results: [ + { content: 'A snippet', title: 'Docs', url: 'https://docs.test' }, + ], + }) as unknown as Response; + } + + const body = JSON.parse(String(init?.body)) as Record; + sent.push(body); + const hop = hops[Math.min(calls, hops.length - 1)]; + calls += 1; + + return makeJsonResponse(hop) as unknown as Response; + }); + + return { + calls: () => calls, + run: () => + handleMessagesRequest( + makeRequest('http://localhost/v1/messages'), + request, + ), + sent, + }; + }; + + const searchCall = (query: string, id: string) => ({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + role: 'assistant', + tool_calls: [ + { + id, + type: 'function', + function: { + arguments: `{"query":"${query}"}`, + name: 'web_search', + }, + }, + ], + }, + }, + ], + usage: { completion_tokens: 10, prompt_tokens: 100 }, + }); + + const answer = (text: string, reasoning?: string) => ({ + choices: [ + { + finish_reason: 'stop', + message: { + content: text, + ...(reasoning ? { reasoning_content: reasoning } : {}), + role: 'assistant', + }, + }, + ], + usage: { completion_tokens: 20, prompt_tokens: 200 }, + }); + + const sideRequest = (maxUses?: number) => ({ + max_tokens: 2048, + messages: [ + { + role: 'user' as const, + content: 'Perform a web search for the query: OpenAI updates 2026', + }, + ], + tool_choice: { type: 'tool', name: 'web_search' }, + tools: [ + { + type: 'web_search_20250305', + name: 'web_search', + ...(maxUses === undefined ? {} : { max_uses: maxUses }), + input_schema: {}, + }, + ], + }); + + it('honours the forced tool_choice, then loosens it so the model may answer', async () => { + await enableSearch(); + const { run, sent } = routed( + [searchCall('OpenAI updates 2026', 'call_1'), answer('Here it is.')], + sideRequest(8), + ); + + const response = await run(); + + expect(sent[0]?.tool_choice).toEqual({ + type: 'function', + function: { name: 'web_search' }, + }); + // Left pinned, the model would be forced to search forever. + expect(sent[1]?.tool_choice).toBe('auto'); + // The server tool stays available: refusals to answer are the model's call. + expect((sent[1]?.tools as unknown[]) ?? []).toHaveLength(1); + + const payload = (await response.json()) as { + content: Array<{ type: string }>; + stop_reason: string; + }; + expect(payload.content.map((block) => block.type)).toEqual([ + 'server_tool_use', + 'web_search_tool_result', + 'text', + ]); + expect(payload.stop_reason).toBe('end_turn'); + }); + + it('streams prose written before the search, ahead of the search blocks', async () => { + await enableSearch(); + const { run } = routed( + [ + { + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: 'Let me look that up.', + reasoning_content: 'The user wants recent news.', + role: 'assistant', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + arguments: '{"query":"OpenAI updates 2026"}', + name: 'web_search', + }, + }, + ], + }, + }, + ], + usage: { completion_tokens: 10, prompt_tokens: 100 }, + }, + answer('Here it is.'), + ], + { ...sideRequest(8), stream: true }, + ); + + const response = await run(); + const events = await readEvents(response); + const types = events + .filter((event) => event.event === 'content_block_start') + .map( + (event) => + (JSON.parse(event.data) as { content_block: { type: string } }) + .content_block.type, + ); + + // What was written before the search, then the search, then the answer. + expect(types).toEqual([ + 'thinking', + 'text', + 'server_tool_use', + 'web_search_tool_result', + 'text', + ]); + expect(JSON.stringify(events)).toContain('Let me look that up.'); + expect(JSON.stringify(events)).toContain('Here it is.'); + }); + + it('renders the answer’s own reasoning after the search blocks', async () => { + await enableSearch(); + const { run } = routed( + [ + { + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + role: 'assistant', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + arguments: '{"query":"OpenAI updates 2026"}', + name: 'web_search', + }, + }, + ], + }, + }, + ], + usage: { completion_tokens: 10, prompt_tokens: 100 }, + }, + answer('Here it is.', 'The results answer it.'), + ], + sideRequest(8), + ); + + const payload = (await run().then((r) => r.json())) as { + content: Array<{ thinking?: string; type: string }>; + }; + + // The reasoning that produced the answer belongs after the searches it + // followed, not alongside the one that asked for them. + expect(payload.content.map((block) => block.type)).toEqual([ + 'server_tool_use', + 'web_search_tool_result', + 'thinking', + 'text', + ]); + expect(payload.content[2].thinking).toBe('The results answer it.'); + }); + + it('renders prose written before the search, ahead of the search blocks', async () => { + await enableSearch(); + const { run } = routed( + [ + { + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: 'Let me look that up.', + reasoning_content: 'The user wants recent news.', + role: 'assistant', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + arguments: '{"query":"OpenAI updates 2026"}', + name: 'web_search', + }, + }, + ], + }, + }, + ], + usage: { completion_tokens: 10, prompt_tokens: 100 }, + }, + answer('Here it is.'), + ], + sideRequest(8), + ); + + const payload = (await run().then((r) => r.json())) as { + content: Array<{ text?: string; thinking?: string; type: string }>; + }; + + // What was written before the search, then the search, then the answer. + expect(payload.content.map((block) => block.type)).toEqual([ + 'thinking', + 'text', + 'server_tool_use', + 'web_search_tool_result', + 'text', + ]); + expect(payload.content[0].thinking).toBe('The user wants recent news.'); + expect(payload.content[1].text).toBe('Let me look that up.'); + expect(payload.content[4].text).toBe('Here it is.'); + }); + + it('bills the whole turn, not just the answering hop', async () => { + await enableSearch(); + const { run } = routed( + [searchCall('OpenAI updates 2026', 'call_1'), answer('Here it is.')], + sideRequest(8), + ); + + const response = await run(); + const payload = (await response.json()) as { + stop_reason: string; + usage: { + input_tokens: number; + output_tokens: number; + server_tool_use: { web_search_requests: number }; + }; + }; + + // 100+200 prompt and 10+20 completion, both hops. + expect(payload.usage.input_tokens).toBe(300); + expect(payload.usage.output_tokens).toBe(30); + expect(payload.usage.server_tool_use.web_search_requests).toBe(1); + }); + + it('stops searching at the max_uses the client declared', async () => { + await enableSearch(); + const { run, calls } = routed( + [ + searchCall('one', 'call_1'), + searchCall('two', 'call_2'), + searchCall('three', 'call_3'), + ], + sideRequest(2), + ); + + const response = await run(); + const payload = (await response.json()) as { + content: Array<{ type: string }>; + stop_reason: string; + usage: { server_tool_use: { web_search_requests: number } }; + }; + + expect(payload.usage.server_tool_use.web_search_requests).toBe(2); + // Two searches, then the closing call with the tool withdrawn, then stop. + expect(calls()).toBe(3); + // Nothing leaks: the client never declared a `web_search` function. + expect(payload.content.map((block) => block.type)).not.toContain( + 'tool_use', + ); + expect(payload.stop_reason).toBe('end_turn'); + }); + + it('searches repeatedly and reports every search', async () => { + await enableSearch(); + const { run, calls } = routed( + [ + searchCall('one', 'call_1'), + searchCall('two', 'call_2'), + answer('Done.'), + ], + sideRequest(8), + ); + + const response = await run(); + const payload = (await response.json()) as { + content: Array<{ type: string }>; + usage: { server_tool_use: { web_search_requests: number } }; + }; + + expect(calls()).toBe(3); + expect(payload.usage.server_tool_use.web_search_requests).toBe(2); + expect(payload.content.map((block) => block.type)).toEqual([ + 'server_tool_use', + 'web_search_tool_result', + 'server_tool_use', + 'web_search_tool_result', + 'text', + ]); + }); + + it('runs the server tool while leaving the client’s own WebSearch alone', async () => { + await enableSearch(); + const { run } = routed( + [searchCall('OpenAI updates 2026', 'call_1'), answer('Here it is.')], + { + max_tokens: 2048, + messages: [{ role: 'user', content: 'lookup' }], + tools: [ + { + type: 'web_search_20250305', + name: 'web_search', + max_uses: 8, + input_schema: {}, + }, + { + name: 'WebSearch', + description: 'Search the web', + input_schema: { type: 'object' }, + }, + ], + }, + ); + + const response = await run(); + const payload = (await response.json()) as { + usage: { server_tool_use: { web_search_requests: number } }; + }; + + // The client declaring WebSearch used to look like a name collision and + // switch the whole feature off. + expect(payload.usage.server_tool_use.web_search_requests).toBe(1); + }); + }); + + /** + * Phase 3: Claude Code wraps the side-request answer as a `tool_result` for + * its own `WebSearch` and carries on. This must not re-enter the server-tool + * path — and it is the shape the whole flow exists to serve. + */ + it('serves the main agent continuation without searching again', async () => { + await enableSearch(); + let calls = 0; + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('searx.test')) { + throw new Error('the continuation must not search'); + } + + calls += 1; + + return makeJsonResponse({ + choices: [ + { + finish_reason: 'stop', + message: { content: 'OpenAI 最近主要有这些更新。' }, + }, + ], + }) as unknown as Response; + }); + + const response = await handleMessagesRequest( + makeRequest('http://localhost/v1/messages'), + { + max_tokens: 2048, + messages: [ + { role: 'user', content: '帮我查一下 OpenAI 最近有什么更新' }, + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'toolu_search_001', + name: 'WebSearch', + input: { query: 'OpenAI latest updates 2026' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_search_001', + content: '根据搜索结果,OpenAI 最近……', + }, + ], + }, + ], + tools: [ + { + name: 'WebSearch', + description: 'Search the web', + input_schema: { type: 'object' }, + }, + { + name: 'Read', + description: 'read', + input_schema: { type: 'object' }, + }, + ], + }, + ); + + const payload = (await response.json()) as { + content: Array<{ type: string }>; + stop_reason: string; + }; + + expect(calls).toBe(1); + expect(payload.content).toEqual([ + { type: 'text', text: 'OpenAI 最近主要有这些更新。' }, + ]); + expect(payload.stop_reason).toBe('end_turn'); + }); + describe('/v1/responses', () => { it('reports the search as a web_search_call item', async () => { await enableSearch(); @@ -923,6 +1398,291 @@ describe('server tool routing', () => { }); }); + /** + * The preamble is the prose a model writes before it searches. It was + * repositioned three times and had no test at all: this pins both halves — + * that it is emitted, and that it lands *before* the searches it preceded. + */ + it('puts a Responses preamble ahead of the searches, and streams the answer', async () => { + await enableSearch(); + let calls = 0; + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('searx.test')) { + return makeJsonResponse({ + results: [ + { content: 'snippet', title: 'Docs', url: 'https://docs.test' }, + ], + }) as unknown as Response; + } + + calls += 1; + + return makeJsonResponse( + calls === 1 + ? { + choices: [ + { + finish_reason: 'tool_calls', + message: { + // Speaks first, then asks — the case the preamble is for. + content: 'Let me look that up.', + reasoning_content: 'The user wants recent news.', + role: 'assistant', + tool_calls: [ + { + id: 'c1', + type: 'function', + function: { + arguments: '{"query":"OpenAI updates"}', + name: 'web_search', + }, + }, + ], + }, + }, + ], + } + : { + choices: [ + { + finish_reason: 'stop', + message: { + content: 'Here is what I found.', + role: 'assistant', + }, + }, + ], + }, + ) as unknown as Response; + }); + + const response = await handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + { + input: 'any news on OpenAI?', + model: 'glm-5.1', + tools: [{ type: 'web_search_preview' }], + }, + ); + + const payload = (await response.json()) as { + output: Array<{ content?: Array<{ text?: string }>; type: string }>; + output_text: string; + }; + + expect(payload.output.map((item) => item.type)).toEqual([ + 'reasoning', + 'message', + 'web_search_call', + 'message', + ]); + // The preamble, then the search, then the answer — in the order written. + expect(payload.output[1].content?.[0]?.text).toBe('Let me look that up.'); + expect(payload.output[3].content?.[0]?.text).toBe( + 'Here is what I found.', + ); + // Not the preamble: a delta-subscribing client must get the answer. + expect(payload.output_text).toBe('Here is what I found.'); + }); + + it('streams a Responses preamble ahead of the searches', async () => { + await enableSearch(); + let calls = 0; + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('searx.test')) { + return makeJsonResponse({ + results: [ + { content: 'snippet', title: 'Docs', url: 'https://docs.test' }, + ], + }) as unknown as Response; + } + + calls += 1; + + return makeJsonResponse( + calls === 1 + ? { + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: 'Let me look that up.', + reasoning_content: 'The user wants recent news.', + role: 'assistant', + tool_calls: [ + { + id: 'c1', + type: 'function', + function: { + arguments: '{"query":"OpenAI updates"}', + name: 'web_search', + }, + }, + ], + }, + }, + ], + } + : { + choices: [ + { + finish_reason: 'stop', + message: { + content: 'Here is what I found.', + role: 'assistant', + }, + }, + ], + }, + ) as unknown as Response; + }); + + const response = await handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + { + input: 'any news on OpenAI?', + model: 'glm-5.1', + stream: true, + tools: [{ type: 'web_search_preview' }], + }, + ); + + const text = await response.text(); + + // One opening, under one id. + expect(text.match(/"type":"response\.created"/g)).toHaveLength(1); + expect(text).toContain('Let me look that up.'); + expect(text).toContain('Here is what I found.'); + // The answer is what streams as text deltas, not the preamble. + expect(text).toContain('"delta":"Here is what I found."'); + expect(text).toContain('"type":"response.completed"'); + }); + + it('reports the upstream’s own message when a streamed turn fails', async () => { + await enableSearch(); + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('searx.test')) { + return makeJsonResponse({ results: [] }) as unknown as Response; + } + + return new Response( + JSON.stringify({ error: { message: 'rate limited upstream' } }), + { headers: { 'Content-Type': 'application/json' }, status: 429 }, + ) as unknown as Response; + }); + + const response = await handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + { + input: 'any news?', + model: 'glm-5.1', + stream: true, + tools: [{ type: 'web_search_preview' }], + }, + ); + + const text = await response.text(); + + // A rate limit has to arrive as one, or a client that retries on that + // alone stops retrying. + expect(text).toContain('rate limited upstream'); + expect(text).toContain('"type":"response.error"'); + }); + + /** + * `web_fetch` end to end. The Responses item for a fetch is still a + * `web_search_call` but carries an `open_page` action — the branch that + * distinguishes the two had no coverage at all. + */ + it('reports a fetch as a web_search_call with an open_page action', async () => { + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); + + let calls = 0; + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('/agenttool/v1/webfetch')) { + return makeJsonResponse({ + content: 'the page says hello', + url: 'https://docs.test/page', + }) as unknown as Response; + } + + calls += 1; + + return makeJsonResponse( + calls === 1 + ? { + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + role: 'assistant', + tool_calls: [ + { + id: 'f1', + type: 'function', + function: { + arguments: '{"url":"https://docs.test/page"}', + name: 'web_fetch', + }, + }, + ], + }, + }, + ], + } + : { + choices: [ + { + finish_reason: 'stop', + message: { + content: 'The page says hello.', + role: 'assistant', + }, + }, + ], + }, + ) as unknown as Response; + }); + + const response = await handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + { + input: 'what does that page say?', + model: 'glm-5.1', + tools: [{ type: 'web_fetch_20250910', name: 'web_fetch' }], + }, + ); + + const payload = (await response.json()) as { + output: Array<{ + action?: { type?: string; url?: string }; + type: string; + }>; + }; + const item = payload.output.find( + (entry) => entry.type === 'web_search_call', + ); + + expect(item).toBeDefined(); + expect(item?.action?.type).toBe('open_page'); + expect(item?.action?.url).toBe('https://docs.test/page'); + }); + it('leaves a client function named web_search to the client', async () => { await enableSearch(); const { upstreamCalls } = mockUpstream(); From ddf9604ebbcc94865017da611ed5f8a01879108c Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Fri, 18 Sep 2026 09:41:04 +0800 Subject: [PATCH 4/9] test(server-tools): drop the fetch probe and make CI failures visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `web_fetch` end-to-end probe raced a real local fetch behind the mocked endpoint call — `local-fetch` uses `node:http`, so the stub the setup file installs on `globalThis.fetch` did not apply and the test resolved a real hostname. That is why it passed locally and failed on CI, and it bought nothing: the branches it targeted are not in this change's diff, so patch coverage is unmoved. `test:ci` now also runs the default reporter alongside the JUnit one. The JUnit reporter writes failures to a file rather than stdout, which is how a red CI run ended up with no explanation in the log. --- package.json | 2 +- tests/server/web-search.test.ts | 85 --------------------------------- 2 files changed, 1 insertion(+), 86 deletions(-) diff --git a/package.json b/package.json index 49426ec..5dceae1 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "test": "vitest run", "commitlint": "commitlint", "lint-staged": "lint-staged", - "test:ci": "vitest run --coverage --reporter=junit --outputFile=test-report.junit.xml", + "test:ci": "vitest run --coverage --reporter=default --reporter=junit --outputFile=test-report.junit.xml", "test:coverage": "vitest run --coverage", "test:patch-branches": "bun scripts/check-patch-branches.ts" }, diff --git a/tests/server/web-search.test.ts b/tests/server/web-search.test.ts index b068571..2e87591 100644 --- a/tests/server/web-search.test.ts +++ b/tests/server/web-search.test.ts @@ -1598,91 +1598,6 @@ describe('server tool routing', () => { expect(text).toContain('"type":"response.error"'); }); - /** - * `web_fetch` end to end. The Responses item for a fetch is still a - * `web_search_call` but carries an `open_page` action — the branch that - * distinguishes the two had no coverage at all. - */ - it('reports a fetch as a web_search_call with an open_page action', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - - let calls = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/webfetch')) { - return makeJsonResponse({ - content: 'the page says hello', - url: 'https://docs.test/page', - }) as unknown as Response; - } - - calls += 1; - - return makeJsonResponse( - calls === 1 - ? { - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: null, - role: 'assistant', - tool_calls: [ - { - id: 'f1', - type: 'function', - function: { - arguments: '{"url":"https://docs.test/page"}', - name: 'web_fetch', - }, - }, - ], - }, - }, - ], - } - : { - choices: [ - { - finish_reason: 'stop', - message: { - content: 'The page says hello.', - role: 'assistant', - }, - }, - ], - }, - ) as unknown as Response; - }); - - const response = await handleResponsesRequest( - makeRequest('http://localhost/v1/responses'), - { - input: 'what does that page say?', - model: 'glm-5.1', - tools: [{ type: 'web_fetch_20250910', name: 'web_fetch' }], - }, - ); - - const payload = (await response.json()) as { - output: Array<{ - action?: { type?: string; url?: string }; - type: string; - }>; - }; - const item = payload.output.find( - (entry) => entry.type === 'web_search_call', - ); - - expect(item).toBeDefined(); - expect(item?.action?.type).toBe('open_page'); - expect(item?.action?.url).toBe('https://docs.test/page'); - }); - it('leaves a client function named web_search to the client', async () => { await enableSearch(); const { upstreamCalls } = mockUpstream(); From be682a98d0a20ca25b3133f44ff65605a58c21d7 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Fri, 18 Sep 2026 10:19:28 +0800 Subject: [PATCH 5/9] fix(server-tools): interleave each hop's prose with its searches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the Codex review findings on the PR. Prose written *between* two searches was dropped. A single preamble can only hold the first hop's, so a turn like text → search → text → search → answer lost the second passage entirely. The turn now reports one segment per hop — the prose that preceded it plus the calls it made — and every renderer interleaves them the way Anthropic does, instead of gathering prose and blocks by kind. Also from the review: - Streamed preambles went out whole inside `content_block_start`. Streaming consumers read generated text from `text_delta` / `thinking_delta`, so a block that starts already complete reaches no delta callback at all. They are now emitted as an empty start, a delta, then a stop. - A streamed `server_tool_use` carried the full input *and* streamed it again as an `input_json_delta`. Anthropic builds streamed input from deltas alone, so the block now opens with `input: {}`. - Withdrawing a server tool nothing here can run left a forced `tool_choice` naming it, which an upstream validating the two together can reject instead of letting the model answer. A forced choice is now dropped whenever the tool it names is not on offer. --- lib/server/proxy/anthropic.ts | 13 ++- lib/server/proxy/anthropic/response.ts | 39 +++----- lib/server/proxy/anthropic/stream.ts | 97 +++++++++++------- lib/server/proxy/responses.ts | 30 +++--- lib/server/proxy/responses/event-stream.ts | 28 +++--- lib/server/proxy/responses/payload.ts | 80 ++++++++------- lib/server/proxy/server-tools/classify.ts | 24 +++++ lib/server/proxy/server-tools/index.ts | 2 + lib/server/proxy/server-tools/turn.ts | 26 ++--- lib/server/proxy/server-tools/types.ts | 23 ++++- tests/server/search-providers.test.ts | 9 ++ tests/server/server-tools.test.ts | 15 ++- tests/server/web-search.test.ts | 109 +++++++++++++++++++++ 13 files changed, 358 insertions(+), 137 deletions(-) diff --git a/lib/server/proxy/anthropic.ts b/lib/server/proxy/anthropic.ts index 90a4b81..235956d 100644 --- a/lib/server/proxy/anthropic.ts +++ b/lib/server/proxy/anthropic.ts @@ -26,6 +26,7 @@ import { import { hasExecutableServerTool, prepareServerToolTurn, + reconcileToolChoice, runServerToolTurn, } from './server-tools'; @@ -108,7 +109,7 @@ export const handleMessagesRequest = async ( return createAnthropicServerToolEventStream({ model, runTurn }); } - const { executions, preamble, response } = await runTurn(); + const { executions, response, segments } = await runTurn(); if (!response.ok) { return createAnthropicError( @@ -120,7 +121,7 @@ export const handleMessagesRequest = async ( const payload = (await response.json()) as OpenAIChatResponse; return Response.json( - mapOpenAIResponseToAnthropic(payload, model, executions, preamble), + mapOpenAIResponseToAnthropic(payload, model, executions, segments), ); } @@ -129,7 +130,13 @@ export const handleMessagesRequest = async ( // tool is declared but nothing on this deployment can execute it, the // declaration still has to be rewritten, or upstream is sent a // `web_search_20250305` type it has never heard of. - { ...chatBody, tools: upstreamTools } as ChatRequestBody, + { + ...chatBody, + tools: upstreamTools, + // A server tool nothing here can run is withdrawn from `tools`, so a + // choice forcing it has to go too. + tool_choice: reconcileToolChoice(chatBody.tool_choice, upstreamTools), + } as ChatRequestBody, Boolean(body.stream), ); diff --git a/lib/server/proxy/anthropic/response.ts b/lib/server/proxy/anthropic/response.ts index c69e02c..64ed241 100644 --- a/lib/server/proxy/anthropic/response.ts +++ b/lib/server/proxy/anthropic/response.ts @@ -4,7 +4,7 @@ import type { OpenAIChatResponse, OpenAIUsage, } from './types'; -import type { ServerToolExecution, ServerToolPreamble } from '../server-tools'; +import type { ServerToolExecution, ServerToolSegment } from '../server-tools'; // --------------------------------------------------------------------------- // Response translation: OpenAI → Anthropic (non-streaming) @@ -131,29 +131,22 @@ export const buildThinkingBlock = ( * asked for it, and put the conclusion before its evidence. */ export const buildAnthropicServerToolTurnBlocks = ( - preamble: ServerToolPreamble, - executions: ServerToolExecution[], -): AnthropicContentBlock[] => { - const blocks: AnthropicContentBlock[] = []; - - if (preamble.reasoning) { - blocks.push(buildThinkingBlock(preamble.reasoning)); - } - - if (preamble.text) { - blocks.push({ type: 'text', text: preamble.text }); - } - - blocks.push(...buildAllAnthropicServerToolBlocks(executions)); - - return blocks; -}; + segments: ServerToolSegment[], +): AnthropicContentBlock[] => + // Interleaved, not gathered by kind: each hop's prose belongs immediately + // before the blocks it asked for. Collecting all the prose first would show + // the user a conclusion ahead of the search that produced it. + segments.flatMap((segment) => [ + ...(segment.reasoning ? [buildThinkingBlock(segment.reasoning)] : []), + ...(segment.text ? [{ text: segment.text, type: 'text' as const }] : []), + ...buildAllAnthropicServerToolBlocks(segment.executions), + ]); export const mapOpenAIResponseToAnthropic = ( openaiResponse: OpenAIChatResponse, model: string, serverToolExecutions: ServerToolExecution[] = [], - preamble?: ServerToolPreamble, + segments?: ServerToolSegment[], ): Record => { const choice = openaiResponse.choices?.[0]; const message = choice?.message; @@ -165,11 +158,11 @@ export const mapOpenAIResponseToAnthropic = ( const textContent = typeof message?.content === 'string' ? message.content : ''; - const contentBlocks: AnthropicContentBlock[] = preamble - ? buildAnthropicServerToolTurnBlocks(preamble, serverToolExecutions) + const contentBlocks: AnthropicContentBlock[] = segments + ? buildAnthropicServerToolTurnBlocks(segments) : []; - if (!preamble) { + if (!segments) { if (reasoningText) { contentBlocks.push(buildThinkingBlock(reasoningText)); } @@ -183,7 +176,7 @@ export const mapOpenAIResponseToAnthropic = ( ); } else { // The closing half of the turn: the answer written once the results were - // in. It follows the tool blocks above rather than preceding them. + // in. It follows every block above rather than preceding them. if (reasoningText) { contentBlocks.push(buildThinkingBlock(reasoningText)); } diff --git a/lib/server/proxy/anthropic/stream.ts b/lib/server/proxy/anthropic/stream.ts index c12d806..10a7081 100644 --- a/lib/server/proxy/anthropic/stream.ts +++ b/lib/server/proxy/anthropic/stream.ts @@ -495,6 +495,36 @@ export const createAnthropicServerToolEventStream = ({ ); }; + /** + * Emits a text-like block the way Anthropic streams one: an empty + * `content_block_start`, then the content as a delta, then the stop. + * Returns nothing; the caller advances the index when it emitted one. + */ + const emitText = ( + blockIndex: number, + type: string, + content: string, + deltaType: string, + ): void => { + if (!content) { + return; + } + + const field = type === 'thinking' ? 'thinking' : 'text'; + + enqueueEvent({ + type: 'content_block_start', + index: blockIndex, + content_block: { type, [field]: '' }, + }); + enqueueEvent({ + type: 'content_block_delta', + index: blockIndex, + delta: { type: deltaType, [field]: content }, + }); + enqueueEvent({ type: 'content_block_stop', index: blockIndex }); + }; + const emitBlock = ( index: number, contentBlock: Record, @@ -527,7 +557,7 @@ export const createAnthropicServerToolEventStream = ({ }); const run = async (): Promise => { - const { executions, preamble, response } = await runTurn(); + const { executions, response, segments } = await runTurn(); if (cancelled) { await response.body?.cancel(); @@ -539,38 +569,39 @@ export const createAnthropicServerToolEventStream = ({ // What the model wrote before it reached for the tool. Anthropic puts // this ahead of the `server_tool_use` block, and a client replaying the // turn expects it there. - if (preamble.reasoning) { - emitBlock(index++, { - type: 'thinking', - thinking: preamble.reasoning, - }); - } - - if (preamble.text) { - emitBlock(index++, { type: 'text', text: preamble.text }); - } - - for (const execution of executions) { - const toolUseId = createAnthropicId('srvtoolu'); - const [toolUse, result] = buildAnthropicServerToolBlocks(execution); - - enqueueEvent({ - type: 'content_block_start', - index, - content_block: { ...toolUse, id: toolUseId }, - }); - enqueueEvent({ - type: 'content_block_delta', - index, - delta: { - type: 'input_json_delta', - partial_json: JSON.stringify(execution.input), - }, - }); - enqueueEvent({ type: 'content_block_stop', index }); - index++; - - emitBlock(index++, { ...result, tool_use_id: toolUseId }); + // Interleaved, exactly as the non-streaming renderer lays it out: each + // hop's prose first, then the blocks it asked for. + for (const segment of segments) { + emitText(index, 'thinking', segment.reasoning, 'thinking_delta'); + index += segment.reasoning ? 1 : 0; + emitText(index, 'text', segment.text, 'text_delta'); + index += segment.text ? 1 : 0; + + for (const execution of segment.executions) { + const toolUseId = createAnthropicId('srvtoolu'); + const [toolUse, result] = buildAnthropicServerToolBlocks(execution); + + enqueueEvent({ + type: 'content_block_start', + index, + // Anthropic builds a streamed tool input from deltas alone, so the + // block opens empty. Carrying the input here too would hand strict + // consumers the arguments twice. + content_block: { ...toolUse, id: toolUseId, input: {} }, + }); + enqueueEvent({ + type: 'content_block_delta', + index, + delta: { + type: 'input_json_delta', + partial_json: JSON.stringify(execution.input), + }, + }); + enqueueEvent({ type: 'content_block_stop', index }); + index++; + + emitBlock(index++, { ...result, tool_use_id: toolUseId }); + } } // Emitted after the blocks rather than instead of them: a search diff --git a/lib/server/proxy/responses.ts b/lib/server/proxy/responses.ts index dd7ee0f..51c6dd4 100644 --- a/lib/server/proxy/responses.ts +++ b/lib/server/proxy/responses.ts @@ -46,8 +46,9 @@ import { getServerToolExecutions, hasExecutableServerTool, prepareServerToolTurn, + reconcileToolChoice, runServerToolTurn, - type ServerToolPreamble, + type ServerToolSegment, } from './server-tools'; export const handleResponsesRequest = async ( @@ -201,9 +202,14 @@ export const handleResponsesRequest = async ( // Rewritten even when nothing will be executed: upstream has no server // tools, so a declared type would be a shape it rejects. tools: rewrite ? rewrite.tools : translatedTools, - tool_choice: translateResponsesToolChoiceToChatWithTools( - prepared.defaults.tools, - prepared.defaults.tool_choice, + // A server tool nothing here can run is withdrawn from `tools`, so a + // choice forcing it has to go too. + tool_choice: reconcileToolChoice( + translateResponsesToolChoiceToChatWithTools( + prepared.defaults.tools, + prepared.defaults.tool_choice, + ), + rewrite ? rewrite.tools : translatedTools, ), }; @@ -217,7 +223,7 @@ export const handleResponsesRequest = async ( // What the model wrote before its first search. The image loop drives // upstream through `callUpstream`, so the preamble has to be captured // here rather than at a single call site. - let turnPreamble: ServerToolPreamble | undefined; + let turnSegments: ServerToolSegment[] | undefined; const callUpstream = async ( loopBody: Record, @@ -253,12 +259,10 @@ export const handleResponsesRequest = async ( ); // First non-empty wins. The image loop calls this repeatedly, and a - // later iteration that ran no server tool returns an empty preamble — - // which would erase the prose an earlier one captured. - const spoken = outcome.preamble.text || outcome.preamble.reasoning; - - if (spoken && !turnPreamble) { - turnPreamble = outcome.preamble; + // later iteration that ran no server tool has no segments — which would + // erase the prose an earlier one captured. + if (outcome.segments.length && !turnSegments) { + turnSegments = outcome.segments; } return outcome.response; @@ -307,7 +311,7 @@ export const handleResponsesRequest = async ( serverToolExecutions, executions, undefined, - turnPreamble, + turnSegments, ), ); } @@ -340,7 +344,7 @@ export const handleResponsesRequest = async ( serverToolExecutions, [], undefined, - turnPreamble, + turnSegments, ), ); } catch (error) { diff --git a/lib/server/proxy/responses/event-stream.ts b/lib/server/proxy/responses/event-stream.ts index a32d446..b6ee335 100644 --- a/lib/server/proxy/responses/event-stream.ts +++ b/lib/server/proxy/responses/event-stream.ts @@ -36,10 +36,11 @@ import type { ResponseSessionDefaults, TranscriptMessage, } from './types'; -import type { ServerToolExecution, ServerToolPreamble } from '../server-tools'; +import type { ServerToolExecution, ServerToolSegment } from '../server-tools'; import { hasExecutableServerTool, prepareServerToolTurn, + reconcileToolChoice, runServerToolTurn, } from '../server-tools'; @@ -55,7 +56,7 @@ export const createResponsesEventStream = async ( ): Promise => { // The image loop drives upstream through `callUpstream`, so prose written // before a search has to be captured there rather than at one call site. - let streamPreamble: ServerToolPreamble | undefined; + let streamSegments: ServerToolSegment[] | undefined; const translatedTools = translateResponsesToolsToChat(defaults.tools); @@ -81,9 +82,14 @@ export const createResponsesEventStream = async ( // Rewritten even when nothing will be executed: upstream has no server // tools, so a declared type would be a shape it rejects. tools: rewrite ? rewrite.tools : translatedTools, - tool_choice: translateResponsesToolChoiceToChatWithTools( - defaults.tools, - defaults.tool_choice, + // A server tool nothing here can run is withdrawn from `tools`, so a + // choice forcing it has to go too. + tool_choice: reconcileToolChoice( + translateResponsesToolChoiceToChatWithTools( + defaults.tools, + defaults.tool_choice, + ), + rewrite ? rewrite.tools : translatedTools, ), }; @@ -130,10 +136,8 @@ export const createResponsesEventStream = async ( // First non-empty wins: the image loop calls this repeatedly, and a later // iteration that ran no server tool returns an empty preamble, which // would erase the prose an earlier one captured. - const spoken = outcome.preamble.text || outcome.preamble.reasoning; - - if (spoken && !streamPreamble) { - streamPreamble = outcome.preamble; + if (outcome.segments.length && !streamSegments) { + streamSegments = outcome.segments; } return outcome.response; @@ -177,7 +181,7 @@ export const createResponsesEventStream = async ( executions, serverToolExecutions, undefined, - streamPreamble, + streamSegments, ); } @@ -268,7 +272,7 @@ export const createResponsesEventStream = async ( const run = async (): Promise => { const { fetchProvider, searchProvider } = prepared!.providers; - const { executions, preamble, response } = await withCodeBuddyToken( + const { executions, response, segments } = await withCodeBuddyToken( () => Promise.resolve(proxyContext.auth.bearerToken), () => runServerToolTurn({ @@ -348,7 +352,7 @@ export const createResponsesEventStream = async ( // client is handed an id nothing was stored against, and a // follow-up carrying `previous_response_id` fails. responseId, - preamble, + segments, // Already announced above: the replay must not emit a // second `response.created` under the same id. false, diff --git a/lib/server/proxy/responses/payload.ts b/lib/server/proxy/responses/payload.ts index 4f897e5..52bc322 100644 --- a/lib/server/proxy/responses/payload.ts +++ b/lib/server/proxy/responses/payload.ts @@ -36,7 +36,7 @@ import type { import type { ServerToolExecution, ServerToolInvocation, - ServerToolPreamble, + ServerToolSegment, } from '../server-tools'; export const mapChatResponseToResponsesPayload = async ( @@ -50,7 +50,7 @@ export const mapChatResponseToResponsesPayload = async ( serverToolExecutions: ServerToolExecution[], imageExecutions: ImageGenerationExecution[] = [], pinnedResponseId?: string, - preamble?: ServerToolPreamble, + segments?: ServerToolSegment[], ): Promise> => { const responseId = pinnedResponseId ?? createResponseId(); const choices = Array.isArray(upstreamPayload.choices) @@ -68,38 +68,48 @@ export const mapChatResponseToResponsesPayload = async ( // themselves — the order it was written in. Only the closing hop's prose and // reasoning live in `upstreamPayload`, so without this a Responses client // never sees the first half of the turn. - const preambleItems: Array> = [ - ...(preamble?.reasoning - ? [ - { - id: createResponseReasoningId(), - type: 'reasoning', - summary: [{ type: 'summary_text', text: preamble.reasoning }], - encrypted_content: `${REASONING_PREFIX}${preamble.reasoning}`, - status: 'completed', - }, - ] - : []), - ...(preamble?.text - ? [ - { - id: createMessageId(), - type: 'message', - role: 'assistant', - status: 'completed', - content: [ - { type: 'output_text', text: preamble.text, annotations: [] }, - ], - }, - ] - : []), - ]; + // Interleaved, matching the Anthropic renderer: each hop's prose, then the + // calls it asked for. A single leading preamble would put prose written + // between two searches before both of them. + const segmentItems: Array> = segments + ? segments.flatMap((segment) => [ + ...(segment.reasoning + ? [ + { + id: createResponseReasoningId(), + type: 'reasoning', + summary: [{ type: 'summary_text', text: segment.reasoning }], + encrypted_content: `${REASONING_PREFIX}${segment.reasoning}`, + status: 'completed', + }, + ] + : []), + ...(segment.text + ? [ + { + id: createMessageId(), + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { type: 'output_text', text: segment.text, annotations: [] }, + ], + }, + ] + : []), + ...segment.executions.map((execution) => + buildResponsesWebSearchCallItem(execution, 'completed'), + ), + ]) + : []; const output: Array> = [ - ...preambleItems, - ...serverToolExecutions.map((execution) => - buildResponsesWebSearchCallItem(execution, 'completed'), - ), + ...segmentItems, + ...(segments + ? [] + : serverToolExecutions.map((execution) => + buildResponsesWebSearchCallItem(execution, 'completed'), + )), // Image generation is executed locally, so the standard // `image_generation_call` item has to be synthesized here — the chat // upstream has no notion of it. @@ -244,7 +254,7 @@ export const mapChatResponseToResponsesStream = async ( imageExecutions: ImageGenerationExecution[], serverToolExecutions: ServerToolExecution[] = [], pinnedResponseId?: string, - preamble?: ServerToolPreamble, + segments?: ServerToolSegment[], emitOpeningEvents = true, ): Promise => { const payload = await mapChatResponseToResponsesPayload( @@ -258,14 +268,14 @@ export const mapChatResponseToResponsesStream = async ( serverToolExecutions, imageExecutions, pinnedResponseId, - preamble, + segments, ); // The mapper creates and persists the session id, so the stream has to reuse // it: advertising a different one would leave a client unable to continue the // turn, because nothing was stored under the id it was given. const responseId = String(payload.id); const output = payload.output as Array>; - // The last message, not the first: a preamble is a message too, and + // The last message, not the first: a segment's prose is a message too, and // streaming the pre-search prose as the answer would drop the real one. let messageIndex = -1; output.forEach((item, index) => { diff --git a/lib/server/proxy/server-tools/classify.ts b/lib/server/proxy/server-tools/classify.ts index 388d028..b8e3f58 100644 --- a/lib/server/proxy/server-tools/classify.ts +++ b/lib/server/proxy/server-tools/classify.ts @@ -360,6 +360,30 @@ export const rewriteServerTools = ({ }; }; +/** + * Drops a `tool_choice` that names a tool no longer on offer. + * + * Withdrawing a server tool nothing here can run leaves a forced choice + * pointing at it otherwise, and an upstream that validates the two together + * rejects the request instead of letting the model answer from memory. + */ +export const reconcileToolChoice = ( + toolChoice: unknown, + tools: unknown, +): unknown => { + const name = getForcedToolName(toolChoice); + + if (!name || !Array.isArray(tools)) { + return toolChoice; + } + + const offered = tools.some( + (tool) => asRecord(asRecord(tool)?.function)?.name === name, + ); + + return offered ? toolChoice : undefined; +}; + /** Whether the proxy will run any server tool at all. */ export const hasExecutableServerTool = ( executable: ServerToolDeclarations, diff --git a/lib/server/proxy/server-tools/index.ts b/lib/server/proxy/server-tools/index.ts index b7a1a9c..72f5c51 100644 --- a/lib/server/proxy/server-tools/index.ts +++ b/lib/server/proxy/server-tools/index.ts @@ -5,6 +5,7 @@ export { hasAmbiguousServerToolName, hasExecutableServerTool, readMaxUses, + reconcileToolChoice, rewriteServerTools, } from './classify'; export type { RewrittenServerTools, ServerToolDeclarations } from './classify'; @@ -32,6 +33,7 @@ export { } from './types'; export type { ChatCompletionMessage, + ServerToolSegment, ChatCompletionPayload, ChatCompletionToolCall, JsonRecord, diff --git a/lib/server/proxy/server-tools/turn.ts b/lib/server/proxy/server-tools/turn.ts index 0d83f2b..55a636c 100644 --- a/lib/server/proxy/server-tools/turn.ts +++ b/lib/server/proxy/server-tools/turn.ts @@ -28,6 +28,7 @@ import type { ServerToolInvocation, ServerToolKind, ServerToolPreamble, + ServerToolSegment, ServerToolTurnOutcome, } from './types'; import { attachServerToolExecutions, EMPTY_PREAMBLE, sumUsage } from './types'; @@ -289,7 +290,10 @@ export const runServerToolTurn = async ({ rewrite; const executions: ServerToolExecution[] = []; - let preamble = EMPTY_PREAMBLE; + // One entry per hop that ran something: the prose that preceded it, plus the + // calls it made. Kept as a list because prose written between two searches + // belongs between the two search blocks, and flattening it loses that. + const segments: ServerToolSegment[] = []; let transcript = asMessages(body); let usage: unknown = null; // Counted separately: the client declares `max_uses` on each server tool, so @@ -324,7 +328,7 @@ export const runServerToolTurn = async ({ if (!response.ok || payload.error) { return { executions, - preamble, + segments, // Attached even on failure: the earlier hops really ran and were // really billed, and the Responses and image paths recover them from // the response rather than from the return value. @@ -353,7 +357,7 @@ export const runServerToolTurn = async ({ if (!localCalls.length) { return { executions, - preamble, + segments, response: attachServerToolExecutions( rebuildResponse(response, JSON.stringify(withUsage(payload, usage))), executions, @@ -365,12 +369,6 @@ export const runServerToolTurn = async ({ // Captured on every hop, up to the first one that actually speaks: the // model may explain itself before each search, and only the closing // answer lives in the payload the renderer sees. - // First hop that actually speaks wins. The preamble is rendered ahead of - // every search, so a later hop's prose here would appear to precede the - // search it was written after. - if (!preamble.text && !preamble.reasoning) { - preamble = readPreamble(message); - } // Clamped to the budget before executing: the bound is only testable // between hops, so a hop emitting k parallel searches would otherwise run @@ -397,6 +395,10 @@ export const runServerToolTurn = async ({ }); executions.push(...results.map((result) => result.execution)); + segments.push({ + ...readPreamble(message), + executions: results.map((result) => result.execution), + }); searches += results.filter( (result) => result.execution.type === 'web_search', ).length; @@ -440,7 +442,7 @@ export const runServerToolTurn = async ({ if (remainingCalls.length) { return { executions, - preamble, + segments, response: attachServerToolExecutions( rebuildResponse( response, @@ -491,7 +493,7 @@ export const runServerToolTurn = async ({ if (!finalResponse.ok || finalPayload.error) { return { executions, - preamble, + segments, // Attached even on failure: the searches really ran and were really // billed, and the Responses path recovers them from the response. response: attachServerToolExecutions( @@ -517,7 +519,7 @@ export const runServerToolTurn = async ({ return { executions, - preamble, + segments, response: attachServerToolExecutions( rebuildResponse( finalResponse, diff --git a/lib/server/proxy/server-tools/types.ts b/lib/server/proxy/server-tools/types.ts index 5bb2014..acb2dd4 100644 --- a/lib/server/proxy/server-tools/types.ts +++ b/lib/server/proxy/server-tools/types.ts @@ -104,6 +104,22 @@ export interface ServerToolPreamble { export const EMPTY_PREAMBLE: ServerToolPreamble = { reasoning: '', text: '' }; +/** + * One hop of a server-tool turn: what the model said, then what it asked for. + * + * A turn is a list of these. Anthropic interleaves prose and server-tool + * blocks rather than gathering them by kind, so the grouping has to survive + * to the renderer — a single "preamble" loses everything written between two + * searches. + */ +export interface ServerToolSegment { + /** Prose and reasoning the model produced before these calls. */ + reasoning: string; + text: string; + /** The calls this hop ran, in call order. */ + executions: ServerToolExecution[]; +} + /** * Result of one server-tool turn. * @@ -114,8 +130,11 @@ export const EMPTY_PREAMBLE: ServerToolPreamble = { reasoning: '', text: '' }; export interface ServerToolTurnOutcome { /** Calls executed locally, in the order the model made them. */ executions: ServerToolExecution[]; - /** What the model wrote before those calls. Empty when it spoke only after. */ - preamble: ServerToolPreamble; + /** + * The hops, each with the prose that preceded it. The closing answer is not + * here — it is in `response`. + */ + segments: ServerToolSegment[]; response: Response; /** * Token usage for the whole turn, summed across every hop. Carried here diff --git a/tests/server/search-providers.test.ts b/tests/server/search-providers.test.ts index f3803d0..77b8fe3 100644 --- a/tests/server/search-providers.test.ts +++ b/tests/server/search-providers.test.ts @@ -1632,6 +1632,12 @@ describe('CodeBuddy fetch provider', () => { stubFetch(async () => { throw 'endpoint fell over'; }); + // The endpoint is not awaited alone: on failure the local attempt is + // awaited too, so without a stubbed transport this resolves a real + // hostname and hangs until the test times out. + installTransport(({ request }) => { + request.emitError('endpoint fell over'); + }); await expect( provider({ resolveHost: publicResolver }).fetch({ @@ -1651,6 +1657,9 @@ describe('CodeBuddy fetch provider', () => { ); }), ); + installTransport(({ request }) => { + request.emitError('connection reset'); + }); await expect( provider({ timeoutMs: 1_000 }).fetch({ url: 'https://a.test/' }), diff --git a/tests/server/server-tools.test.ts b/tests/server/server-tools.test.ts index 2f752e8..de530a7 100644 --- a/tests/server/server-tools.test.ts +++ b/tests/server/server-tools.test.ts @@ -418,7 +418,7 @@ describe('server tool turn', () => { expect(outcome.executions).toEqual([]); // It answered outright, so there is no preamble — the answer stays in the // payload where the renderer will find it. - expect(outcome.preamble).toEqual({ reasoning: '', text: '' }); + expect(outcome.segments).toEqual([]); expect((await outcome.response.json()).choices[0].message.content).toBe( 'Yesterday.', ); @@ -952,7 +952,7 @@ describe('server tool edge cases', () => { }); expect(outcome.executions).toEqual([]); - expect(outcome.preamble).toEqual({ reasoning: '', text: '' }); + expect(outcome.segments).toEqual([]); }); it('runs a turn whose body carries no messages', async () => { @@ -1114,7 +1114,14 @@ it('runs the search but hands a client call back unresolved', async () => { // One search ran, and the client's call survives for it to answer. expect(outcome.executions).toHaveLength(1); - expect(outcome.preamble.text).toBe('Let me check that file first.'); + // The prose that led to the search stays with it, as its own segment. + expect(outcome.segments).toEqual([ + { + executions: outcome.executions, + reasoning: '', + text: 'Let me check that file first.', + }, + ]); const payload = (await outcome.response.json()) as { choices: Array<{ @@ -1145,7 +1152,7 @@ it('copes with a hop that carries no message at all', async () => { // No call to answer and no prose to keep: the hop is the turn. expect(outcome.executions).toEqual([]); - expect(outcome.preamble).toEqual({ reasoning: '', text: '' }); + expect(outcome.segments).toEqual([]); }); describe('server tool loop', () => { diff --git a/tests/server/web-search.test.ts b/tests/server/web-search.test.ts index 2e87591..839419f 100644 --- a/tests/server/web-search.test.ts +++ b/tests/server/web-search.test.ts @@ -1171,6 +1171,115 @@ describe('server tool routing', () => { expect(payload.content[4].text).toBe('Here it is.'); }); + /** + * Prose written *between* two searches. It used to be dropped: a single + * preamble can only hold the first hop's, so a turn like + * text → search → text → search → answer lost the second passage. + */ + it('keeps the prose written between two searches, in place', async () => { + await enableSearch(); + let hop = 0; + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('searx.test')) { + return makeJsonResponse({ + results: [ + { content: 'snippet', title: 'Docs', url: 'https://docs.test' }, + ], + }) as unknown as Response; + } + + hop += 1; + + if (hop === 1) { + return makeJsonResponse({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: 'First, the broad picture.', + role: 'assistant', + tool_calls: [ + { + id: 'c1', + type: 'function', + function: { + arguments: '{"query":"quantum computing"}', + name: 'web_search', + }, + }, + ], + }, + }, + ], + usage: { completion_tokens: 10, prompt_tokens: 100 }, + }) as unknown as Response; + } + + if (hop === 2) { + return makeJsonResponse({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: 'Now the 2026 announcements.', + role: 'assistant', + tool_calls: [ + { + id: 'c2', + type: 'function', + function: { + arguments: '{"query":"IBM quantum 2026"}', + name: 'web_search', + }, + }, + ], + }, + }, + ], + usage: { completion_tokens: 10, prompt_tokens: 100 }, + }) as unknown as Response; + } + + return makeJsonResponse(answer('Both are covered now.')); + }); + + const payload = (await handleMessagesRequest( + makeRequest('http://localhost/v1/messages'), + { + max_tokens: 2048, + messages: [{ role: 'user', content: 'summarise quantum progress' }], + tools: [ + { + type: 'web_search_20250305', + name: 'web_search', + input_schema: {}, + }, + ], + }, + ).then((r) => r.json())) as { + content: Array<{ text?: string; type: string }>; + usage: { server_tool_use: { web_search_requests: number } }; + }; + + // Each passage sits immediately before the search it motivated. + expect(payload.content.map((block) => block.type)).toEqual([ + 'text', + 'server_tool_use', + 'web_search_tool_result', + 'text', + 'server_tool_use', + 'web_search_tool_result', + 'text', + ]); + expect(payload.content[0].text).toBe('First, the broad picture.'); + expect(payload.content[3].text).toBe('Now the 2026 announcements.'); + expect(payload.content[6].text).toBe('Both are covered now.'); + expect(payload.usage.server_tool_use.web_search_requests).toBe(2); + }); + it('bills the whole turn, not just the answering hop', async () => { await enableSearch(); const { run } = routed( From ce1d91f768791fb7d0dd54dc7101a386aaf81311 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Fri, 18 Sep 2026 10:33:08 +0800 Subject: [PATCH 6/9] docs(design): add the Claude Code WebSearch protocol spec The contract these routes are audited against: the three-phase flow, and the distinction between `WebSearch` (Claude Code's own client tool, which the proxy must never pick up) and `web_search_*` (a provider-executed tool the proxy must run, in a loop bounded by `max_uses`, hiding the internal chat function). Kept out of the VitePress navigation: it is an internal design document rather than user documentation, and the guide tree is localised per language. Co-Authored-By: Claude Fable 5 --- docs/design/claude-code-websearch-flow.md | 1482 +++++++++++++++++++++ 1 file changed, 1482 insertions(+) create mode 100644 docs/design/claude-code-websearch-flow.md diff --git a/docs/design/claude-code-websearch-flow.md b/docs/design/claude-code-websearch-flow.md new file mode 100644 index 0000000..c0d27c2 --- /dev/null +++ b/docs/design/claude-code-websearch-flow.md @@ -0,0 +1,1482 @@ +# Claude Code WebSearch 完整链路 + +## 1. 架构 + +你的实际架构: + +```text +┌─────────────────────┐ +│ Claude Code │ +│ 客户端 CC │ +└──────────┬──────────┘ + │ + │ Anthropic Messages 协议 + │ POST /v1/messages + ▼ +┌────────────────────────────┐ +│ 你的兼容层 │ +│ 对外伪装 Anthropic API │ +│ │ +│ /v1/messages │ +└──────────┬─────────────────┘ + │ + │ OpenAI-compatible + │ Chat Completions + ▼ +┌────────────────────────────┐ +│ 上游 Chat API │ +│ POST /v1/chat/completions │ +└────────────────────────────┘ +``` + +整个 WebSearch 流程里实际上存在两种不同的工具: + +```text +WebSearch +``` + +和: + +```text +web_search +``` + +它们不是同一个东西。 + +| 工具 | 类型 | 谁声明 | 谁执行 | +| ------------------------------------ | ----------------------- | ---------------------- | ------------------ | +| `WebSearch` | Claude Code client tool | Claude Code | Claude Code | +| `web_search_20250305` / `web_search` | Anthropic server tool | Claude Code 的内部请求 | `/messages` 服务端 | +| 你转给 Chat 的 `web_search` | 普通 function | 你的兼容层 | 你的兼容层 | + +因为你的上游是普通 Chat API,所以: + +```text +Anthropic server tool +``` + +不能直接一一映射成真正的 hosted server tool。 + +你需要在自己的兼容层里模拟 server-tool loop。 + +--- + +# 2. 第一阶段:Claude Code 主请求 + +用户在 Claude Code 中说: + +```text +帮我查一下 OpenAI 最近有什么更新 +``` + +Claude Code 向你的 `/v1/messages` 发请求。 + +例如: + +```http +POST /v1/messages +``` + +```json +{ + "model": "claude-opus-...", + "max_tokens": 32000, + "messages": [ + { + "role": "user", + "content": "帮我查一下 OpenAI 最近有什么更新" + } + ], + "tools": [ + { + "name": "WebSearch", + "description": "Search the web", + "input_schema": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "allowed_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "blocked_domains": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["query"] + } + }, + { + "name": "Read", + "description": "...", + "input_schema": {} + }, + { + "name": "Bash", + "description": "...", + "input_schema": {} + } + ] +} +``` + +这里: + +```text +WebSearch +``` + +是大写。 + +它是一个普通 Claude Code client tool。 + +所以你的兼容层: + +```text +❌ 不应该在这里执行搜索 +``` + +而是应该把它当普通 function tool 转给上游 Chat。 + +--- + +# 3. 第一次 `/messages` → `/chat/completions` + +你的兼容层转换成: + +```http +POST /v1/chat/completions +``` + +```json +{ + "model": "your-upstream-model", + "messages": [ + { + "role": "user", + "content": "帮我查一下 OpenAI 最近有什么更新" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "WebSearch", + "description": "Search the web", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "allowed_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "blocked_domains": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["query"] + } + } + }, + { + "type": "function", + "function": { + "name": "Read", + "description": "...", + "parameters": {} + } + } + ] +} +``` + +--- + +# 4. 上游 Chat 决定调用 `WebSearch` + +上游 Chat 返回: + +```json +{ + "id": "chatcmpl-main-001", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_search_001", + "type": "function", + "function": { + "name": "WebSearch", + "arguments": "{\"query\":\"OpenAI latest updates 2026\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ] +} +``` + +注意: + +```text +name = WebSearch +``` + +还是大写的 Claude Code client tool。 + +你的兼容层把它转回 Anthropic: + +```json +{ + "id": "msg_001", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_search_001", + "name": "WebSearch", + "input": { + "query": "OpenAI latest updates 2026" + } + } + ], + "stop_reason": "tool_use" +} +``` + +然后: + +```text +你的兼容层 + ↓ +把 tool_use 返回 Claude Code +``` + +此时你的服务器: + +```text +仍然没有搜索。 +``` + +--- + +# 5. Claude Code 收到 `WebSearch` + +Claude Code 收到: + +```json +{ + "type": "tool_use", + "id": "toolu_search_001", + "name": "WebSearch", + "input": { + "query": "OpenAI latest updates 2026" + } +} +``` + +此时执行: + +```text +Claude Code 内置 WebSearch +``` + +也就是说: + +```text + 第一次请求 + +Claude Code + ↓ +你的 /messages + ↓ +上游 Chat + ↓ +tool_call WebSearch + ↓ +你的 /messages + ↓ +tool_use WebSearch + ↓ +Claude Code +``` + +到这里第一阶段结束。 + +--- + +# 6. Claude Code 执行 WebSearch 时再次请求 `/messages` + +Claude Code 为了实现这个 `WebSearch`,会创建一个内部的 side request。 + +这是: + +```text +第二次独立的 /v1/messages 请求 +``` + +示意结构: + +```http +POST /v1/messages +``` + +```json +{ + "model": "claude-haiku-...", + "max_tokens": 32000, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Perform a web search for the query: OpenAI latest updates 2026" + } + ] + } + ], + "tools": [ + { + "type": "web_search_20250305", + "name": "web_search", + "max_uses": 8 + } + ], + "tool_choice": { + "type": "tool", + "name": "web_search" + } +} +``` + +注意第二次已经不是: + +```text +WebSearch +``` + +而是: + +```text +web_search +``` + +并且有特殊 type: + +```text +web_search_20250305 +``` + +这表示: + +```text +Anthropic server tool +``` + +--- + +# 7. Query 怎么从第一次传到第二次? + +第一阶段主模型产生: + +```json +{ + "name": "WebSearch", + "input": { + "query": "OpenAI latest updates 2026" + } +} +``` + +Claude Code 收到以后: + +```text +query = +"OpenAI latest updates 2026" +``` + +然后 CC 的 WebSearch implementation 创建第二次 side request。 + +示意: + +```text +WebSearch.input.query + +"OpenAI latest updates 2026" + + ↓ + + Claude Code + + ↓ + +第二次 /messages 输入 + +"Perform a web search for the query: + OpenAI latest updates 2026" +``` + +重点是: + +```text +web_search_20250305 的 tool definition +本身没有 query。 +``` + +它只是: + +```json +{ + "type": "web_search_20250305", + "name": "web_search" +} +``` + +query 存在于这次模型输入的语义里。 + +之后模型应该生成: + +```json +{ + "type": "server_tool_use", + "name": "web_search", + "input": { + "query": "OpenAI latest updates 2026" + } +} +``` + +Anthropic 原生 API 会在这里执行搜索。 + +但是: + +```text +你不是 Anthropic。 + +你的上游也不是 Anthropic server-tool runtime。 + +你的上游是 Chat API。 +``` + +所以这一部分需要你自己模拟。 + +--- + +# 8. 你的兼容层收到第二次 `/messages` + +现在你收到: + +```json +{ + "messages": [ + { + "role": "user", + "content": "Perform a web search for the query: OpenAI latest updates 2026" + } + ], + "tools": [ + { + "type": "web_search_20250305", + "name": "web_search", + "max_uses": 8 + } + ], + "tool_choice": { + "type": "tool", + "name": "web_search" + } +} +``` + +这一次: + +```text +✅ 你的服务端必须处理 web_search +``` + +因为从 Claude Code 看: + +```text +你的 /messages API += +Anthropic server +``` + +--- + +# 9. `web_search` server tool 怎么转换给 Chat? + +普通 Chat API 不理解: + +```json +{ + "type": "web_search_20250305" +} +``` + +所以你把它降级成一个普通 function: + +```json +{ + "type": "function", + "function": { + "name": "web_search", + "description": "Search the web", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string" + } + }, + "required": ["query"] + } + } +} +``` + +也就是说: + +```text +Anthropic Server Tool +web_search + ↓ +你的兼容层 + ↓ +Chat Function Tool +web_search +``` + +注意这个 Chat function 是: + +```text +你的内部实现细节。 +``` + +它绝对不能直接返回给 Claude Code。 + +--- + +# 10. 第二次 `/messages` → 第一次内部 `/chat/completions` + +你发给上游: + +```http +POST /v1/chat/completions +``` + +```json +{ + "model": "your-upstream-model", + "messages": [ + { + "role": "system", + "content": "You are an assistant for performing a web search." + }, + { + "role": "user", + "content": "Perform a web search for the query: OpenAI latest updates 2026" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "web_search", + "description": "Search the web", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string" + } + }, + "required": ["query"] + } + } + } + ], + "tool_choice": { + "type": "function", + "function": { + "name": "web_search" + } + } +} +``` + +这里非常关键: + +```text +你不需要自己从: + +"Perform a web search for the query: ..." + +parse query。 +``` + +因为你可以让 Chat 模型自己产生 function arguments。 + +--- + +# 11. Chat 上游生成真正的搜索 query + +上游返回: + +```json +{ + "id": "chatcmpl-side-001", + "choices": [ + { + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_internal_search_001", + "type": "function", + "function": { + "name": "web_search", + "arguments": "{\"query\":\"OpenAI latest updates 2026\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ] +} +``` + +这里: + +```json +{ + "query": "OpenAI latest updates 2026" +} +``` + +才是: + +```text +真正应该交给搜索引擎的 query。 +``` + +完整路径: + +```text +外层主模型 +WebSearch.input.query + ↓ +Claude Code + ↓ +side request messages + ↓ +上游 Chat 模型 + ↓ +web_search function arguments + ↓ +你的搜索实现 +``` + +--- + +# 12. 你的兼容层执行真正搜索 + +现在你的兼容层拿到: + +```json +{ + "query": "OpenAI latest updates 2026" +} +``` + +执行真正的 search: + +```text +search("OpenAI latest updates 2026") +``` + +例如得到: + +```json +[ + { + "title": "OpenAI announces ...", + "url": "https://example.com/1", + "snippet": "..." + }, + { + "title": "OpenAI API update ...", + "url": "https://example.com/2", + "snippet": "..." + } +] +``` + +这一步: + +```text +✅ 发生在你的兼容层 +``` + +因为上游只是普通 Chat API。 + +--- + +# 13. 把 search result 回传给 Chat 上游 + +现在需要完成普通 Chat function-call loop。 + +再次调用: + +```http +POST /v1/chat/completions +``` + +messages: + +```json +{ + "model": "your-upstream-model", + "messages": [ + { + "role": "system", + "content": "You are an assistant for performing a web search." + }, + { + "role": "user", + "content": "Perform a web search for the query: OpenAI latest updates 2026" + }, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_internal_search_001", + "type": "function", + "function": { + "name": "web_search", + "arguments": "{\"query\":\"OpenAI latest updates 2026\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_internal_search_001", + "content": "[{\"title\":\"OpenAI announces ...\",\"url\":\"https://example.com/1\",\"snippet\":\"...\"},{\"title\":\"OpenAI API update ...\",\"url\":\"https://example.com/2\",\"snippet\":\"...\"}]" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "web_search", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string" + } + }, + "required": ["query"] + } + } + } + ] +} +``` + +--- + +# 14. 上游模型基于搜索结果回答 + +现在上游看到: + +```text +用户要求搜索 Q + ++ + +自己调用过 web_search(Q) + ++ + +tool result 里有真实搜索结果 +``` + +于是返回: + +```json +{ + "id": "chatcmpl-side-002", + "choices": [ + { + "message": { + "role": "assistant", + "content": "根据搜索结果,OpenAI 最近发布了……" + }, + "finish_reason": "stop" + } + ] +} +``` + +这就是: + +```text +基于 Web Search 的模型回答 +``` + +--- + +# 15. 如果上游再次调用 `web_search` + +不能假设只有一次。 + +例如第二轮可能返回: + +```json +{ + "tool_calls": [ + { + "id": "call_internal_search_002", + "type": "function", + "function": { + "name": "web_search", + "arguments": "{\"query\":\"OpenAI September 2026 API announcements\"}" + } + } + ] +} +``` + +那你的兼容层再次: + +```text +执行搜索 +↓ +追加 tool result +↓ +再次请求 Chat +``` + +所以内部需要: + +```text +while model requests web_search: + execute search + append tool result + call chat again +``` + +并且应该尊重 Anthropic side request 里的: + +```json +{ + "max_uses": 8 +} +``` + +例如: + +```text +最多允许 8 次内部搜索 +``` + +--- + +# 16. 这整个过程对 Claude Code 是隐藏的 + +第二次 `/messages` 请求期间: + +```text +Claude Code + │ + │ POST /messages + ▼ +你的兼容层 + + Chat call #1 + ↓ + web_search function_call + ↓ + 你真正搜索 + ↓ + Chat call #2 + ↓ + 如果还有搜索 + ↓ + 再搜索 / 再 Chat + ↓ + 最终 text + + │ + ▼ +Claude Code +``` + +Claude Code 不会看到: + +```text +Chat function_call +``` + +也不会看到: + +```text +你的搜索 provider +``` + +因为从 Claude Code 看: + +```text +web_search 是 server tool。 +``` + +所以 server-side loop 应该完全在你的 `/messages` 请求内部完成。 + +--- + +# 17. 第二次 `/messages` 最终回包 + +假设最终上游返回: + +```text +根据搜索结果,OpenAI 最近…… +``` + +最简单的兼容方式可以返回: + +```json +{ + "id": "msg_side_final", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "根据搜索结果,OpenAI 最近……" + } + ], + "stop_reason": "end_turn" +} +``` + +Claude Code 收到。 + +--- + +# 18. Claude Code 完成外层 `WebSearch` + +还记得最开始那个: + +```json +{ + "type": "tool_use", + "id": "toolu_search_001", + "name": "WebSearch", + "input": { + "query": "OpenAI latest updates 2026" + } +} +``` + +吗? + +Claude Code 现在拿到了第二次 side request 的结果: + +```text +根据搜索结果,OpenAI 最近…… +``` + +于是把它当成大写 `WebSearch` 的: + +```text +tool_result +``` + +接着第三次调用你的 `/messages`。 + +--- + +# 19. Claude Code 回到主 Agent + +例如: + +```http +POST /v1/messages +``` + +```json +{ + "model": "claude-opus-...", + "messages": [ + { + "role": "user", + "content": "帮我查一下 OpenAI 最近有什么更新" + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_search_001", + "name": "WebSearch", + "input": { + "query": "OpenAI latest updates 2026" + } + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_search_001", + "content": "根据搜索结果,OpenAI 最近……" + } + ] + } + ], + "tools": [ + { + "name": "WebSearch", + "input_schema": { + "...": "..." + } + }, + { + "name": "Read", + "input_schema": { + "...": "..." + } + } + ] +} +``` + +注意: + +```text +现在又回到了大写 WebSearch。 +``` + +因为这是主 Agent 的 client-tool history。 + +--- + +# 20. 主 Agent continuation → Chat + +你的兼容层把它转换为 Chat: + +```json +{ + "model": "your-upstream-model", + "messages": [ + { + "role": "user", + "content": "帮我查一下 OpenAI 最近有什么更新" + }, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_search_001", + "type": "function", + "function": { + "name": "WebSearch", + "arguments": "{\"query\":\"OpenAI latest updates 2026\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_search_001", + "content": "根据搜索结果,OpenAI 最近……" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "WebSearch", + "parameters": { + "...": "..." + } + } + } + ] +} +``` + +--- + +# 21. 上游主模型最终回答 + +Chat 返回: + +```json +{ + "choices": [ + { + "message": { + "role": "assistant", + "content": "OpenAI 最近主要有这些更新:……" + }, + "finish_reason": "stop" + } + ] +} +``` + +你的兼容层转换: + +```json +{ + "id": "msg_final", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "OpenAI 最近主要有这些更新:……" + } + ], + "stop_reason": "end_turn" +} +``` + +返回 Claude Code。 + +至此整个 WebSearch 完成。 + +--- + +# 22. 完整时序图 + +```text +Claude Code 你的 /messages 上游 Chat + │ │ │ + │ ① 主请求 │ │ + │ tools=[WebSearch,...] │ │ + ├─────────────────────────────>│ │ + │ │ │ + │ │ ② 转普通 function │ + │ │ WebSearch │ + │ ├───────────────────────────>│ + │ │ │ + │ │ tool_call WebSearch(Q) │ + │ │<───────────────────────────┤ + │ │ │ + │ ③ tool_use WebSearch(Q) │ │ + │<─────────────────────────────┤ │ + │ │ │ + │ │ │ + │ CC 执行 WebSearch │ │ + │ │ │ + │ ④ side request │ │ + │ tools=[web_search_...] │ │ + ├─────────────────────────────>│ │ + │ │ │ + │ │ ⑤ 转内部 function │ + │ │ web_search │ + │ ├───────────────────────────>│ + │ │ │ + │ │ tool_call web_search(Q2) │ + │ │<───────────────────────────┤ + │ │ │ + │ │ │ + │ ┌──────┴──────┐ │ + │ │ 真正执行搜索 │ │ + │ └──────┬──────┘ │ + │ │ │ + │ │ ⑥ tool result │ + │ ├───────────────────────────>│ + │ │ │ + │ │ 基于搜索结果的 text │ + │ │<───────────────────────────┤ + │ │ │ + │ ⑦ side request 最终回答 │ │ + │<─────────────────────────────┤ │ + │ │ │ + │ CC 将它包装成 │ │ + │ WebSearch tool_result │ │ + │ │ │ + │ ⑧ 主 Agent continuation │ │ + ├─────────────────────────────>│ │ + │ │ │ + │ │ ⑨ function result │ + │ ├───────────────────────────>│ + │ │ │ + │ │ 最终主回答 │ + │ │<───────────────────────────┤ + │ │ │ + │ ⑩ 最终回答 │ │ + │<─────────────────────────────┤ │ +``` + +--- + +# 23. 最重要的两条分支 + +你的 `/messages` handler 应该明确区分: + +## A. 大写 `WebSearch` + +```json +{ + "name": "WebSearch", + "input_schema": {} +} +``` + +处理方式: + +```text +→ 普通 client tool +→ 转成 Chat function +→ 上游调用后 +→ 转成 Anthropic tool_use +→ 返回 Claude Code +→ 绝对不要自己执行搜索 +``` + +--- + +## B. 小写 `web_search_*` + +```json +{ + "type": "web_search_20250305", + "name": "web_search" +} +``` + +处理方式: + +```text +→ Anthropic server tool +→ 不应该把 tool call 暴露给 Claude Code +→ 转成你内部的 Chat function +→ Chat 生成 query +→ 你的兼容层执行搜索 +→ tool result 回 Chat +→ Chat 继续生成 +→ 整个内部 loop 完成后 +→ 才返回这次 /messages 请求 +``` + +--- + +# 24. 核心代码逻辑 + +整体可以理解为: + +```go +func HandleMessages(req AnthropicRequest) AnthropicResponse { + if hasServerWebSearch(req.Tools) { + return handleServerWebSearch(req) + } + + return handleNormalClaudeCodeRequest(req) +} +``` + +普通主 Agent: + +```go +func handleNormalClaudeCodeRequest( + req AnthropicRequest, +) AnthropicResponse { + + chatReq := convertAnthropicToChat(req) + + chatResp := callUpstreamChat(chatReq) + + // 如果 Chat 调用的是 WebSearch / Read / Bash 等 + // 不执行。 + // 直接转换成 Anthropic tool_use 返回 CC。 + + return convertChatToAnthropic(chatResp) +} +``` + +Server WebSearch: + +```go +func handleServerWebSearch( + req AnthropicRequest, +) AnthropicResponse { + + chatReq := convertServerWebSearchToChat(req) + + searchCount := 0 + maxUses := getMaxUses(req) // 例如 8 + + for { + chatResp := callUpstreamChat(chatReq) + + call := findToolCall(chatResp, "web_search") + + if call == nil { + // 模型已经生成最终答案 + return convertFinalChatToAnthropic(chatResp) + } + + if searchCount >= maxUses { + return errorOrForceFinish() + } + + query := call.Arguments.Query + + result := executeWebSearch(query) + + chatReq.Messages = append( + chatReq.Messages, + chatResp.AssistantMessage, + ChatMessage{ + Role: "tool", + ToolCallID: call.ID, + Content: serialize(result), + }, + ) + + searchCount++ + } +} +``` + +--- + +# 25. 最终关系 + +可以压缩成一句话: + +```text +Claude Code 大写 WebSearch + ↓ +你的 API 当普通 function 转给 Chat + ↓ +Chat 返回 WebSearch tool call + ↓ +你的 API 返回给 Claude Code + ↓ +Claude Code 执行 WebSearch + ↓ +Claude Code 再调用你的 /messages, +这一次带小写 web_search server tool + ↓ +你的 API 把它变成内部 Chat function + ↓ +Chat 产生真正 query + ↓ +你的 API 真正搜索 + ↓ +search result → Chat + ↓ +Chat 生成基于搜索结果的回答 + ↓ +你的 API 返回 Claude Code + ↓ +Claude Code 包装成外层 WebSearch tool_result + ↓ +主 Agent 继续 +``` + +--- + +# 26. 一定不要做错的地方 + +错误: + +```text +主请求看到: + +WebSearch + +↓ + +你的服务直接搜索 +``` + +因为这样 Claude Code 永远收不到: + +```text +tool_use WebSearch +``` + +会破坏 CC 的 client-tool lifecycle。 + +正确: + +```text +主请求 WebSearch +↓ +返回 CC 执行 + +side request web_search_* +↓ +你的服务内部执行 +``` + +另外也不要: + +```text +收到 web_search_* 后 +直接从 user prompt 正则提取 query +然后搜索 +``` + +更接近 Anthropic server-tool 语义的是: + +```text +messages ++ +web_search function schema +↓ +Chat 模型生成结构化 query +↓ +你的服务执行搜索 +``` + +因此模型仍然负责: + +```text +tool selection / query generation +``` + +而你的兼容层负责: + +```text +tool execution / continuation loop +``` + +这就是普通 Chat 上游情况下最干净的实现。 From 11d84096fe0b52079b0662b4f6cf64044cb4ec3d Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Fri, 18 Sep 2026 11:31:45 +0800 Subject: [PATCH 7/9] fix(server-tools): end the server-tool turn instead of leaking or spinning Conformance fixes against the design spec in docs/design/claude-code-websearch-flow.md. - Terminate the loop when a hop can afford nothing. `spent` tested "every kind exhausted" while affordability is per kind, so with both search and fetch runnable, a model that kept asking for the exhausted one ran a hop that executed nothing: the transcript never advanced and the loop re-issued an identical billed request forever. - Stop handing the internal search function to the client on the failure paths. Both error returns passed the payload through verbatim, so a `web_search` tool_call could reach the client as a `tool_use` for a tool it never declared and has no handler for. Every return now keeps only the calls the client can answer. - Read the closing call's body through the same guard every other hop uses. An unparseable body threw out of the turn, discarding every search already run and billed. - Reconcile `tool_choice` against the tools actually offered, on the first hop and on the closing call: a pin can no longer name a withdrawn server tool, and a pin the client set on one of its own tools survives the withdrawal. - Judge name collisions per server-tool kind, so a clash on the fetch name no longer withholds a search the client also declared. Responses path: - Accumulate image-loop iteration segments instead of keeping the first, which dropped every search after the first along with the prose that preceded it. - Skip call items the proxy mints when replaying a transcript; each was becoming an empty user turn, one per search the previous turn ran. Tests cover each fix, plus the three gaps the spec leans on hardest: both tools declared in one request with the model calling both, the query coming from the model's arguments rather than the prompt, and the Anthropic translator preserving a declared server tool's type. Co-Authored-By: Claude Fable 5 --- lib/server/proxy/responses.ts | 13 +- lib/server/proxy/responses/event-stream.ts | 13 +- lib/server/proxy/responses/transcript.ts | 12 + lib/server/proxy/server-tools/classify.ts | 28 +- lib/server/proxy/server-tools/turn.ts | 78 +++++- tests/server/server-tools.test.ts | 304 ++++++++++++++++++++- tests/server/web-search.test.ts | 192 +++++++++++++ 7 files changed, 595 insertions(+), 45 deletions(-) diff --git a/lib/server/proxy/responses.ts b/lib/server/proxy/responses.ts index 51c6dd4..38e7e0b 100644 --- a/lib/server/proxy/responses.ts +++ b/lib/server/proxy/responses.ts @@ -258,11 +258,14 @@ export const handleResponsesRequest = async ( }), ); - // First non-empty wins. The image loop calls this repeatedly, and a - // later iteration that ran no server tool has no segments — which would - // erase the prose an earlier one captured. - if (outcome.segments.length && !turnSegments) { - turnSegments = outcome.segments; + // Accumulated across iterations: the image loop calls this once per + // iteration and each outcome carries only that iteration's segments, so + // first-wins would drop every search after the first. An iteration that + // ran no server tool contributes an empty array and erases nothing, and + // `undefined` still means none ran at all — the mapper falls back to + // `serverToolExecutions` on that distinction. + if (outcome.segments.length) { + turnSegments = [...(turnSegments ?? []), ...outcome.segments]; } return outcome.response; diff --git a/lib/server/proxy/responses/event-stream.ts b/lib/server/proxy/responses/event-stream.ts index b6ee335..bfe90d2 100644 --- a/lib/server/proxy/responses/event-stream.ts +++ b/lib/server/proxy/responses/event-stream.ts @@ -133,11 +133,14 @@ export const createResponsesEventStream = async ( }), ); - // First non-empty wins: the image loop calls this repeatedly, and a later - // iteration that ran no server tool returns an empty preamble, which - // would erase the prose an earlier one captured. - if (outcome.segments.length && !streamSegments) { - streamSegments = outcome.segments; + // Accumulated across iterations: the image loop calls this once per + // iteration and each outcome carries only that iteration's segments, so + // first-wins would drop every search after the first. An iteration that ran + // no server tool contributes an empty array and erases nothing, and + // `undefined` still means none ran at all — the mapper falls back to + // `serverToolExecutions` on that distinction. + if (outcome.segments.length) { + streamSegments = [...(streamSegments ?? []), ...outcome.segments]; } return outcome.response; diff --git a/lib/server/proxy/responses/transcript.ts b/lib/server/proxy/responses/transcript.ts index 9744f6c..8a0c4c3 100644 --- a/lib/server/proxy/responses/transcript.ts +++ b/lib/server/proxy/responses/transcript.ts @@ -228,6 +228,18 @@ export const mapInputItemToMessage = ( }; } + // Call items the proxy itself mints and a stateless client replays verbatim + // from the previous response's `output`. They carry no text, so the + // plain-message case below would turn each one into an empty + // `{role:'user', content:''}` entry — one phantom user turn per search the + // previous turn ran, repeated on every later turn. + if ( + item.type === 'web_search_call' || + item.type === 'image_generation_call' + ) { + return null; + } + // Every other item type returns above, so what is left is a plain message: // either one with a declared `type: 'message'`, or one carrying only // `role`/`content`. Images are kept structured so the chat path can rebuild diff --git a/lib/server/proxy/server-tools/classify.ts b/lib/server/proxy/server-tools/classify.ts index b8e3f58..7274d81 100644 --- a/lib/server/proxy/server-tools/classify.ts +++ b/lib/server/proxy/server-tools/classify.ts @@ -160,16 +160,23 @@ const declarationName = (tool: unknown): string => { }; /** - * Whether any client-owned function collides with a server tool the proxy is - * about to inject. + * Whether any client-owned function collides with the name this proxy is about + * to register for `kind`. * * Both would arrive upstream under the same name, and a model calling it gets * no way to say which it meant — so the call is left to the client rather than * guessed at. This is the same normalization collision this file exists to * avoid, reached from the other side: two declarations this time, one by type * and one by name, that upstream cannot tell apart. + * + * Answered for one kind at a time, never once for the whole request: a + * collision on the fetch name is not a reason to withhold a search the client + * also declared, and a single request-wide verdict would do exactly that. */ -export const hasAmbiguousServerToolName = (tools: unknown): boolean => { +export const hasAmbiguousServerToolName = ( + tools: unknown, + kind: ServerToolKind, +): boolean => { if (!Array.isArray(tools)) { return false; } @@ -189,7 +196,7 @@ export const hasAmbiguousServerToolName = (tools: unknown): boolean => { return; } - if (classifyServerToolDeclaration(tool)) { + if (classifyServerToolDeclaration(tool) === kind) { serverNames.add(name); } else { clientNames.add(name); @@ -240,8 +247,12 @@ export const rewriteServerTools = ({ tools: unknown[]; }): RewrittenServerTools => { // Ambiguity is resolved in the client's favour; see - // {@link hasAmbiguousServerToolName}. - const ambiguous = hasAmbiguousServerToolName(tools); + // {@link hasAmbiguousServerToolName}. Read per kind, so that a clash on one + // server tool's name does not withhold the other. + const ambiguous = { + web_fetch: hasAmbiguousServerToolName(tools, 'web_fetch'), + web_search: hasAmbiguousServerToolName(tools, 'web_search'), + }; const maxUses = { web_fetch: readMaxUses(tools, 'web_fetch'), @@ -249,8 +260,9 @@ export const rewriteServerTools = ({ }; const executable: ServerToolDeclarations = { - fetch: declarations.fetch && Boolean(fetchProvider) && !ambiguous, - search: declarations.search && Boolean(searchProvider) && !ambiguous, + fetch: declarations.fetch && Boolean(fetchProvider) && !ambiguous.web_fetch, + search: + declarations.search && Boolean(searchProvider) && !ambiguous.web_search, }; /** diff --git a/lib/server/proxy/server-tools/turn.ts b/lib/server/proxy/server-tools/turn.ts index 55a636c..dab9840 100644 --- a/lib/server/proxy/server-tools/turn.ts +++ b/lib/server/proxy/server-tools/turn.ts @@ -15,6 +15,7 @@ import { import { findServerToolDeclarations, getForcedToolName, + reconcileToolChoice, type RewrittenServerTools, rewriteServerTools, } from './classify'; @@ -203,6 +204,26 @@ const keepOutstandingCalls = ( }; }; +/** + * Strips the proxy's own server-tool calls from a payload, whatever else it + * carries. + * + * Used on the paths that return a payload the client will see without having + * inspected it first — the failures above all. A `tool_use` for a `web_search` + * the client never declared is a call it has no handler for, and the internal + * function is not to be seen outside this turn; see the note in `classify.ts`. + */ +const keepClientCalls = ( + payload: ChatCompletionPayload, + isExecutableCall: (toolCall: ChatCompletionToolCall) => boolean, +): ChatCompletionPayload => + keepOutstandingCalls( + payload, + (payload.choices?.[0]?.message?.tool_calls ?? []).filter( + (toolCall) => !isExecutableCall(toolCall), + ), + ); + /** * Reads a hop's payload, converting a malformed body into an error. * @@ -312,8 +333,13 @@ export const runServerToolTurn = async ({ tools, // Only the first hop honours a forced server tool; after that the // model chooses, or it would never stop searching. + // + // Reconciled as well as relaxed: a declaration nothing here can run is + // withdrawn from `tools`, so a choice still naming it would point at a + // tool the request no longer offers — which some upstreams reject + // outright. A pin naming one of the client's own tools is untouched. tool_choice: firstHop - ? body.tool_choice + ? reconcileToolChoice(body.tool_choice, tools) : relaxToolChoice(body.tool_choice, classifyCall), }, false, @@ -333,7 +359,12 @@ export const runServerToolTurn = async ({ // really billed, and the Responses and image paths recover them from // the response rather than from the return value. response: attachServerToolExecutions( - rebuildResponse(response, JSON.stringify(withUsage(payload, usage))), + rebuildResponse( + response, + JSON.stringify( + keepClientCalls(withUsage(payload, usage), isExecutableCall), + ), + ), executions, ), usage, @@ -468,23 +499,34 @@ export const runServerToolTurn = async ({ // when it has run out. OR-ing the two, or counting every declared kind, // would end the turn while one of them still had allowance left — or // never end it at all for a kind that was declared but never used. + // + // A hop that could afford nothing ends the turn for the same reason, and + // it is not covered by the test above: when two kinds are runnable and the + // model keeps asking only for the one that is exhausted, that test stays + // false while the hop executes nothing — so the transcript never advances + // and the loop would re-issue an identical request forever, each one a real + // billed round trip. const spent = - (!executable.search || searches >= maxUses.web_search) && - (!executable.fetch || fetches >= maxUses.web_fetch); + !affordable.length || + ((!executable.search || searches >= maxUses.web_search) && + (!executable.fetch || fetches >= maxUses.web_fetch)); if (spent) { const finalResponse = await callUpstream( { ...body, messages: transcript, - ...withoutServerTools(tools, isExecutableCall), + ...withoutServerTools(tools, isExecutableCall, body.tool_choice), }, false, ); const finalBuffered = await finalResponse.text(); - const finalPayload = await readBufferedChatCompletionPayload( - rebuildResponse(finalResponse, finalBuffered), + // Read safely, like every other hop: a closing call that answers with a + // body that will not parse would otherwise throw out of the turn, and + // every search already run would be billed and lost. + const finalPayload = await readBufferedPayloadSafely( + finalResponse, finalBuffered, ); @@ -499,7 +541,12 @@ export const runServerToolTurn = async ({ response: attachServerToolExecutions( rebuildResponse( finalResponse, - JSON.stringify(withUsage(finalPayload, usage)), + JSON.stringify( + keepClientCalls( + withUsage(finalPayload, usage), + isExecutableCall, + ), + ), ), executions, ), @@ -513,10 +560,6 @@ export const runServerToolTurn = async ({ * answer them, and the client never declared a `web_search` it could * resolve itself. Anything it *does* own survives. */ - const finalCalls = ( - finalPayload.choices?.[0]?.message?.tool_calls ?? [] - ).filter((toolCall) => !isExecutableCall(toolCall)); - return { executions, segments, @@ -524,7 +567,7 @@ export const runServerToolTurn = async ({ rebuildResponse( finalResponse, JSON.stringify( - withUsage(keepOutstandingCalls(finalPayload, finalCalls), usage), + keepClientCalls(withUsage(finalPayload, usage), isExecutableCall), ), ), executions, @@ -562,6 +605,7 @@ const takeWithinBudget = ( const withoutServerTools = ( tools: unknown[], isExecutableCall: (toolCall: ChatCompletionToolCall) => boolean, + toolChoice: unknown, ): { tool_choice: unknown; tools: unknown[] } => { const remaining = tools.filter((tool) => { const name = asRecord(asRecord(tool)?.function)?.name; @@ -574,8 +618,14 @@ const withoutServerTools = ( // No `tool_choice` once nothing is left to choose: naming a tool that is not // on offer is a contradiction some upstreams reject outright, and every // search already run lives in this hop's transcript. + // + // Reconciled rather than reset to `auto`, so a pin the client set on one of + // its own tools survives the withdrawal — only one naming a withdrawn server + // tool goes, and a client tool left unpinned still gets `auto`. return { - tool_choice: remaining.length ? 'auto' : undefined, + tool_choice: remaining.length + ? (reconcileToolChoice(toolChoice, remaining) ?? 'auto') + : undefined, tools: remaining, }; }; diff --git a/tests/server/server-tools.test.ts b/tests/server/server-tools.test.ts index de530a7..4441e6c 100644 --- a/tests/server/server-tools.test.ts +++ b/tests/server/server-tools.test.ts @@ -93,6 +93,43 @@ const assistantToolCall = ( usage: { total_tokens: 10 }, }); +/** An upstream that died behind a gateway: an HTML page on a success status. */ +const makeHtmlResponse = (): Response => + new Response('502 Bad Gateway', { + headers: { 'Content-Type': 'text/html' }, + status: 200, + }); + +/** + * The tool calls a response still asks the client to answer, and the + * `finish_reason` that introduces them. + * + * Both halves of the same question: a `tool_use` left in the payload is a call + * the client has to resolve, and a `tool_calls` finish reason is what makes it + * wait for one. + */ +const outstandingCalls = async ( + response: Response, +): Promise<{ + finishReason: string | null | undefined; + names: Array; +}> => { + const payload = (await response.json()) as { + choices?: Array<{ + finish_reason?: string | null; + message?: { tool_calls?: Array<{ function?: { name?: string } }> }; + }>; + }; + const choice = payload.choices?.[0]; + + return { + finishReason: choice?.finish_reason, + names: (choice?.message?.tool_calls ?? []).map( + (call) => call.function?.name, + ), + }; +}; + describe('server tool classification', () => { describe('declarations', () => { it('recognises an Anthropic dated server tool type', () => { @@ -191,34 +228,40 @@ describe('server tool classification', () => { // calling it ambiguous disabled the server tool outright — the client // then received a `web_search` tool_use it had no handler for. expect( - hasAmbiguousServerToolName([ - { type: SEARCH_TYPE, name: 'web_search' }, - claudeCodeWebSearch, - ]), + hasAmbiguousServerToolName( + [{ type: SEARCH_TYPE, name: 'web_search' }, claudeCodeWebSearch], + 'web_search', + ), ).toBe(false); }); it('flags a genuine name clash: the same name, two kinds', () => { // Here the model really could not say which it meant, so neither runs. expect( - hasAmbiguousServerToolName([ - { type: SEARCH_TYPE, name: 'web_search' }, - { name: 'web_search', input_schema: {} }, - ]), + hasAmbiguousServerToolName( + [ + { type: SEARCH_TYPE, name: 'web_search' }, + { name: 'web_search', input_schema: {} }, + ], + 'web_search', + ), ).toBe(true); }); it('is not confused by a client function of another name', () => { expect( - hasAmbiguousServerToolName([ - { type: SEARCH_TYPE, name: 'web_search' }, - { name: 'Read', input_schema: {} }, - ]), + hasAmbiguousServerToolName( + [ + { type: SEARCH_TYPE, name: 'web_search' }, + { name: 'Read', input_schema: {} }, + ], + 'web_search', + ), ).toBe(false); }); it('ignores a non-array tool list', () => { - expect(hasAmbiguousServerToolName(undefined)).toBe(false); + expect(hasAmbiguousServerToolName(undefined, 'web_search')).toBe(false); }); /** @@ -262,6 +305,60 @@ describe('server tool classification', () => { expect(rewrite?.executable).toEqual({ fetch: false, search: false }); expect(hasExecutableServerTool(rewrite!.executable)).toBe(false); }); + + /** + * Ambiguity belongs to one name, so it is answered for one kind at a time. + * A single request-wide verdict withheld the search over a clash on the + * fetch's name, and the client got no server tool at all for a collision + * it had nothing to do with. + */ + it('withholds only the server tool whose name collides', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: true, search: true }, + fetchProvider: makeFetchProvider(), + searchProvider: makeSearchProvider(), + tools: [ + { type: SEARCH_TYPE, name: 'web_search' }, + { type: FETCH_TYPE, name: 'web_fetch' }, + // The client's own function, spelled exactly like the fetch tool: + // upstream cannot tell the two apart, so that one is left to it. + { name: 'web_fetch', input_schema: {} }, + ], + }); + + expect(rewrite?.executable).toEqual({ fetch: false, search: true }); + expect(hasExecutableServerTool(rewrite!.executable)).toBe(true); + // The search is still ours to run, and the fetch call goes back to the + // client — the one that declared a function of that name. + expect( + rewrite?.isExecutableCall({ function: { name: 'web_search' } }), + ).toBe(true); + expect( + rewrite?.isExecutableCall({ function: { name: 'web_fetch' } }), + ).toBe(false); + }); + + it('leaves the fetch runnable when the search name is what collides', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: true, search: true }, + fetchProvider: makeFetchProvider(), + searchProvider: makeSearchProvider(), + tools: [ + { type: SEARCH_TYPE, name: 'web_search' }, + { type: FETCH_TYPE, name: 'web_fetch' }, + { name: 'web_search', input_schema: {} }, + ], + }); + + expect(rewrite?.executable).toEqual({ fetch: true, search: false }); + expect(hasExecutableServerTool(rewrite!.executable)).toBe(true); + expect( + rewrite?.isExecutableCall({ function: { name: 'web_fetch' } }), + ).toBe(true); + expect( + rewrite?.isExecutableCall({ function: { name: 'web_search' } }), + ).toBe(false); + }); }); describe('rewriteServerTools', () => { @@ -1193,6 +1290,76 @@ describe('server tool loop', () => { const ask = (query: string, id = 'call_1') => assistantToolCall('web_search', `{"query":"${query}"}`, id); + /** + * Both kinds runnable, which is the only shape in which a hop can afford + * nothing while the turn still has allowance left: with one kind alone, the + * hop that asks for it either runs or ends the turn. + * + * The last hop repeats forever, so a model that never stops asking is + * scripted by giving it a single hop. + */ + const scriptedBoth = ( + hops: Array>, + budgets: { fetch: number; search: number } = { fetch: 5, search: 5 }, + ): { + run: () => Promise; + upstreamCalls: () => number; + } => { + let calls = 0; + + const run = (): Promise => + runServerToolTurn({ + body, + callUpstream: async () => { + calls += 1; + + // A ceiling rather than an endless script. A turn that stops + // terminating has to fail this test, not hang the suite with it. + if (calls > 25) { + throw new Error( + `the turn did not terminate: ${calls} upstream calls`, + ); + } + + return makeJsonResponse(hops[Math.min(calls - 1, hops.length - 1)]); + }, + fetchProvider: makeFetchProvider(), + rewrite: rewriteServerTools({ + declarations: { fetch: true, search: true }, + fetchProvider: makeFetchProvider(), + searchProvider: makeSearchProvider(), + tools: [ + { type: SEARCH_TYPE, name: 'web_search', max_uses: budgets.search }, + { type: FETCH_TYPE, name: 'web_fetch', max_uses: budgets.fetch }, + ], + }), + searchProvider: makeSearchProvider(), + }); + + return { run, upstreamCalls: () => calls }; + }; + + /** + * The model never stops asking, but only ever asks for the kind whose budget + * is gone. Such a hop executes nothing and appends nothing, so the transcript + * never advances and the loop re-issues an identical request — each one a + * real billed round trip. Affordability is per hop, so that is a reason to + * stop even though the other kind still has allowance left. + */ + it('terminates when the model only ever asks for the spent kind', async () => { + const { run, upstreamCalls } = scriptedBoth( + [assistantToolCall('web_fetch', '{"url":"https://a.test"}', 'call_1')], + { fetch: 1, search: 8 }, + ); + + const outcome = await run(); + + // The first fetch ran; the second ask could afford nothing. + expect(outcome.executions).toHaveLength(1); + // One hop that ran, one that could not, and one closing call. + expect(upstreamCalls()).toBe(3); + }); + /** * The point of a server tool: the model refines its query and searches * again, all inside the one request the client sent. @@ -1458,3 +1625,114 @@ describe('server tool budgets and wire shape', () => { }); }); }); + +/** + * What has to survive a hop that goes wrong. The searches have already run and + * been billed by then, and the proxy's own server-tool function must never + * reach the client: the client declared a *provider-executed* tool, so it has + * no handler for the name at all. + */ +describe('a hop that goes wrong', () => { + const searchOnly = (maxUses: number) => + rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [{ type: SEARCH_TYPE, name: 'web_search', max_uses: maxUses }], + }); + + /** + * A failure carrying both an `error` and a call that will never be answered. + * Handing the payload through as it arrived gave the client a `tool_use` for + * the proxy's internal `web_search` — a tool it never declared — and a finish + * reason that made it wait for a result that was never coming. + */ + it('hands a failed hop back with none of the proxy’s own calls', async () => { + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => + makeJsonResponse({ + ...assistantToolCall('web_search', '{"query":"q"}'), + error: { message: 'upstream exploded' }, + }), + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + }); + + await expect(outstandingCalls(outcome.response)).resolves.toEqual({ + finishReason: 'stop', + names: [], + }); + }); + + it('does so on a failed closing call too, keeping the search that ran', async () => { + let calls = 0; + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => { + calls += 1; + + return calls === 1 + ? makeJsonResponse(assistantToolCall('web_search', '{"query":"q"}')) + : makeJsonResponse({ + ...assistantToolCall('web_search', '{"query":"q"}', 'call_2'), + error: { message: 'rate limited' }, + }); + }, + fetchProvider: null, + // One use, so the closing call comes straight after the first search. + rewrite: searchOnly(1), + searchProvider: makeSearchProvider(), + }); + + // Billed before the failure, and not lost with it. + expect(outcome.executions).toHaveLength(1); + await expect(outstandingCalls(outcome.response)).resolves.toEqual({ + finishReason: 'stop', + names: [], + }); + }); + + /** + * Every other hop reads its body through a guard that turns an unparseable + * body into an error; the closing call used to read it directly and threw, + * discarding every search the turn had already run and paid for. + */ + it('keeps the searches when the closing call answers with a body that will not parse', async () => { + let calls = 0; + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => { + calls += 1; + + return calls < 3 + ? makeJsonResponse( + assistantToolCall( + 'web_search', + `{"query":"q${calls}"}`, + `call_${calls}`, + ), + ) + : makeHtmlResponse(); + }, + fetchProvider: null, + rewrite: searchOnly(2), + searchProvider: makeSearchProvider(), + }); + + // Two searches and the closing call that failed to speak JSON: resolving at + // all is the point, and the turn must not have asked again. + expect(calls).toBe(3); + expect(outcome.executions).toHaveLength(2); + // Still recoverable from the response, which is where the Responses path + // reads them from. + expect(getServerToolExecutions(outcome.response)).toHaveLength(2); + const payload = (await outcome.response.json()) as { + error?: { message?: string }; + }; + + // Surfaced as a failure rather than swallowed. + expect(payload.error?.message).toBeTruthy(); + }); +}); diff --git a/tests/server/web-search.test.ts b/tests/server/web-search.test.ts index 839419f..e826f37 100644 --- a/tests/server/web-search.test.ts +++ b/tests/server/web-search.test.ts @@ -28,6 +28,7 @@ import { import { translateResponsesToolsToChat } from '@/lib/server/proxy/responses'; import { handleResponsesRequest } from '@/lib/server/proxy/responses'; import { handleMessagesRequest } from '@/lib/server/proxy/anthropic'; +import { mapAnthropicToolsToChat } from '@/lib/server/proxy/anthropic/request'; const SEARXNG_ENV_NAMES = [ 'SEARXNG_URL', @@ -581,6 +582,50 @@ describe('responses tool translation', () => { }); }); +/** + * §9. The declared type is the only thing that carries the server-tool / + * client-tool distinction through translation — `normalizeToolName` makes + * `WebSearch` and `web_search` the same string, so the name cannot. The + * Responses translator is covered above; this is the Anthropic one, whose + * declaration is the shape Claude Code's side request actually sends. + */ +describe('anthropic tool translation', () => { + it('keeps a declared server tool’s type and its max_uses', () => { + // `max_uses` is on the declaration Claude Code sends but not on + // `AnthropicTool`, so it enters through a spread — the same way the side + // request builds its own declaration. The translator spreads every field + // the client declared through, so it belongs in what is asserted here. + const translated = mapAnthropicToolsToChat([ + { + input_schema: {}, + name: 'web_search', + type: 'web_search_20250305', + ...{ max_uses: 8 }, + }, + ]) as Array>; + + expect(translated).toHaveLength(1); + // Downstream classification reads the type, so it has to survive — and the + // budget rides on the declaration the client sent, not on a default. + expect(translated[0]).toMatchObject({ + max_uses: 8, + type: 'web_search_20250305', + }); + // Reshaped into a function upstream can call, but still the same tool. + expect(translated[0]).toMatchObject({ function: { name: 'web_search' } }); + }); + + it('translates Claude Code’s bare WebSearch as an ordinary function', () => { + const translated = mapAnthropicToolsToChat([ + { name: 'WebSearch', input_schema: {} }, + ]) as Array<{ type: string; function: { name: string } }>; + + // No `type` on the way in means the client resolves the call itself. + expect(translated[0].type).toBe('function'); + expect(translated[0].function.name).toBe('WebSearch'); + }); +}); + describe('server tool routing', () => { const tempRootDir = path.join(process.cwd(), '.tmp-servertool-route-root'); const tempDataDir = path.join(tempRootDir, '.codebuddy_data'); @@ -936,6 +981,21 @@ describe('server tool routing', () => { }; }; + /** + * The queries the turn actually put to the search provider, one per call. + * + * Read off the fetch spy rather than a counter on the hop mock: `routed` + * answers the provider before it counts the call, so a search is invisible + * to `calls()` — which is the point, but it means only this can prove what + * was searched for. + */ + const providerQueries = (): Array => + vi + .mocked(globalThis.fetch) + .mock.calls.map(([input]) => String(input)) + .filter((url) => url.includes('searx.test')) + .map((url) => new URL(url).searchParams.get('q')); + const searchCall = (query: string, id: string) => ({ choices: [ { @@ -1391,6 +1451,138 @@ describe('server tool routing', () => { // switch the whole feature off. expect(payload.usage.server_tool_use.web_search_requests).toBe(1); }); + + /** + * Both declarations in one request, and the model calls both on the first + * hop — the one request where §23-A and §23-B conflict. + * + * `WebSearch` and `web_search` are the same string once case and separators + * are stripped, so only the declared type can tell them apart. Get it wrong + * in either direction and Claude Code loses: run the client's `WebSearch` + * here and it never receives the `tool_use` it needs (§26), or withhold the + * server tool and the side request it is waiting on never searches. + */ + it('runs the server tool and still hands the client’s own WebSearch back', async () => { + await enableSearch(); + const { calls, run } = routed( + [ + { + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + role: 'assistant', + tool_calls: [ + { + id: 'call_server', + type: 'function', + function: { + arguments: '{"query":"OpenAI updates 2026"}', + name: 'web_search', + }, + }, + { + id: 'call_client', + type: 'function', + function: { + arguments: '{"query":"OpenAI updates 2026"}', + name: 'WebSearch', + }, + }, + ], + }, + }, + ], + usage: { completion_tokens: 10, prompt_tokens: 100 }, + }, + answer('Here it is.'), + ], + { + max_tokens: 2048, + messages: [ + { + role: 'user', + content: + 'Perform a web search for the query: OpenAI updates 2026', + }, + ], + tools: [ + { + type: 'web_search_20250305', + name: 'web_search', + max_uses: 8, + input_schema: {}, + }, + { + name: 'WebSearch', + description: 'Search the web', + input_schema: { type: 'object' }, + }, + ], + }, + ); + + const payload = (await run().then((r) => r.json())) as { + content: Array<{ name?: string; type: string }>; + stop_reason: string; + usage: { server_tool_use: { web_search_requests: number } }; + }; + + // The declared server tool really ran, against the provider. + expect(providerQueries()).toHaveLength(1); + expect(payload.usage.server_tool_use.web_search_requests).toBe(1); + + // ...and it is reported structurally, never as a `tool_use` — a client + // that declared a provider-executed tool has no handler for one. + expect(payload.content.map((block) => block.type)).toEqual([ + 'server_tool_use', + 'web_search_tool_result', + 'tool_use', + ]); + expect(payload.content[0].name).toBe('web_search'); + + // The client's own call survived, and it is the only `tool_use` here. + expect( + payload.content.filter((block) => block.type === 'tool_use'), + ).toEqual([expect.objectContaining({ name: 'WebSearch' })]); + expect(payload.stop_reason).toBe('tool_use'); + + // The turn stops at this hop: continuing would replay an assistant + // message whose client call has no result behind it. + expect(calls()).toBe(1); + }); + + /** + * §11 and §26: the query that reaches the provider has to be the one the + * model emitted as its `web_search` argument, never one extracted from the + * prompt. Every other phase-2 test uses the same string in both places, so + * a regression that regex-scraped the prompt would pass them all. + */ + it('searches for the query the model emitted, not the one in the prompt', async () => { + await enableSearch(); + const { run } = routed( + [searchCall('IBM quantum 2026', 'call_1'), answer('Here it is.')], + sideRequest(8), + ); + + const response = await run(); + const payload = (await response.json()) as { + content: Array<{ input?: { query?: string }; type: string }>; + usage: { server_tool_use: { web_search_requests: number } }; + }; + + const searchedQueries = providerQueries(); + + // The prompt asked about OpenAI; the model chose IBM. Only the model's + // argument may reach the provider. + expect(searchedQueries).toEqual(['IBM quantum 2026']); + expect(payload.usage.server_tool_use.web_search_requests).toBe(1); + expect(payload.content[0]).toMatchObject({ + input: { query: 'IBM quantum 2026' }, + type: 'server_tool_use', + }); + }); }); /** From 681436ccc8ee2c3c47c6ff328e2dd6c2e52a54af Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Fri, 18 Sep 2026 12:24:40 +0800 Subject: [PATCH 8/9] fix(responses): carry the server-tool turn through the image loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects on the Responses path, both confirmed against the real handler rather than inferred. A hosted-tool `tool_choice` was rejected outright: `{type: 'web_search_preview'}` matches none of the three shapes the validator accepts, so pinning search returned 400. Past validation it would have failed anyway — the choice was forwarded unchanged as a type the chat upstream has never seen, while the declaration itself had been rewritten into an ordinary function. The pin is what makes the model emit a query instead of answering from memory, so it is now rewritten to name the function the proxy actually injects. With no backend configured the pin is dropped rather than naming a withdrawn tool, and the request answers from memory instead of failing. The image-generation loop replayed each round without the turn's searches. The turn builds its continuation against a transcript it keeps to itself, so the replayed request asked the model to continue from input in which the search it had just run did not exist: round 1: [user, assistant[web_search], tool] round 2: [user, assistant[image_generation], tool] <- search gone The turn now hands back the messages it appended, on the same out-of-band channel it already used for executions, and the loop splices them in ahead of its own message. Co-Authored-By: Claude Fable 5 --- lib/server/proxy/image-generation.ts | 11 ++ lib/server/proxy/responses/tools.ts | 38 +++++- lib/server/proxy/server-tools/index.ts | 1 + lib/server/proxy/server-tools/turn.ts | 26 +++- lib/server/proxy/server-tools/types.ts | 32 +++++ tests/server/image-generation.test.ts | 114 ++++++++++++++++ tests/server/web-search.test.ts | 173 +++++++++++++++++++++++++ 7 files changed, 392 insertions(+), 3 deletions(-) diff --git a/lib/server/proxy/image-generation.ts b/lib/server/proxy/image-generation.ts index 5b1e19f..7cc7682 100644 --- a/lib/server/proxy/image-generation.ts +++ b/lib/server/proxy/image-generation.ts @@ -25,6 +25,7 @@ import { buildUpstreamHeaders } from './codebuddy'; import { foldIntermediateTexts, getServerToolExecutions, + getServerToolFollowUpMessages, type ChatCompletionMessage, type ChatCompletionPayload, type ChatCompletionToolCall, @@ -447,6 +448,12 @@ export const executeImageGenerationLoop = async ({ // rebuilt response the caller can no longer look them up on. serverToolExecutions.push(...getServerToolExecutions(response)); + // Likewise the messages the server-tool turn appended to its own + // transcript. It ran its searches against a transcript it built internally + // and never handed back, so a loop that replays the request would ask the + // model to continue from input in which those searches do not exist. + const followUpMessages = getServerToolFollowUpMessages(response); + // A stream has already begun emitting to the client, so it cannot be // resumed with a tool result; hand it back untouched. if ( @@ -530,6 +537,10 @@ export const executeImageGenerationLoop = async ({ ? [...currentBody.messages] : []; + // Ahead of this round's own message: the turn's hops are what came before + // it, and dropping them loses the searches that produced this round. + messages.push(...followUpMessages); + if (message) { messages.push(message); } diff --git a/lib/server/proxy/responses/tools.ts b/lib/server/proxy/responses/tools.ts index 686d839..b53946c 100644 --- a/lib/server/proxy/responses/tools.ts +++ b/lib/server/proxy/responses/tools.ts @@ -17,6 +17,10 @@ import { IMAGE_GENERATION_CHAT_TOOL_NAME, IMAGE_GENERATION_TOOL_TYPE, } from '../image-generation'; +import { + classifyServerToolDeclaration, + type ServerToolKind, +} from '../server-tools'; import type { ResponsesRequestBody, SupportedChatTool, @@ -409,6 +413,20 @@ export const translateResponsesToolsToChat = ( }); }; +/** + * The function a hosted-tool `tool_choice` has to name. + * + * The choice repeats the declared type — `web_search_preview` — and that is a + * shape upstream has never seen: the declaration was rewritten into an ordinary + * function on its way out. Naming the injected function is what lets the pin do + * its job, which is to make the model emit a query instead of answering from + * memory. + */ +const SERVER_TOOL_CHOICE_NAMES: Record = { + web_fetch: WEB_FETCH_TOOL_NAME, + web_search: WEB_SEARCH_TOOL_NAME, +}; + export const translateResponsesToolChoiceToChat = ( toolChoice: unknown, ): unknown => { @@ -435,6 +453,19 @@ export const translateResponsesToolChoiceToChat = ( return choice.type; } + // A hosted-tool choice names the same declaration the tools array carries + // under its own type, so classification recognises it. Upstream only ever + // sees the function the proxy injected, and a pin left as + // `web_search_preview` is a shape it has never heard of. + const serverToolKind = classifyServerToolDeclaration(choice); + + if (serverToolKind) { + return { + type: 'function', + function: { name: SERVER_TOOL_CHOICE_NAMES[serverToolKind] }, + }; + } + // Responses API selects a function by name: // {type: 'function', name: 'fn'} -> chat schema {type: 'function', function: {name: 'fn'}} if (typeof choice.name === 'string') { @@ -526,11 +557,16 @@ export const getResponsesCompatibilityError = ( choice.type === 'none' || choice.type === 'required'; const isNamedFunctionLikeChoice = typeof choice.name === 'string'; + // A hosted tool is pinned by its declared type — the same vocabulary the + // tools array uses, so the classifier recognises it. Rejecting it here + // 400s a request this adapter can serve; the choice is rewritten below. + const isHostedToolChoice = classifyServerToolDeclaration(choice) !== null; if ( !isPretranslatedFunctionChoice && !isSimpleChoiceType && - !isNamedFunctionLikeChoice + !isNamedFunctionLikeChoice && + !isHostedToolChoice ) { return createErrorResponse( 400, diff --git a/lib/server/proxy/server-tools/index.ts b/lib/server/proxy/server-tools/index.ts index 72f5c51..e3a3082 100644 --- a/lib/server/proxy/server-tools/index.ts +++ b/lib/server/proxy/server-tools/index.ts @@ -29,6 +29,7 @@ export { attachServerToolExecutions, EMPTY_PREAMBLE, getServerToolExecutions, + getServerToolFollowUpMessages, STREAM_TEXT_CHUNK_LENGTH, } from './types'; export type { diff --git a/lib/server/proxy/server-tools/turn.ts b/lib/server/proxy/server-tools/turn.ts index dab9840..22e196f 100644 --- a/lib/server/proxy/server-tools/turn.ts +++ b/lib/server/proxy/server-tools/turn.ts @@ -315,6 +315,16 @@ export const runServerToolTurn = async ({ // calls it made. Kept as a list because prose written between two searches // belongs between the two search blocks, and flattening it loses that. const segments: ServerToolSegment[] = []; + /** + * Only the messages this turn appended to the transcript it was handed. + * + * The turn builds its continuation internally, but a caller that drives + * upstream across several rounds — the image-generation loop — replays the + * request from its own copy of the messages. It has to be given these, or the + * next round's model is asked to continue a turn whose searches it cannot + * see: the findings were fed to the model once and then discarded. + */ + const appended: JsonRecord[] = []; let transcript = asMessages(body); let usage: unknown = null; // Counted separately: the client declares `max_uses` on each server tool, so @@ -354,6 +364,7 @@ export const runServerToolTurn = async ({ if (!response.ok || payload.error) { return { executions, + followUpMessages: appended, segments, // Attached even on failure: the earlier hops really ran and were // really billed, and the Responses and image paths recover them from @@ -366,6 +377,7 @@ export const runServerToolTurn = async ({ ), ), executions, + appended, ), usage, }; @@ -388,10 +400,12 @@ export const runServerToolTurn = async ({ if (!localCalls.length) { return { executions, + followUpMessages: appended, segments, response: attachServerToolExecutions( rebuildResponse(response, JSON.stringify(withUsage(payload, usage))), executions, + appended, ), usage, }; @@ -446,8 +460,7 @@ export const runServerToolTurn = async ({ })); if (runCalls.length) { - transcript = [ - ...transcript, + const hopMessages: JsonRecord[] = [ { ...(message as JsonRecord), content: message?.content ?? null, @@ -460,6 +473,9 @@ export const runServerToolTurn = async ({ tool_call_id: result.tool_call_id, })), ]; + + appended.push(...hopMessages); + transcript = [...transcript, ...hopMessages]; } /** @@ -473,6 +489,7 @@ export const runServerToolTurn = async ({ if (remainingCalls.length) { return { executions, + followUpMessages: appended, segments, response: attachServerToolExecutions( rebuildResponse( @@ -485,6 +502,7 @@ export const runServerToolTurn = async ({ ), ), executions, + appended, ), usage, }; @@ -535,6 +553,7 @@ export const runServerToolTurn = async ({ if (!finalResponse.ok || finalPayload.error) { return { executions, + followUpMessages: appended, segments, // Attached even on failure: the searches really ran and were really // billed, and the Responses path recovers them from the response. @@ -549,6 +568,7 @@ export const runServerToolTurn = async ({ ), ), executions, + appended, ), usage, }; @@ -562,6 +582,7 @@ export const runServerToolTurn = async ({ */ return { executions, + followUpMessages: appended, segments, response: attachServerToolExecutions( rebuildResponse( @@ -571,6 +592,7 @@ export const runServerToolTurn = async ({ ), ), executions, + appended, ), usage, }; diff --git a/lib/server/proxy/server-tools/types.ts b/lib/server/proxy/server-tools/types.ts index acb2dd4..322c501 100644 --- a/lib/server/proxy/server-tools/types.ts +++ b/lib/server/proxy/server-tools/types.ts @@ -130,6 +130,17 @@ export interface ServerToolSegment { export interface ServerToolTurnOutcome { /** Calls executed locally, in the order the model made them. */ executions: ServerToolExecution[]; + /** + * The messages the turn appended to the transcript it was handed: the + * assistant messages carrying its calls, and the tool results behind them. + * + * The turn builds its continuation internally, so a caller that drives + * upstream across several rounds — the image-generation loop — has to splice + * these into its own copy of the messages. Without them the next round asks + * the model to continue a turn whose findings are nowhere in its input: the + * search ran, was billed, and was then thrown away. + */ + followUpMessages: JsonRecord[]; /** * The hops, each with the prose that preceded it. The closing answer is not * here — it is in `response`. @@ -154,14 +165,27 @@ export interface ServerToolTurnOutcome { */ const serverToolExecutions = new WeakMap(); +const serverToolFollowUpMessages = new WeakMap(); + +/** + * Hangs both out-of-band channels on the response a turn hands back. + * + * `followUpMessages` ride along for the same reason `executions` do — see the + * note above — and only ever matter to a caller that drives upstream itself. + */ export const attachServerToolExecutions = ( response: Response, executions: ServerToolExecution[], + followUpMessages: JsonRecord[] = [], ): Response => { if (executions.length) { serverToolExecutions.set(response, executions); } + if (followUpMessages.length) { + serverToolFollowUpMessages.set(response, followUpMessages); + } + return response; }; @@ -169,6 +193,14 @@ export const getServerToolExecutions = ( response: Response, ): ServerToolExecution[] => serverToolExecutions.get(response) ?? []; +/** + * The messages a turn appended to the transcript it was handed, read off a + * response it produced. Empty when no server tool ran. + */ +export const getServerToolFollowUpMessages = ( + response: Response, +): JsonRecord[] => serverToolFollowUpMessages.get(response) ?? []; + /** * Adds two usage blocks together. * diff --git a/tests/server/image-generation.test.ts b/tests/server/image-generation.test.ts index 9c5f0c9..cedd9b9 100644 --- a/tests/server/image-generation.test.ts +++ b/tests/server/image-generation.test.ts @@ -382,6 +382,120 @@ describe('Responses image support', () => { ]); }); + /** + * The server-tool turn builds its continuation against a transcript it + * keeps to itself, and the image loop replays the request from its own copy + * of the messages. Without handing the turn's hops back, the replayed round + * asks the model to continue a turn whose searches are nowhere in its + * input: the search ran, was billed, and was then discarded. + */ + it('carries the server-tool turn’s searches into the replayed request', async () => { + const secret = await addCredentialWith(); + const previousSearxng = process.env.SEARXNG_URL; + + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + + try { + let chatCall = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { + const target = String(url); + + if (target.includes('searx.test')) { + return new Response( + JSON.stringify({ + results: [ + { content: 'a cat', title: 'Cats', url: 'https://cats.test' }, + ], + }), + { headers: { 'Content-Type': 'application/json' } }, + ) as unknown as Response; + } + + if (target.includes('/v2/images/generations')) { + return makeImageResponse([{ b64_json: 'QUJD' }]); + } + + chatCall += 1; + // 1: the model searches. 2: with the findings, it asks for an image. + // 3: the loop's replay, which is what this test is about. + return makeChatResponse( + chatCall === 1 + ? { + content: null, + role: 'assistant', + tool_calls: [ + { + function: { + arguments: '{"query":"cat photos"}', + name: 'web_search', + }, + id: 'call_search', + type: 'function', + }, + ], + } + : chatCall === 2 + ? { + content: null, + role: 'assistant', + tool_calls: [ + { + function: { + arguments: '{"prompt":"a cat"}', + name: 'image_generation', + }, + id: 'call_1', + type: 'function', + }, + ], + } + : { content: 'Here is your cat.' }, + ); + }); + + const response = await handleResponsesRequest(makeRequest(secret), { + input: 'find a cat photo and draw it', + model: 'claude-sonnet-4.6', + tools: [{ type: 'web_search_preview' }, { type: 'image_generation' }], + } as never); + + expect(response.status).toBe(200); + + const chatBodies = requestBodies().filter((body) => + Array.isArray(body.messages), + ); + const replayed = chatBodies.at(-1)?.messages as Array< + Record + >; + const shape = replayed.map((message) => { + const calls = message.tool_calls as + Array<{ function?: { name?: string } }> | undefined; + + return calls?.length + ? `assistant[${calls.map((call) => call.function?.name).join(',')}]` + : String(message.role); + }); + + expect(shape).toEqual([ + 'user', + 'assistant[web_search]', + 'tool', + 'assistant[image_generation]', + 'tool', + ]); + } finally { + if (previousSearxng === undefined) { + delete process.env.SEARXNG_URL; + } else { + process.env.SEARXNG_URL = previousSearxng; + } + + resetWebSearchProviders(); + } + }); + it('emits a failed image_generation_call when generation fails', async () => { const secret = await addCredentialWith(); let chatCall = 0; diff --git a/tests/server/web-search.test.ts b/tests/server/web-search.test.ts index e826f37..510cc42 100644 --- a/tests/server/web-search.test.ts +++ b/tests/server/web-search.test.ts @@ -1945,6 +1945,179 @@ describe('server tool routing', () => { expect(types).toContain('response.web_search_call.completed'); expect(types).toContain('response.output_item.done'); }); + + /** + * Answers each hop from `hops`, falling through on the last, and keeps + * every request body that reached upstream. + * + * The Responses path is non-streaming hop to hop regardless of `stream`: + * whether the model wants another search is only knowable once a hop has + * finished, so the turn buffers every one. + */ + const routed = ( + hops: Array>, + request: Record, + ) => { + const sent: Array> = []; + let calls = 0; + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { + const url = String(_input); + + if (url.includes('searx.test')) { + return makeJsonResponse({ + results: [ + { content: 'A snippet', title: 'Docs', url: 'https://docs.test' }, + ], + }) as unknown as Response; + } + + const body = JSON.parse(String(init?.body)) as Record; + sent.push(body); + const hop = hops[Math.min(calls, hops.length - 1)]; + calls += 1; + + return makeJsonResponse(hop) as unknown as Response; + }); + + return { + calls: () => calls, + run: () => + handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + request, + ), + sent, + }; + }; + + const searchHop = (query: string) => ({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + role: 'assistant', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + arguments: `{"query":"${query}"}`, + name: 'web_search', + }, + }, + ], + }, + }, + ], + usage: { completion_tokens: 10, prompt_tokens: 100 }, + }); + + const answerHop = (text: string) => ({ + choices: [ + { + finish_reason: 'stop', + message: { content: text, role: 'assistant' }, + }, + ], + usage: { completion_tokens: 20, prompt_tokens: 200 }, + }); + + /** A search declared and pinned by its hosted-tool type. */ + const pinned = { + input: 'any news?', + model: 'glm-5.1', + tool_choice: { type: 'web_search_preview' }, + tools: [{ type: 'web_search_preview' }], + }; + + /** + * The Responses API pins a hosted tool by its declared type, and the pin is + * load-bearing: it is what makes the model emit a query instead of + * answering from memory. It used to be rejected outright — 400, no search. + */ + it('runs the search when tool_choice pins the hosted tool', async () => { + await enableSearch(); + mockUpstream(); + + const response = await handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + pinned, + ); + + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + output: Array>; + }; + const types = payload.output.map((item) => item.type); + + expect(types).toContain('web_search_call'); + expect(types).toContain('message'); + // The pin held, so the search ran before the answer that used it. + expect(types.indexOf('web_search_call')).toBeLessThan( + types.indexOf('message'), + ); + }); + + it('sends the pin upstream as the function the proxy injected', async () => { + await enableSearch(); + const { run, sent } = routed( + [searchHop('OpenAI updates'), answerHop('Here it is.')], + pinned, + ); + + await run(); + + // Upstream has never heard of `web_search_preview`; the pin has to name + // the function the declaration was rewritten into. + expect(sent[0]?.tool_choice).toEqual({ + type: 'function', + function: { name: 'web_search' }, + }); + }); + + it('stops pinning the hosted tool after the first hop', async () => { + await enableSearch(); + const { run, sent } = routed( + [searchHop('OpenAI updates'), answerHop('Here it is.')], + pinned, + ); + + await run(); + + // Left pinned, the model would be forced to search forever instead of + // answering with what it found. + expect(sent[1]?.tool_choice).toBe('auto'); + }); + + it('drops the pin instead of failing when no backend is configured', async () => { + // No `enableSearch()`: `beforeEach` clears SEARXNG_URL, so nothing here + // can run the declared tool and it is withdrawn from the request. + const { run, sent } = routed( + [answerHop('It shipped in March, as I recall.')], + pinned, + ); + + const response = await run(); + + expect(response.status).toBe(200); + // A choice naming a tool the request no longer offers is a contradiction + // upstream rejects, so it goes rather than being sent as it is. + expect(sent[0]?.tool_choice).toBeUndefined(); + + const payload = (await response.json()) as { + output: Array>; + output_text: string; + }; + + expect(payload.output.map((item) => item.type)).not.toContain( + 'web_search_call', + ); + // Answering from memory is the honest degradation, not a 400. + expect(payload.output_text).toBe('It shipped in March, as I recall.'); + }); }); describe('/v1/chat/completions', () => { From 7413a3ec96f07f53392620b2667dc38a6a8b2182 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Fri, 18 Sep 2026 13:12:25 +0800 Subject: [PATCH 9/9] fix(server-tools): stop spending on a turn whose caller has gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client that hung up mid-turn was charged for the rest of the budget it declared. The loop had no way to know, so every remaining search ran, each followed by an upstream round trip, and the closing answer was written for nobody. The turn now takes an `AbortSignal` and stops at the next checkpoint — between hops, before executing, and before the closing call — handing back 499 (client closed request) with the searches already run still attached, as on every other non-success path. It does not abort mid-hop: an upstream call already in flight cannot be recalled more cheaply than letting it finish. `rebuildResponse` now labels what it returns as JSON rather than inheriting the upstream's content-type. Every caller hands it a `JSON.stringify(...)` result, so an upstream that answered a `stream: false` hop with SSE left a body claiming to be an event stream. Responses path: - Hosted-tool `tool_choice` now covers the tools this adapter actually serves. `image_generation` is executed here, so pinning it 400'd; it names the injected function instead. A pin on a declared-but-unimplemented hosted type is dropped rather than rejected, so the request is served without the tool instead of failing. Pinning a tool the request never declared still 400s. - `output_text` carries `url_citation` annotations for the search results the model actually quoted. The indices are measured, never guessed: a search knows the titles and URLs but nothing about where they land in an answer that did not exist yet, so a result whose URL never appears in the text gets no annotation. Co-Authored-By: Claude Fable 5 --- lib/server/proxy/anthropic.ts | 1 + lib/server/proxy/responses.ts | 1 + lib/server/proxy/responses/event-stream.ts | 2 + lib/server/proxy/responses/payload.ts | 88 +++- lib/server/proxy/responses/tools.ts | 74 +++- lib/server/proxy/server-tools/turn.ts | 72 ++++ tests/server/responses-search-output.test.ts | 326 ++++++++++++++ .../server/responses-stream-citations.test.ts | 398 ++++++++++++++++++ tests/server/server-tools.test.ts | 200 +++++++++ tests/server/web-search.test.ts | 74 ++++ 10 files changed, 1234 insertions(+), 2 deletions(-) create mode 100644 tests/server/responses-search-output.test.ts create mode 100644 tests/server/responses-stream-citations.test.ts diff --git a/lib/server/proxy/anthropic.ts b/lib/server/proxy/anthropic.ts index 235956d..424ee9d 100644 --- a/lib/server/proxy/anthropic.ts +++ b/lib/server/proxy/anthropic.ts @@ -102,6 +102,7 @@ export const handleMessagesRequest = async ( fetchProvider, rewrite, searchProvider, + signal: request.signal, }), ); diff --git a/lib/server/proxy/responses.ts b/lib/server/proxy/responses.ts index 38e7e0b..eba2377 100644 --- a/lib/server/proxy/responses.ts +++ b/lib/server/proxy/responses.ts @@ -255,6 +255,7 @@ export const handleResponsesRequest = async ( fetchProvider: serverTools!.providers.fetchProvider, rewrite, searchProvider: serverTools!.providers.searchProvider, + signal: request.signal, }), ); diff --git a/lib/server/proxy/responses/event-stream.ts b/lib/server/proxy/responses/event-stream.ts index bfe90d2..c784bc7 100644 --- a/lib/server/proxy/responses/event-stream.ts +++ b/lib/server/proxy/responses/event-stream.ts @@ -130,6 +130,7 @@ export const createResponsesEventStream = async ( fetchProvider: prepared!.providers.fetchProvider, rewrite, searchProvider: prepared!.providers.searchProvider, + signal: request.signal, }), ); @@ -294,6 +295,7 @@ export const createResponsesEventStream = async ( fetchProvider, rewrite: rewrite!, searchProvider, + signal: request.signal, }), ); diff --git a/lib/server/proxy/responses/payload.ts b/lib/server/proxy/responses/payload.ts index 52bc322..25a8362 100644 --- a/lib/server/proxy/responses/payload.ts +++ b/lib/server/proxy/responses/payload.ts @@ -9,6 +9,7 @@ import { eventFrameText, } from '../../shared/sse'; import type { ProxyContext } from '../codebuddy'; +import type { WebSearchResult } from '../../search/types'; import { buildResponsesImageGenerationCallItem, type ImageGenerationExecution, @@ -39,6 +40,80 @@ import type { ServerToolSegment, } from '../server-tools'; +/** A span of `output_text` that points at one search result. */ +interface UrlCitationSpan { + end: number; + start: number; + title: string; + url: string; +} + +/** + * The `url_citation` annotations for one `output_text`. + * + * Only URLs the model actually wrote into the text are annotated, because no + * index here can be derived: a search response carries titles and URLs but no + * offsets into an answer that does not exist yet, and the upstream chat + * protocol hands the answer back as a single opaque string — so choosing a + * span for a result would mean inventing one. A model that cites a source + * normally quotes its URL, and that occurrence is a span measured rather than + * guessed. A result whose URL never appears in the text gets no annotation. + */ +const buildUrlCitationAnnotations = ( + text: string, + results: WebSearchResult[], +): Array> => { + const claimed = new Set(); + + const spans = results.flatMap((result): UrlCitationSpan[] => { + const url = result.url ?? ''; + + // Two results can share a URL; a second span over identical offsets would + // overlap the first by definition. + if (!url || claimed.has(url)) { + return []; + } + + claimed.add(url); + const title = result.title || url; + const found: UrlCitationSpan[] = []; + + for ( + let start = text.indexOf(url); + start !== -1; + start = text.indexOf(url, start + url.length) + ) { + found.push({ end: start + url.length, start, title, url }); + } + + return found; + }); + + // Longest first at one offset: a result URL that prefixes another would + // otherwise take the shorter span and leave the longer one overlapping. + spans.sort((left, right) => left.start - right.start || right.end - left.end); + + const annotations: Array> = []; + let coveredUntil = 0; + + spans.forEach(({ end, start, title, url }) => { + if (start < coveredUntil) { + return; + } + + coveredUntil = end; + annotations.push({ + type: 'url_citation', + start_index: start, + end_index: end, + title, + url, + }); + }); + + return annotations; +}; + export const mapChatResponseToResponsesPayload = async ( accessKeyId: string | null, credentialFilename: string | null, @@ -148,6 +223,17 @@ export const mapChatResponseToResponsesPayload = async ( }); } + // Exactly the searches this response reports. Only those may be cited: a + // result the client saw no `web_search_call` for is not a source it can + // trace the citation back to. + const reportedExecutions = segments + ? segments.flatMap((segment) => segment.executions) + : serverToolExecutions; + + const citedResults = reportedExecutions.flatMap((execution) => + execution.type === 'web_search' ? (execution.result?.results ?? []) : [], + ); + if (outputText || !toolCalls.length) { output.push({ id: createMessageId(), @@ -158,7 +244,7 @@ export const mapChatResponseToResponsesPayload = async ( { type: 'output_text', text: outputText, - annotations: [], + annotations: buildUrlCitationAnnotations(outputText, citedResults), }, ], }); diff --git a/lib/server/proxy/responses/tools.ts b/lib/server/proxy/responses/tools.ts index b53946c..80e4d06 100644 --- a/lib/server/proxy/responses/tools.ts +++ b/lib/server/proxy/responses/tools.ts @@ -427,6 +427,55 @@ const SERVER_TOOL_CHOICE_NAMES: Record = { web_search: WEB_SEARCH_TOOL_NAME, }; +/** + * Image generation is executed by its own loop, never by the server-tool turn, + * so it is deliberately outside the classifier's vocabulary — widening that + * would have the turn claim a call it cannot run. It is still a tool this + * adapter serves, so it gets a branch of its own everywhere one is needed. + */ +const isImageGenerationToolChoice = ( + choice: Record, +): boolean => choice.type === IMAGE_GENERATION_TOOL_TYPE; + +/** + * A pin on a hosted type the request declared but this adapter withdraws. + * + * `getSupportedChatTools` drops a hosted declaration with no implementation + * here, so the tool never reaches upstream — and a pin naming it would be a + * choice with nothing behind it, which an upstream that validates the two + * together rejects. Serving the request without the tool is the honest + * degradation: the model answers from memory, which is what + * `reconcileToolChoice` already arranges for a server tool with no backend. + */ +const isWithdrawnToolChoice = ( + tools: ResponsesRequestBody['tools'], + toolChoice: unknown, +): boolean => { + if (typeof toolChoice !== 'object' || toolChoice === null) { + return false; + } + + const choice = toolChoice as Record; + const type = typeof choice.type === 'string' ? choice.type : ''; + + // Every other shape has a branch of its own, and none of them is a + // withdrawal: a function is pinned by name, and a hosted tool this adapter + // serves is translated rather than dropped. + if ( + !type || + type === 'function' || + typeof choice.name === 'string' || + isImageGenerationToolChoice(choice) || + classifyServerToolDeclaration(choice) !== null + ) { + return false; + } + + return Boolean( + tools?.some((tool) => typeof tool?.type === 'string' && tool.type === type), + ); +}; + export const translateResponsesToolChoiceToChat = ( toolChoice: unknown, ): unknown => { @@ -466,6 +515,16 @@ export const translateResponsesToolChoiceToChat = ( }; } + // The image tool is rewritten into a function on its way out too, so its pin + // has to name that function for the same reason a search pin does: upstream + // has never heard of `image_generation` as a tool type. + if (isImageGenerationToolChoice(choice)) { + return { + type: 'function', + function: { name: IMAGE_GENERATION_CHAT_TOOL_NAME }, + }; + } + // Responses API selects a function by name: // {type: 'function', name: 'fn'} -> chat schema {type: 'function', function: {name: 'fn'}} if (typeof choice.name === 'string') { @@ -482,6 +541,12 @@ export const translateResponsesToolChoiceToChatWithTools = ( tools: ResponsesRequestBody['tools'], toolChoice: unknown, ): unknown => { + // A withdrawn declaration is not on offer upstream, so its pin goes rather + // than being sent as a type upstream has never heard of. + if (isWithdrawnToolChoice(tools, toolChoice)) { + return undefined; + } + const translated = translateResponsesToolChoiceToChat(toolChoice); if (typeof translated !== 'object' || translated === null) { @@ -560,7 +625,14 @@ export const getResponsesCompatibilityError = ( // A hosted tool is pinned by its declared type — the same vocabulary the // tools array uses, so the classifier recognises it. Rejecting it here // 400s a request this adapter can serve; the choice is rewritten below. - const isHostedToolChoice = classifyServerToolDeclaration(choice) !== null; + // Three cases, and none of them is a client error: search and fetch name + // the injected function, image generation names its own, and a type no + // implementation here serves is dropped rather than pinned to a tool + // upstream is never offered. + const isHostedToolChoice = + classifyServerToolDeclaration(choice) !== null || + isImageGenerationToolChoice(choice) || + isWithdrawnToolChoice(tools, choice); if ( !isPretranslatedFunctionChoice && diff --git a/lib/server/proxy/server-tools/turn.ts b/lib/server/proxy/server-tools/turn.ts index 22e196f..c53a1ee 100644 --- a/lib/server/proxy/server-tools/turn.ts +++ b/lib/server/proxy/server-tools/turn.ts @@ -163,6 +163,10 @@ const rebuildResponse = (response: Response, body: string): Response => { headers.delete('content-encoding'); headers.delete('content-length'); headers.delete('transfer-encoding'); + // Every caller hands this a `JSON.stringify(...)` result, so an inherited + // label is a lie as soon as upstream answers a `stream: false` hop with SSE + // — and a client that trusts it tries to read an event stream out of JSON. + headers.set('content-type', 'application/json; charset=utf-8'); return new Response(body, { headers, @@ -224,6 +228,21 @@ const keepClientCalls = ( ), ); +/** + * The response for a turn whose client hung up mid-way. + * + * 499 is nginx's "client closed request". No standard status covers a request + * the caller abandoned, and all the callers do with a non-ok response is stop + * rendering — which is what is wanted, since nobody is listening. + */ +const abortedResponse = (): Response => + new Response( + JSON.stringify({ + error: { message: 'Client closed the request', status: 499 }, + }), + { headers: { 'content-type': 'application/json' }, status: 499 }, + ); + /** * Reads a hop's payload, converting a malformed body into an error. * @@ -296,6 +315,7 @@ export const runServerToolTurn = async ({ onResult, rewrite, searchProvider, + signal, }: { body: ChatRequestBody; /** One round trip to upstream. Always buffered. */ @@ -306,6 +326,11 @@ export const runServerToolTurn = async ({ /** Output of {@link rewriteServerTools} for this request. */ rewrite: NonNullable>; searchProvider: WebSearchProvider | null; + /** + * Aborted when the client hangs up. The turn stops spending at the next + * checkpoint rather than finishing the budget for a caller that has gone. + */ + signal?: AbortSignal; }): Promise => { const { classifyCall, executable, isExecutableCall, maxUses, tools } = rewrite; @@ -326,6 +351,39 @@ export const runServerToolTurn = async ({ */ const appended: JsonRecord[] = []; let transcript = asMessages(body); + + /** + * The turn as it stands, for a client that is no longer listening. + * + * The searches already run are attached exactly as on the failure paths: + * they really happened and really were billed, and a renderer that recovers + * them from the response is the only record of them. + */ + const aborted = (): ServerToolTurnOutcome => ({ + executions, + followUpMessages: appended, + segments, + response: attachServerToolExecutions( + abortedResponse(), + executions, + appended, + ), + usage, + }); + + /** + * Whether to stop spending on this turn. + * + * Checked between hops, and again before executing and before the closing + * call: a hop is a real upstream round trip and a search is a real backend + * call, and a client that hung up would otherwise be charged for the rest of + * the budget it declared — every remaining search, and the closing answer to + * go with them. + * + * Not checked mid-hop: an upstream call already in flight cannot be recalled + * any cheaper than letting it finish. + */ + const hangUp = (): boolean => signal?.aborted === true; let usage: unknown = null; // Counted separately: the client declares `max_uses` on each server tool, so // a fetch must not spend the search budget — but both need a bound, or a @@ -336,6 +394,10 @@ export const runServerToolTurn = async ({ let firstHop = true; while (true) { + if (hangUp()) { + return aborted(); + } + const response = await callUpstream( { ...body, @@ -431,6 +493,12 @@ export const runServerToolTurn = async ({ buildServerToolInvocation(toolCall, kind, callCounter++), ); + // Before executing rather than after: a search is real work at a real + // backend, and a client that has already hung up gets nothing from it. + if (hangUp()) { + return aborted(); + } + const results = await executeServerToolInvocations({ fetchProvider, invocations, @@ -530,6 +598,10 @@ export const runServerToolTurn = async ({ (!executable.fetch || fetches >= maxUses.web_fetch)); if (spent) { + if (hangUp()) { + return aborted(); + } + const finalResponse = await callUpstream( { ...body, diff --git a/tests/server/responses-search-output.test.ts b/tests/server/responses-search-output.test.ts new file mode 100644 index 0000000..8f27e39 --- /dev/null +++ b/tests/server/responses-search-output.test.ts @@ -0,0 +1,326 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { NextRequest } from 'next/server'; + +import { updateSettings } from '@/lib/server/domain/config'; +import { + addCredential, + resetCredentialRuntimeState, +} from '@/lib/server/domain/credentials'; +import { handleResponsesRequest } from '@/lib/server/proxy/responses'; +import { resetWebSearchProviders } from '@/lib/server/search'; + +/** + * The citations a Responses client reads off `output_text`. + * + * Real OpenAI emits a `url_citation` annotation per source so the client can + * render links against the prose. This proxy runs the search itself and the + * results never reached the response, so a client had nothing to show. + */ +describe('responses search output', () => { + const tempRootDir = path.join(process.cwd(), '.tmp-responses-citations-root'); + const tempDataDir = path.join(tempRootDir, '.codebuddy_data'); + + const SEARXNG_ENV_NAMES = [ + 'SEARXNG_URL', + 'SEARXNG_API_KEY', + 'SEARXNG_ENGINES', + 'SEARXNG_LANGUAGE', + 'SEARXNG_MAX_RESULTS', + 'SEARXNG_TIMEOUT_MS', + ] as const; + + /** An output item, as far as these tests read it. */ + interface OutputItem { + content?: Array>; + type: string; + } + + /** A `url_citation`, as the Responses API spells it. */ + interface UrlCitation { + end_index: number; + start_index: number; + title: string; + type: string; + url: string; + } + + interface TurnPayload { + output: OutputItem[]; + output_text: string; + } + + const clearSearxngEnv = (): void => { + for (const name of SEARXNG_ENV_NAMES) { + delete process.env[name]; + } + + resetWebSearchProviders(); + }; + + const cleanupDir = (): void => { + fs.rmSync(tempRootDir, { force: true, recursive: true, maxRetries: 5 }); + }; + + const makeJsonResponse = ( + payload: Record, + status = 200, + ): Response => + new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + + const makeRequest = (): NextRequest => + new NextRequest('http://localhost/v1/responses', { + method: 'POST', + headers: { authorization: 'Bearer responses-citations-token' }, + }); + + const enableSearch = async (): Promise => { + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + }; + + const searchHop = (content: string | null) => ({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content, + role: 'assistant', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + arguments: '{"query":"release date"}', + name: 'web_search', + }, + }, + ], + }, + }, + ], + }); + + const answerHop = (text: string) => ({ + choices: [ + { finish_reason: 'stop', message: { content: text, role: 'assistant' } }, + ], + }); + + /** + * One turn: the model asks for a search, the proxy serves `results`, and the + * model answers with `answer`. + * + * `tools` is what makes it a server-tool turn — the proxy only runs a search + * for a provider-executed declaration, so a request that declares none never + * reaches the backend at all. + */ + interface TurnOptions { + answer: string; + /** Text the model writes before it searches, when it writes any. */ + preamble?: string; + results: Array>; + tools?: Array>; + } + + const runTurn = async ({ + answer, + preamble, + results, + tools = [{ type: 'web_search_preview' }], + }: TurnOptions): Promise => { + let calls = 0; + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('searx.test')) { + return makeJsonResponse({ results }) as unknown as Response; + } + + calls += 1; + + return makeJsonResponse( + calls === 1 && tools.length + ? searchHop(preamble ?? null) + : answerHop(answer), + ) as unknown as Response; + }); + + const response = await handleResponsesRequest(makeRequest(), { + input: 'when did it ship?', + model: 'glm-5.1', + tools, + }); + + return (await response.json()) as TurnPayload; + }; + + /** Every assistant message, in the order it was written. */ + const messages = (payload: TurnPayload): OutputItem[] => + payload.output.filter((item) => item.type === 'message'); + + /** The annotations on one message; the last one unless told otherwise. */ + const annotationsOf = ( + payload: TurnPayload, + messageIndex = -1, + ): UrlCitation[] => + (messages(payload).at(messageIndex)?.content?.[0]?.annotations ?? + []) as UrlCitation[]; + + beforeEach(async () => { + clearSearxngEnv(); + resetCredentialRuntimeState(); + cleanupDir(); + fs.mkdirSync(tempDataDir, { recursive: true }); + vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + process.env.CODEBUDDY_AUTH_MODE = 'auto'; + await addCredential({ + bearer_token: 'responses-citations-token', + responses_passthrough: false, + user_id: 'responses-citations@example.com', + }); + }); + + afterEach(() => { + clearSearxngEnv(); + cleanupDir(); + vi.restoreAllMocks(); + }); + + it('cites the result whose URL the model quoted, over exactly that span', async () => { + await enableSearch(); + + const payload = await runTurn({ + answer: 'It shipped yesterday — see https://docs.test/release for notes.', + results: [ + { + content: 'A snippet', + title: 'Docs', + url: 'https://docs.test/release', + }, + ], + }); + + const annotations = annotationsOf(payload); + + expect(annotations).toEqual([ + { + end_index: 'It shipped yesterday — see https://docs.test/release' + .length, + start_index: 'It shipped yesterday — see '.length, + title: 'Docs', + type: 'url_citation', + url: 'https://docs.test/release', + }, + ]); + // End-exclusive, and nothing but the URL it points at. + expect( + payload.output_text.slice( + annotations[0].start_index, + annotations[0].end_index, + ), + ).toBe('https://docs.test/release'); + }); + + it('invents nothing for a result the model never quoted', async () => { + await enableSearch(); + + const payload = await runTurn({ + answer: 'It shipped yesterday, apparently.', + results: [ + { + content: 'A snippet', + title: 'Docs', + url: 'https://docs.test/release', + }, + ], + }); + + // The search still ran, so the call item is there — but nothing in the + // answer points at a source, so no span can be claimed. + expect(payload.output.map((item) => item.type)).toContain( + 'web_search_call', + ); + expect(annotationsOf(payload)).toEqual([]); + }); + + it('orders two cited URLs by start_index', async () => { + await enableSearch(); + + const payload = await runTurn({ + answer: 'See https://b.test/second and https://a.test/first.', + results: [ + { content: 'one', title: 'First', url: 'https://a.test/first' }, + { content: 'two', title: 'Second', url: 'https://b.test/second' }, + ], + }); + + const annotations = annotationsOf(payload); + + // The second result is quoted first, so it is annotated first. + expect(annotations).toEqual([ + { + end_index: 'See https://b.test/second'.length, + start_index: 'See '.length, + title: 'Second', + type: 'url_citation', + url: 'https://b.test/second', + }, + { + end_index: 'See https://b.test/second and https://a.test/first'.length, + start_index: 'See https://b.test/second and '.length, + title: 'First', + type: 'url_citation', + url: 'https://a.test/first', + }, + ]); + }); + + it('leaves the preamble unannotated', async () => { + await enableSearch(); + + const payload = await runTurn({ + answer: 'Here is what I found: https://docs.test/release.', + preamble: 'Let me check https://docs.test/release.', + results: [ + { + content: 'A snippet', + title: 'Docs', + url: 'https://docs.test/release', + }, + ], + }); + + const all = messages(payload); + + // Two: what was written before the search, and the answer after it. + expect(all).toHaveLength(2); + expect(all[0].content?.[0]?.text).toBe( + 'Let me check https://docs.test/release.', + ); + // Prose written before the search cannot cite it, even when it quotes a + // URL that later turns out to be a result. + expect(annotationsOf(payload, 0)).toEqual([]); + expect(annotationsOf(payload)).toHaveLength(1); + }); + + it('annotates nothing when no search ran', async () => { + await enableSearch(); + + const payload = await runTurn({ + answer: 'It shipped in March, as I recall.', + results: [], + tools: [], + }); + + expect(payload.output.map((item) => item.type)).toEqual(['message']); + expect(annotationsOf(payload)).toEqual([]); + }); +}); diff --git a/tests/server/responses-stream-citations.test.ts b/tests/server/responses-stream-citations.test.ts new file mode 100644 index 0000000..af61bab --- /dev/null +++ b/tests/server/responses-stream-citations.test.ts @@ -0,0 +1,398 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { NextRequest } from 'next/server'; + +import { updateSettings } from '@/lib/server/domain/config'; +import { + addCredential, + resetCredentialRuntimeState, +} from '@/lib/server/domain/credentials'; +import { handleResponsesRequest } from '@/lib/server/proxy/responses'; +import { resetWebSearchProviders } from '@/lib/server/search'; + +/** + * Citations on a turn the client asked to have streamed. + * + * `runServerToolTurn` asks upstream for a buffered answer on every hop, so a + * server-tool turn is replayed through the buffered mapper rather than mapped + * one SSE chunk at a time — and the buffered mapper is the one that annotates. + * These tests pin that a streamed turn cites exactly what a buffered one does. + */ +describe('responses streaming citations', () => { + const tempRootDir = path.join( + process.cwd(), + '.tmp-responses-stream-citations-root', + ); + const tempDataDir = path.join(tempRootDir, '.codebuddy_data'); + + const SEARXNG_ENV_NAMES = [ + 'SEARXNG_URL', + 'SEARXNG_API_KEY', + 'SEARXNG_ENGINES', + 'SEARXNG_LANGUAGE', + 'SEARXNG_MAX_RESULTS', + 'SEARXNG_TIMEOUT_MS', + ] as const; + + /** An output item, as far as these tests read it. */ + interface OutputItem { + content?: Array<{ annotations?: UrlCitation[]; text?: string }>; + type: string; + } + + /** A `url_citation`, as the Responses API spells it. */ + interface UrlCitation { + end_index: number; + start_index: number; + title: string; + type: string; + url: string; + } + + /** The terminal event of a streamed turn, as far as these tests read it. */ + interface CompletedResponse { + output: OutputItem[]; + output_text: string; + } + + const clearSearxngEnv = (): void => { + for (const name of SEARXNG_ENV_NAMES) { + delete process.env[name]; + } + + resetWebSearchProviders(); + }; + + const cleanupDir = (): void => { + fs.rmSync(tempRootDir, { force: true, recursive: true, maxRetries: 5 }); + }; + + const makeResponse = ( + body: string, + contentType: string, + status = 200, + ): Response => + new Response(body, { headers: { 'Content-Type': contentType }, status }); + + const makeJsonResponse = ( + payload: Record, + status = 200, + ): Response => + makeResponse(JSON.stringify(payload), 'application/json', status); + + const makeRequest = (): NextRequest => + new NextRequest('http://localhost/v1/responses', { + method: 'POST', + headers: { authorization: 'Bearer responses-stream-citations-token' }, + }); + + const enableSearch = async (): Promise => { + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + }; + + /** One SSE frame, as the chat upstream spells it. */ + const frame = (chunk: Record): string => + `data: ${JSON.stringify(chunk)}`; + + const searchCall = { + function: { arguments: '{"query":"release date"}', name: 'web_search' }, + id: 'call_1', + type: 'function', + }; + + /** A hop that asks for a search, as a buffered chat payload. */ + const searchHop = (content: string | null) => ({ + choices: [ + { + finish_reason: 'tool_calls', + message: { content, role: 'assistant', tool_calls: [searchCall] }, + }, + ], + }); + + /** A hop that answers, as a buffered chat payload. */ + const answerHop = (text: string) => ({ + choices: [ + { finish_reason: 'stop', message: { content: text, role: 'assistant' } }, + ], + }); + + /** + * One hop, as the SSE frames an upstream that ignored `stream: false` would + * send: the same message, spelled as deltas ending in a `finish_reason`. + */ + const asSseBody = ( + content: string | null, + toolCalls: Array>, + finishReason: string, + ): string => + [ + frame({ + choices: [ + { delta: { content, role: 'assistant' }, finish_reason: null }, + ], + }), + ...(toolCalls.length + ? [ + frame({ + choices: [ + { + delta: { tool_calls: toolCalls }, + finish_reason: finishReason, + }, + ], + }), + ] + : [frame({ choices: [{ delta: {}, finish_reason: finishReason }] })]), + 'data: [DONE]', + '', + ].join('\n\n'); + + interface TurnOptions { + answer: string; + /** + * How upstream labels the body of a hop it was asked not to stream. + * + * `application/json` is what an upstream that honours `stream: false` + * returns. `text/event-stream` is the one way a buffered turn could reach + * the chunk-by-chunk mapper, which reads only the label to pick itself — + * and which finds no `data:` frames in a JSON body, so it would answer + * with an empty `output_text` rather than merely an unannotated one. + */ + hopContentType?: 'application/json' | 'text/event-stream'; + /** Text the model writes before it searches, when it writes any. */ + preamble?: string; + results: Array>; + stream?: boolean; + } + + /** + * One turn: the model asks for a search, the proxy serves `results`, and the + * model answers with `answer`. + * + * Returns the response body — SSE when the client asked to stream, JSON + * otherwise. + */ + const runTurn = async ({ + answer, + hopContentType = 'application/json', + preamble, + results, + stream = false, + }: TurnOptions): Promise => { + let calls = 0; + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('searx.test')) { + return makeJsonResponse({ results }) as unknown as Response; + } + + calls += 1; + const isSearchHop = calls === 1; + + if (hopContentType === 'text/event-stream') { + return makeResponse( + isSearchHop + ? asSseBody( + preamble ?? null, + [{ ...searchCall, index: 0 }], + 'tool_calls', + ) + : asSseBody(answer, [], 'stop'), + 'text/event-stream', + ) as unknown as Response; + } + + return makeJsonResponse( + isSearchHop ? searchHop(preamble ?? null) : answerHop(answer), + ) as unknown as Response; + }); + + const response = await handleResponsesRequest(makeRequest(), { + input: 'when did it ship?', + model: 'glm-5.1', + ...(stream ? { stream: true } : {}), + tools: [{ type: 'web_search_preview' }], + }); + + return response.text(); + }; + + /** The events of an SSE body, in the order they were written. */ + const eventsOf = (body: string): Array> => + body + .split('\n\n') + .map((block) => + block.split('\n').find((segment) => segment.startsWith('data: ')), + ) + .filter((line): line is string => typeof line === 'string') + .map((line) => line.slice(6).trim()) + .filter((raw) => raw && raw !== '[DONE]') + .map((raw) => JSON.parse(raw) as Record); + + /** The `response.completed` payload of a streamed turn. */ + const completedOf = (body: string): CompletedResponse => { + const event = eventsOf(body).find( + (item) => item.type === 'response.completed', + ); + + if (!event) { + throw new Error(`no response.completed in:\n${body}`); + } + + return event.response as CompletedResponse; + }; + + /** Every assistant message of a completed turn, in the order written. */ + const messagesOf = (response: CompletedResponse): OutputItem[] => + response.output.filter((item) => item.type === 'message'); + + /** The annotations on one message; the last one unless told otherwise. */ + const annotationsOf = ( + response: CompletedResponse, + messageIndex = -1, + ): UrlCitation[] => + messagesOf(response).at(messageIndex)?.content?.[0]?.annotations ?? []; + + const ANSWER = + 'It shipped yesterday — see https://docs.test/release for notes.'; + const RESULTS = [ + { content: 'A snippet', title: 'Docs', url: 'https://docs.test/release' }, + ]; + const CITATION: UrlCitation = { + end_index: 'It shipped yesterday — see https://docs.test/release'.length, + start_index: 'It shipped yesterday — see '.length, + title: 'Docs', + type: 'url_citation', + url: 'https://docs.test/release', + }; + + beforeEach(async () => { + clearSearxngEnv(); + resetCredentialRuntimeState(); + cleanupDir(); + fs.mkdirSync(tempDataDir, { recursive: true }); + vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + process.env.CODEBUDDY_AUTH_MODE = 'auto'; + await addCredential({ + bearer_token: 'responses-stream-citations-token', + responses_passthrough: false, + user_id: 'responses-stream-citations@example.com', + }); + }); + + afterEach(() => { + clearSearxngEnv(); + cleanupDir(); + vi.restoreAllMocks(); + }); + + it('cites the quoted result on a streamed turn exactly as on a buffered one', async () => { + await enableSearch(); + + const streamed = completedOf( + await runTurn({ answer: ANSWER, results: RESULTS, stream: true }), + ); + const buffered = JSON.parse( + await runTurn({ answer: ANSWER, results: RESULTS }), + ) as CompletedResponse; + + expect(annotationsOf(streamed)).toEqual([CITATION]); + expect(annotationsOf(streamed)).toEqual(annotationsOf(buffered)); + // The turn really was a server-tool turn, so a client can trace the + // citation back to a search it was shown. + expect(streamed.output.map((item) => item.type)).toContain( + 'web_search_call', + ); + }); + + it('replays the answer as text deltas, and the citation with the item it closes', async () => { + await enableSearch(); + + const events = eventsOf( + await runTurn({ answer: ANSWER, results: RESULTS, stream: true }), + ); + const deltas = events + .filter((event) => event.type === 'response.output_text.delta') + .map((event) => String(event.delta ?? '')); + + expect(deltas.join('')).toBe(ANSWER); + + const closedMessage = events + .filter((event) => event.type === 'response.output_item.done') + .map((event) => event.item as OutputItem) + .find((item) => item.type === 'message'); + + // The client renders against the item it keeps, so that is where the + // annotation has to be — not only on the terminal event. + expect(closedMessage?.content?.[0]?.annotations).toEqual([CITATION]); + }); + + it('leaves the preamble unannotated on a streamed turn', async () => { + await enableSearch(); + + const streamed = completedOf( + await runTurn({ + answer: 'Here is what I found: https://docs.test/release.', + preamble: 'Let me check https://docs.test/release.', + results: RESULTS, + stream: true, + }), + ); + const messages = messagesOf(streamed); + + expect(messages).toHaveLength(2); + // Prose written before the search cannot cite it, even when it quotes a + // URL that later turns out to be a result. + expect(annotationsOf(streamed, 0)).toEqual([]); + expect(annotationsOf(streamed)).toHaveLength(1); + }); + + it('annotates nothing when the model quoted no result', async () => { + await enableSearch(); + + const streamed = completedOf( + await runTurn({ + answer: 'It shipped yesterday, apparently.', + results: RESULTS, + stream: true, + }), + ); + + expect(streamed.output.map((item) => item.type)).toContain( + 'web_search_call', + ); + expect(annotationsOf(streamed)).toEqual([]); + }); + + /** + * The invariant that keeps the chunk-by-chunk mapper off this path. + * + * `isEventStream` reads only the content type, and a hop's body is + * re-serialized as JSON before it is handed on — so an upstream that + * answers a buffered hop with an SSE label is the one way a streamed + * server-tool turn could reach that mapper, which finds no `data:` frames + * in a JSON body and would drop the answer whole. + */ + it('still cites when the upstream labels a buffered hop as an event stream', async () => { + await enableSearch(); + + const streamed = completedOf( + await runTurn({ + answer: ANSWER, + hopContentType: 'text/event-stream', + results: RESULTS, + stream: true, + }), + ); + + expect(streamed.output_text).toBe(ANSWER); + expect(annotationsOf(streamed)).toEqual([CITATION]); + }); +}); diff --git a/tests/server/server-tools.test.ts b/tests/server/server-tools.test.ts index 4441e6c..f113720 100644 --- a/tests/server/server-tools.test.ts +++ b/tests/server/server-tools.test.ts @@ -1736,3 +1736,203 @@ describe('a hop that goes wrong', () => { expect(payload.error?.message).toBeTruthy(); }); }); + +/** + * Every hop is an upstream round trip and every search a backend call, so a + * client that hangs up mid-turn should not be charged for the rest of the + * budget it declared. + */ +describe('a client that hangs up', () => { + const answerHop = (text = 'done'): Record => ({ + choices: [ + { finish_reason: 'stop', message: { content: text, role: 'assistant' } }, + ], + }); + + const rewriteWithBudget = ( + searchProvider: WebSearchProvider, + maxUses: number, + ) => + rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider, + tools: [{ type: SEARCH_TYPE, name: 'web_search', max_uses: maxUses }], + })!; + + /** + * Aborts once `abortAfter` upstream calls have answered, so the hop that + * produced the last one completes in full and the next checkpoint is where + * the turn stops spending. + */ + const hangingUpTurn = ({ + abortAfter, + hops, + maxUses = 5, + }: { + abortAfter: number; + hops: Array>; + maxUses?: number; + }) => { + const controller = new AbortController(); + let calls = 0; + let searches = 0; + + const searchProvider: WebSearchProvider = { + id: 'hangup-search', + search: async () => { + searches += 1; + + return { content: 'findings', results: [] }; + }, + }; + + const run = (): Promise => + runServerToolTurn({ + body, + callUpstream: async () => { + calls += 1; + + if (calls > 25) { + throw new Error( + `the turn did not terminate: ${calls} upstream calls`, + ); + } + + const response = makeJsonResponse( + hops[Math.min(calls - 1, hops.length - 1)], + ); + + if (calls === abortAfter) { + controller.abort(); + } + + return response; + }, + fetchProvider: null, + rewrite: rewriteWithBudget(searchProvider, maxUses), + searchProvider, + signal: controller.signal, + }); + + return { calls: () => calls, run, searches: () => searches }; + }; + + it('spends nothing when the caller is already gone', async () => { + const controller = new AbortController(); + controller.abort(); + let calls = 0; + + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => { + calls += 1; + + return makeJsonResponse(answerHop()); + }, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + signal: controller.signal, + }); + + expect(calls).toBe(0); + expect(outcome.executions).toHaveLength(0); + // Non-ok, so every caller stops rendering rather than emitting an answer + // nobody is listening for. + expect(outcome.response.ok).toBe(false); + }); + + it('stops asking upstream after the caller leaves', async () => { + const { calls, run, searches } = hangingUpTurn({ + abortAfter: 1, + hops: [assistantToolCall('web_search', '{"query":"q"}'), answerHop()], + }); + + await run(); + + // The hop was already in flight when the caller left, so it cannot be + // recalled any cheaper than letting it finish — but it runs nothing, and + // nothing follows it. + expect(calls()).toBe(1); + expect(searches()).toBe(0); + }); + + it('does not spend the closing call once the caller leaves', async () => { + // One search is the whole budget, so hop 1 leaves the turn ready to make + // its closing call. That call is the next thing to skip. + const { calls, run } = hangingUpTurn({ + abortAfter: 1, + hops: [assistantToolCall('web_search', '{"query":"q"}'), answerHop()], + maxUses: 1, + }); + + await run(); + + expect(calls()).toBe(1); + }); + + it('keeps the searches it already ran', async () => { + const { run, searches } = hangingUpTurn({ + abortAfter: 2, + hops: [ + assistantToolCall('web_search', '{"query":"q"}'), + assistantToolCall('web_search', '{"query":"q2"}'), + answerHop(), + ], + }); + + const outcome = await run(); + + // Hop 1's search ran before the caller left. Hop 2's was still owed when + // they went, so it is not executed and billed on their behalf. + expect(searches()).toBe(1); + expect(outcome.executions).toHaveLength(1); + // Still recoverable from the response, which is where the Responses path + // reads them from: it really ran and was really billed. + expect(getServerToolExecutions(outcome.response)).toHaveLength(1); + }); + + /** + * Consistent with every other way out of the turn: the internal function is + * never handed to the client, not even when the turn is cut short. A + * `tool_use` for a `web_search` the client never declared is a call it has + * no handler for. + */ + it('hands back no server-tool call the client would have to resolve', async () => { + const { run } = hangingUpTurn({ + abortAfter: 1, + hops: [assistantToolCall('web_search', '{"query":"q"}'), answerHop()], + }); + + const outcome = await run(); + const payload = (await outcome.response.json()) as { + choices?: Array<{ message?: { tool_calls?: unknown[] } }>; + }; + + expect(payload.choices?.[0]?.message?.tool_calls ?? []).toEqual([]); + }); + + it('runs to completion when no signal is given', async () => { + let calls = 0; + + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => { + calls += 1; + + return makeJsonResponse( + calls === 1 + ? assistantToolCall('web_search', '{"query":"q"}') + : answerHop('It shipped yesterday.'), + ); + }, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + }); + + expect(calls).toBe(2); + expect(outcome.response.ok).toBe(true); + }); +}); diff --git a/tests/server/web-search.test.ts b/tests/server/web-search.test.ts index 510cc42..d78bccd 100644 --- a/tests/server/web-search.test.ts +++ b/tests/server/web-search.test.ts @@ -2118,6 +2118,80 @@ describe('server tool routing', () => { // Answering from memory is the honest degradation, not a 400. expect(payload.output_text).toBe('It shipped in March, as I recall.'); }); + + const pinnedImage = { + input: 'draw a cat', + model: 'glm-5.1', + tool_choice: { type: 'image_generation' }, + tools: [{ type: 'image_generation' }], + }; + + /** + * Image generation is executed here, by its own loop rather than the + * server-tool turn, so a pin on it is a request this adapter can serve — + * and it used to be rejected outright with a 400. + */ + it('serves a request pinning the image tool by its hosted type', async () => { + const { run, sent } = routed( + [answerHop('Here is the cat.')], + pinnedImage, + ); + + const response = await run(); + + expect(response.status).toBe(200); + // Upstream has never heard of `image_generation` as a tool type; the pin + // has to name the function the declaration was rewritten into. + expect(sent[0]?.tool_choice).toEqual({ + type: 'function', + function: { name: 'image_generation' }, + }); + }); + + /** + * `file_search` has no implementation here, so its declaration is + * withdrawn from what goes upstream. Pinning it is therefore not a client + * error but a request to serve without the tool — the same degradation a + * server tool with no backend already gets. + */ + it('drops a pin on a hosted tool this adapter does not implement', async () => { + const { run, sent } = routed([answerHop('From memory.')], { + input: 'search the docs', + model: 'glm-5.1', + tool_choice: { type: 'file_search' }, + tools: [{ type: 'file_search' }], + }); + + const response = await run(); + + expect(response.status).toBe(200); + expect(sent[0]?.tool_choice).toBeUndefined(); + + const payload = (await response.json()) as { output_text: string }; + + // The request is still served — just without the tool it pinned. + expect(payload.output_text).toBe('From memory.'); + }); + + /** + * The relaxation above is scoped to a declaration the request actually + * made. A pin naming a tool that was never on offer is a client error, and + * it has to stay one. + */ + it('rejects a pin on a hosted tool the request never declared', async () => { + const { run, sent } = routed([answerHop('From memory.')], { + input: 'search the docs', + model: 'glm-5.1', + tool_choice: { type: 'file_search' }, + tools: [{ type: 'function', name: 'lookup', parameters: {} }], + }); + + const response = await run(); + + expect(response.status).toBe(400); + // Rejected before anything is sent upstream. + expect(sent).toHaveLength(0); + }); }); describe('/v1/chat/completions', () => {