Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,482 changes: 1,482 additions & 0 deletions docs/design/claude-code-websearch-flow.md

Large diffs are not rendered by default.

132 changes: 104 additions & 28 deletions lib/server/proxy/anthropic.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -19,8 +17,18 @@ 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,
reconcileToolChoice,
runServerToolTurn,
} from './server-tools';

// ---------------------------------------------------------------------------
// Main handler
Expand All @@ -37,23 +45,100 @@ 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);

/**
* 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<Response> =>
proxyChatCompletions(
request,
{ ...turnBody, stream },
context,
debugTrace,
Comment thread
orangeboyChen marked this conversation as resolved.
'/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,
signal: request.signal,
}),
);

if (body.stream) {
return createAnthropicServerToolEventStream({ model, runTurn });
}

const { executions, response, segments } = 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, segments),
);
}

const upstreamResponse = await proxyChatCompletions(
request,
chatBody as ChatRequestBody,
undefined,
debugTrace,
'/v1/messages',
{ findingsAsStructuredBlocks: true },
const upstreamResponse = await callUpstream()(
// `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,
// 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),
);

if (!upstreamResponse.ok) {
Expand All @@ -63,22 +148,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,
Expand Down
66 changes: 32 additions & 34 deletions lib/server/proxy/anthropic/request.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 => {
Expand All @@ -338,42 +344,34 @@ export const mapAnthropicToolsToChat = (
}

return tools.map((tool) => {
const mapped = {
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)),
);

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: '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<boolean> => {
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;
Expand Down
75 changes: 33 additions & 42 deletions lib/server/proxy/anthropic/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {
OpenAIChatResponse,
OpenAIUsage,
} from './types';
import type { ServerToolExecution, ServerToolTurn } from '../web-search-loop';
import type { ServerToolExecution, ServerToolSegment } from '../server-tools';

// ---------------------------------------------------------------------------
// Response translation: OpenAI → Anthropic (non-streaming)
Expand Down Expand Up @@ -121,47 +121,32 @@ 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[],
): AnthropicContentBlock[] => {
const blocks: AnthropicContentBlock[] = [];

turns.forEach((turn) => {
if (turn.reasoning) {
blocks.push(buildThinkingBlock(turn.reasoning));
}

if (turn.text) {
blocks.push({ type: 'text', text: turn.text });
}

blocks.push(...buildAllAnthropicServerToolBlocks(turn.executions));
});

return blocks;
};
export const buildAnthropicServerToolTurnBlocks = (
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[] = [],
turns?: ServerToolTurn[],
segments?: ServerToolSegment[],
): Record<string, unknown> => {
const choice = openaiResponse.choices?.[0];
const message = choice?.message;
Expand All @@ -173,15 +158,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[] = segments
? buildAnthropicServerToolTurnBlocks(segments)
: [];

if (!turns) {
if (!segments) {
if (reasoningText) {
contentBlocks.push(buildThinkingBlock(reasoningText));
}
Expand All @@ -193,6 +174,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 every block above rather than preceding them.
if (reasoningText) {
contentBlocks.push(buildThinkingBlock(reasoningText));
}

if (textContent) {
contentBlocks.push({ type: 'text', text: textContent });
}
}

// Tool calls
Expand Down
Loading
Loading