Skip to content
Open
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
40 changes: 39 additions & 1 deletion src/routes/messages/anthropic-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,31 @@ export interface AnthropicToolResultBlock {
is_error?: boolean
}

export interface AnthropicServerToolUseBlock {
type: "server_tool_use"
id: string
name: string
input: Record<string, unknown>
}

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
Expand All @@ -62,11 +87,14 @@ export type AnthropicUserContentBlock =
| AnthropicTextBlock
| AnthropicImageBlock
| AnthropicToolResultBlock
| AnthropicServerToolResultBlock
| AnthropicWebSearchToolResultBlock

export type AnthropicAssistantContentBlock =
| AnthropicTextBlock
| AnthropicToolUseBlock
| AnthropicThinkingBlock
| AnthropicServerToolUseBlock

export interface AnthropicUserMessage {
role: "user"
Expand All @@ -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<string, unknown>
}

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"
Expand Down
171 changes: 171 additions & 0 deletions src/routes/messages/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import {
translateToAnthropic,
translateToOpenAI,
hasWebSearchTool,
} from "./non-stream-translation"
import { translateChunkToAnthropicEvents } from "./stream-translation"

Expand All @@ -28,6 +29,8 @@ export async function handleCompletion(c: Context) {
const anthropicPayload = await c.req.json<AnthropicMessagesPayload>()
consola.debug("Anthropic request payload:", JSON.stringify(anthropicPayload))

const webSearchEnabled = hasWebSearchTool(anthropicPayload.tools)

const openAIPayload = translateToOpenAI(anthropicPayload)
consola.debug(
"Translated OpenAI request payload:",
Expand All @@ -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)) {
Expand Down Expand Up @@ -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<typeof translateToOpenAI>,
): Promise<AnthropicResponse | null> {
// 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<string | null> {
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<ReturnType<typeof createChatCompletions>>,
): response is ChatCompletionResponse => Object.hasOwn(response, "choices")
Loading