diff --git a/src/routes/messages/anthropic-types.ts b/src/routes/messages/anthropic-types.ts index 881fffcc8..6da2aee66 100644 --- a/src/routes/messages/anthropic-types.ts +++ b/src/routes/messages/anthropic-types.ts @@ -46,6 +46,31 @@ export interface AnthropicToolResultBlock { is_error?: boolean } +export interface AnthropicServerToolUseBlock { + type: "server_tool_use" + id: string + name: string + input: Record +} + +export interface AnthropicServerToolResultBlock { + type: "server_tool_result" + tool_use_id: string + content: unknown +} + +export interface AnthropicWebSearchToolResultBlock { + type: "web_search_tool_result" + tool_use_id: string + content: Array<{ + type: "web_search_result" + url: string + title: string + page_content: string + encrypted_index?: string + }> +} + export interface AnthropicToolUseBlock { type: "tool_use" id: string @@ -62,11 +87,14 @@ export type AnthropicUserContentBlock = | AnthropicTextBlock | AnthropicImageBlock | AnthropicToolResultBlock + | AnthropicServerToolResultBlock + | AnthropicWebSearchToolResultBlock export type AnthropicAssistantContentBlock = | AnthropicTextBlock | AnthropicToolUseBlock | AnthropicThinkingBlock + | AnthropicServerToolUseBlock export interface AnthropicUserMessage { role: "user" @@ -80,12 +108,22 @@ export interface AnthropicAssistantMessage { export type AnthropicMessage = AnthropicUserMessage | AnthropicAssistantMessage -export interface AnthropicTool { +export interface AnthropicCustomTool { + type?: "custom" name: string description?: string input_schema: Record } +export interface AnthropicServerTool { + type: string // e.g. "web_search_20250305" + name: string + // Server tools may carry additional config (max_uses, etc.) + [key: string]: unknown +} + +export type AnthropicTool = AnthropicCustomTool | AnthropicServerTool + export interface AnthropicResponse { id: string type: "message" diff --git a/src/routes/messages/handler.ts b/src/routes/messages/handler.ts index 85dbf6243..3fceb832e 100644 --- a/src/routes/messages/handler.ts +++ b/src/routes/messages/handler.ts @@ -19,6 +19,7 @@ import { import { translateToAnthropic, translateToOpenAI, + hasWebSearchTool, } from "./non-stream-translation" import { translateChunkToAnthropicEvents } from "./stream-translation" @@ -28,6 +29,8 @@ export async function handleCompletion(c: Context) { const anthropicPayload = await c.req.json() consola.debug("Anthropic request payload:", JSON.stringify(anthropicPayload)) + const webSearchEnabled = hasWebSearchTool(anthropicPayload.tools) + const openAIPayload = translateToOpenAI(anthropicPayload) consola.debug( "Translated OpenAI request payload:", @@ -38,6 +41,19 @@ export async function handleCompletion(c: Context) { await awaitApproval() } + // When web search is enabled and non-streaming, check if the model wants + // to search. If so, perform a proxy web search via a separate Copilot + // request and inject results as a simulated server_tool_use / result. + if (webSearchEnabled && !anthropicPayload.stream) { + const webSearchResult = await maybePerformWebSearch( + anthropicPayload, + openAIPayload, + ) + if (webSearchResult) { + return c.json(webSearchResult) + } + } + const response = await createChatCompletions(openAIPayload) if (isNonStreaming(response)) { @@ -86,6 +102,161 @@ export async function handleCompletion(c: Context) { }) } +/** + * When web search is enabled, first ask the model (non-streaming) if it needs + * to search. If the response looks like a search intent, perform a proxy + * search via a separate Copilot chat/completions call to a web-capable model + * (gpt-4o) and return a synthetic Anthropic response containing both the + * server_tool_use and web_search_tool_result blocks. + */ +async function maybePerformWebSearch( + anthropicPayload: AnthropicMessagesPayload, + openAIPayload: ReturnType, +): Promise { + // Skip if the conversation already contains a server_tool_result + // (meaning we already searched and should let the model answer) + const lastMsg = + anthropicPayload.messages[anthropicPayload.messages.length - 1] + if ( + lastMsg?.role === "user" && + Array.isArray(lastMsg.content) && + lastMsg.content.some( + (b: { type: string }) => + b.type === "server_tool_result" || + b.type === "web_search_tool_result", + ) + ) { + return null + } + + // Check if the previous assistant message already had a search request + const prevMsg = + anthropicPayload.messages.length >= 2 + ? anthropicPayload.messages[anthropicPayload.messages.length - 2] + : null + if ( + prevMsg?.role === "assistant" && + Array.isArray(prevMsg.content) && + prevMsg.content.some( + (b: { type: string }) => + b.type === "server_tool_use" || b.type === "web_search_tool_use", + ) + ) { + return null + } + + // Make the normal completion request first + const response = await createChatCompletions({ + ...openAIPayload, + stream: false, + }) + + if (!isNonStreaming(response)) { + return null + } + + // Check if the assistant response suggests it wants to search + const assistantText = response.choices[0]?.message?.content ?? "" + const searchQuery = extractSearchIntent(assistantText) + + if (!searchQuery) { + // No search intent — return the translated response as-is + return translateToAnthropic(response) + } + + consola.info(`Web search proxy: searching for "${searchQuery}"`) + + // Perform the web search via a separate gpt-4o request + const searchResults = await performWebSearch(searchQuery) + + if (!searchResults) { + return translateToAnthropic(response) + } + + // Build a synthetic Anthropic response with web search blocks + const toolUseId = `ws_${Date.now()}` + return { + id: response.id, + type: "message", + role: "assistant", + model: response.model, + content: [ + { + type: "server_tool_use" as const, + id: toolUseId, + name: "web_search", + input: { query: searchQuery }, + } as unknown as import("./anthropic-types").AnthropicAssistantContentBlock, + { + type: "web_search_tool_result" as const, + tool_use_id: toolUseId, + content: [ + { + type: "web_search_result" as const, + url: "", + title: "Search Results", + page_content: searchResults, + }, + ], + } as unknown as import("./anthropic-types").AnthropicAssistantContentBlock, + ], + stop_reason: "end_turn", + stop_sequence: null, + usage: { + input_tokens: response.usage?.prompt_tokens ?? 0, + output_tokens: response.usage?.completion_tokens ?? 0, + }, + } +} + +function extractSearchIntent(text: string): string | null { + // Look for common patterns indicating the model wants to search + const patterns = [ + /(?:let me|I'll|I will|I need to|I should|I want to)\s+(?:search|look up|find|check|google)/i, + /(?:searching|looking up|searching for)[:\s]+["']?(.+?)["']?$/im, + /\bsearch(?:ing)?\s+(?:for|the web|online)[:\s]+["']?(.+?)["']?$/im, + ] + + for (const pattern of patterns) { + const match = text.match(pattern) + if (match) { + return match[1] ?? text.trim().slice(0, 200) + } + } + + // If the text is short and looks like a search query itself + if (text.length < 100 && text.includes("?")) { + return text.trim() + } + + return null +} + +async function performWebSearch(query: string): Promise { + try { + const searchPayload = { + model: "gpt-4o", + messages: [ + { + role: "user" as const, + content: `Search the web for: ${query}\n\nProvide a concise summary of the most relevant and recent results.`, + }, + ], + max_tokens: 1000, + stream: false, + } + + const searchResponse = await createChatCompletions(searchPayload) + if (isNonStreaming(searchResponse)) { + return searchResponse.choices[0]?.message?.content ?? null + } + return null + } catch (error) { + consola.warn("Web search proxy failed:", error) + return null + } +} + const isNonStreaming = ( response: Awaited>, ): response is ChatCompletionResponse => Object.hasOwn(response, "choices") diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index dc41e6382..49d5de6d2 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -11,9 +11,12 @@ import { import { type AnthropicAssistantContentBlock, type AnthropicAssistantMessage, + type AnthropicCustomTool, type AnthropicMessage, type AnthropicMessagesPayload, type AnthropicResponse, + type AnthropicServerToolResultBlock, + type AnthropicServerToolUseBlock, type AnthropicTextBlock, type AnthropicThinkingBlock, type AnthropicTool, @@ -21,6 +24,7 @@ import { type AnthropicToolUseBlock, type AnthropicUserContentBlock, type AnthropicUserMessage, + type AnthropicWebSearchToolResultBlock, } from "./anthropic-types" import { mapOpenAIStopReasonToAnthropic } from "./utils" @@ -94,8 +98,18 @@ function handleUserMessage(message: AnthropicUserMessage): Array { (block): block is AnthropicToolResultBlock => block.type === "tool_result", ) + + // Convert server_tool_result / web_search_tool_result to user text + const serverToolResultBlocks = message.content.filter( + (block): block is AnthropicServerToolResultBlock | AnthropicWebSearchToolResultBlock => + block.type === "server_tool_result" || block.type === "web_search_tool_result", + ) + const otherBlocks = message.content.filter( - (block) => block.type !== "tool_result", + (block) => + block.type !== "tool_result" && + block.type !== "server_tool_result" && + block.type !== "web_search_tool_result", ) // Tool results must come first to maintain protocol: tool_use -> tool_result -> user @@ -107,6 +121,17 @@ function handleUserMessage(message: AnthropicUserMessage): Array { }) } + // Convert server tool results (web search) into user messages with search context + for (const block of serverToolResultBlocks) { + const searchText = formatServerToolResult(block) + if (searchText) { + newMessages.push({ + role: "user", + content: `[Web Search Results]\n${searchText}`, + }) + } + } + if (otherBlocks.length > 0) { newMessages.push({ role: "user", @@ -123,6 +148,20 @@ function handleUserMessage(message: AnthropicUserMessage): Array { return newMessages } +function formatServerToolResult( + block: AnthropicServerToolResultBlock | AnthropicWebSearchToolResultBlock, +): string { + if (block.type === "web_search_tool_result" && Array.isArray(block.content)) { + return block.content + .map((result) => `- [${result.title}](${result.url}): ${result.page_content}`) + .join("\n") + } + if (typeof block.content === "string") { + return block.content + } + return JSON.stringify(block.content) +} + function handleAssistantMessage( message: AnthropicAssistantMessage, ): Array { @@ -139,6 +178,11 @@ function handleAssistantMessage( (block): block is AnthropicToolUseBlock => block.type === "tool_use", ) + // Handle server_tool_use blocks (e.g. web_search) — convert to text context + const serverToolUseBlocks = message.content.filter( + (block): block is AnthropicServerToolUseBlock => block.type === "server_tool_use", + ) + const textBlocks = message.content.filter( (block): block is AnthropicTextBlock => block.type === "text", ) @@ -147,11 +191,15 @@ function handleAssistantMessage( (block): block is AnthropicThinkingBlock => block.type === "thinking", ) - // Combine text and thinking blocks, as OpenAI doesn't have separate thinking blocks - const allTextContent = [ + // Combine text, thinking, and server tool use descriptions + const allTextParts = [ ...textBlocks.map((b) => b.text), ...thinkingBlocks.map((b) => b.thinking), - ].join("\n\n") + ...serverToolUseBlocks.map( + (b) => `[Used web search: ${JSON.stringify(b.input)}]`, + ), + ] + const allTextContent = allTextParts.filter(Boolean).join("\n\n") return toolUseBlocks.length > 0 ? [ @@ -171,7 +219,7 @@ function handleAssistantMessage( : [ { role: "assistant", - content: mapContent(message.content), + content: allTextContent || mapContent(message.content), }, ] } @@ -234,7 +282,18 @@ function translateAnthropicToolsToOpenAI( if (!anthropicTools) { return undefined } - return anthropicTools.map((tool) => ({ + + // Filter out server tools (web_search, etc.) — they don't have input_schema + // and are not supported by the OpenAI API. They are handled separately. + const customTools = anthropicTools.filter( + (tool): tool is AnthropicCustomTool => isCustomTool(tool), + ) + + if (customTools.length === 0) { + return undefined + } + + return customTools.map((tool) => ({ type: "function", function: { name: tool.name, @@ -244,6 +303,26 @@ function translateAnthropicToolsToOpenAI( })) } +/** + * Check if the tools array contains any web search server tools. + */ +export function hasWebSearchTool( + anthropicTools: Array | undefined, +): boolean { + if (!anthropicTools) return false + return anthropicTools.some((tool) => !isCustomTool(tool)) +} + +function isCustomTool(tool: AnthropicTool): tool is AnthropicCustomTool { + // Server tools have types like "web_search_20250305" + // Custom tools either have no type, type === "custom", or have input_schema + if (!tool.type || tool.type === "custom") return true + // If type starts with known server tool prefixes, it's a server tool + if (typeof tool.type === "string" && tool.type.startsWith("web_search")) return false + // Fallback: if it has input_schema, treat as custom tool + return "input_schema" in tool +} + function translateAnthropicToolChoiceToOpenAI( anthropicToolChoice: AnthropicMessagesPayload["tool_choice"], ): ChatCompletionsPayload["tool_choice"] {