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
17 changes: 17 additions & 0 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export interface AppConfig {
useResponsesApiWebSocket?: boolean
anthropicApiKey?: string
useResponsesApiWebSearch?: boolean
// Copilot rejects Anthropic's web_search server tool on /v1/messages, so a
// Claude request that only asks for web search is switched to this
// Responses-capable GPT model, which runs the search natively. Leave unset to
// disable (the tool is then stripped). Mixing web_search with other tools is
// not supported.
messageApiWebSearchModel?: string
claudeTokenMultiplier?: number
}

Expand Down Expand Up @@ -121,6 +127,7 @@ const defaultConfig: AppConfig = {
useMessagesApi: true,
useResponsesApiWebSocket: true,
useResponsesApiWebSearch: true,
messageApiWebSearchModel: "gpt-5-mini",
}

let cachedConfig: AppConfig | null = null
Expand Down Expand Up @@ -627,6 +634,16 @@ export function isResponsesApiWebSearchEnabled(): boolean {
return config.useResponsesApiWebSearch ?? true
}

export function isMessagesApiWebSearchEnabled(): boolean {
return Boolean(getMessageApiWebSearchModel())
}

export function getMessageApiWebSearchModel(): string | undefined {
const config = getConfig()
const model = config.messageApiWebSearchModel
return model && model.trim().length > 0 ? model : undefined
}

export function getClaudeTokenMultiplier(): number {
const config = getConfig()
return config.claudeTokenMultiplier ?? 1.15
Expand Down
43 changes: 43 additions & 0 deletions src/routes/messages/anthropic-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,51 @@ export interface AnthropicTool {
input_schema: Record<string, unknown>
defer_loading?: boolean
cache_control?: AnthropicCacheControl | null
// Server-side tool fields (e.g. web_search_20250305). Server tools carry a
// `type` and omit `input_schema`; these stay optional so the same interface
// can describe both custom and server tools without rippling type changes.
type?: string
max_uses?: number
allowed_domains?: Array<string>
blocked_domains?: Array<string>
user_location?: Record<string, unknown>
}

// --- Web search result blocks (Anthropic server tool shape) ---------------
// Emitted in the assistant response when the proxy fulfills a web_search tool.

export interface AnthropicWebSearchResultItem {
type: "web_search_result"
url: string
title: string
page_age?: string | null
encrypted_content?: string
}

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

export interface AnthropicWebSearchToolResultErrorBlock {
type: "web_search_tool_result_error"
error_code: string
}

export interface AnthropicWebSearchToolResultBlock {
type: "web_search_tool_result"
tool_use_id: string
content:
| Array<AnthropicWebSearchResultItem>
| AnthropicWebSearchToolResultErrorBlock
}

export type AnthropicWebSearchContentBlock =
| AnthropicServerToolUseBlock
| AnthropicWebSearchToolResultBlock

export interface AnthropicResponse {
id: string
type: "message"
Expand Down
40 changes: 40 additions & 0 deletions src/routes/messages/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { COMPACT_REQUEST } from "~/lib/compact"
import {
getSmallModel,
isMessagesApiEnabled,
getMessageApiWebSearchModel,
isResponsesApiWebSearchEnabled,
resolveMappedModel,
} from "~/lib/config"
import { createHandlerLogger, debugJson } from "~/lib/logger"
Expand Down Expand Up @@ -38,6 +40,12 @@ import {
stripToolReferenceTurnBoundary,
} from "./preprocess"
import { parseSubagentMarkerFromFirstUser } from "./subagent-marker"
import {
handleWebSearchViaResponses,
hasWebSearchServerTool,
resolveWebSearchRoute,
stripWebSearchServerTool,
} from "./web-search/fulfill"
import consola from "consola"

const logger = createHandlerLogger("messages-handler")
Expand Down Expand Up @@ -130,6 +138,38 @@ export async function handleCompletion(c: Context) {
const selectedModel = findEndpointModel(anthropicPayload.model)
anthropicPayload.model = selectedModel?.id ?? anthropicPayload.model

// Copilot rejects Anthropic's server-side web_search tool on /v1/messages.
// A request that asks for ONLY web search is switched to the configured
// messageApiWebSearchModel: a `provider/model` alias is passed straight
// through to that provider's (websearch-capable) message API, while a Copilot
// GPT model runs the search via /responses. Mixing web_search with other
// tools is unsupported, so in that case (or when nothing is configured) the
// tool is stripped to avoid a 400.
if (hasWebSearchServerTool(anthropicPayload)) {
const route = resolveWebSearchRoute(anthropicPayload, {
webSearchModel: getMessageApiWebSearchModel(),
responsesWebSearchEnabled: isResponsesApiWebSearchEnabled(),
})
if (route.kind === "provider") {
anthropicPayload.model = route.alias.model
return await handleProviderMessagesForProvider(c, {
payload: anthropicPayload,
provider: route.alias.provider,
})
}
if (route.kind === "responses") {
return await handleWebSearchViaResponses(c, anthropicPayload, {
subagentMarker,
webSearchModel: route.model,
requestId,
sessionId,
compactType,
logger,
})
}
stripWebSearchServerTool(anthropicPayload)
}

if (shouldUseMessagesApi(selectedModel)) {
return await messagesFlowHandlers.handleWithMessagesApi(
c,
Expand Down
102 changes: 102 additions & 0 deletions src/routes/messages/web-search/backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type {
ResponseOutputMessage,
ResponsesResult,
} from "~/services/copilot/create-responses"

export interface WebSearchSource {
url: string
title: string
page_age?: string | null
}

export interface WebSearchExtract {
/** The grounded answer text produced by the GPT backend (with inline cites). */
answerText: string
/** Deduped sources extracted from url_citation annotations. */
sources: Array<WebSearchSource>
/** Search queries the backend actually ran. */
queries: Array<string>
}

export interface WebSearchToolConfig {
allowedDomains?: Array<string>
blockedDomains?: Array<string>
userLocation?: Record<string, unknown>
}

interface UrlCitationAnnotation {
type: "url_citation"
url: string
title?: string
}

/** Builds the Responses API web_search tool object from the Anthropic config. */
export const buildResponsesWebSearchTool = (
config: WebSearchToolConfig,
): Record<string, unknown> => {
const tool: Record<string, unknown> = { type: "web_search" }
const filters: Record<string, unknown> = {}
if (config.allowedDomains?.length) {
filters.allowed_domains = config.allowedDomains
}
if (config.blockedDomains?.length) {
filters.blocked_domains = config.blockedDomains
}
if (Object.keys(filters).length > 0) tool.filters = filters
if (config.userLocation) tool.user_location = config.userLocation
return tool
}

const isMessageItem = (
item: ResponsesResult["output"][number],
): item is ResponseOutputMessage => item.type === "message"

/**
* Extracts the answer text, deduped sources, and run queries from a GPT
* /responses web_search result.
*/
export const extractWebSearchResult = (
result: ResponsesResult,
): WebSearchExtract => {
const textParts: Array<string> = []
const sources: Array<WebSearchSource> = []
const seenUrls = new Set<string>()
const queries: Array<string> = []

for (const item of result.output) {
if (isMessageItem(item)) {
for (const block of item.content ?? []) {
if ((block as { type?: string }).type !== "output_text") continue
const textBlock = block as {
text?: string
annotations?: Array<unknown>
}
if (textBlock.text) textParts.push(textBlock.text)
for (const annotation of textBlock.annotations ?? []) {
const ann = annotation as UrlCitationAnnotation
if (
ann.type === "url_citation"
&& ann.url
&& !seenUrls.has(ann.url)
) {
seenUrls.add(ann.url)
sources.push({ url: ann.url, title: ann.title ?? ann.url })
}
}
}
continue
}

if ((item as { type?: string }).type === "web_search_call") {
const action = (
item as { action?: { query?: string; queries?: Array<string> } }
).action
if (action?.queries?.length) queries.push(...action.queries)
else if (action?.query) queries.push(action.query)
}
}

const answerText =
textParts.join("\n\n").trim() || (result.output_text ?? "").trim()
return { answerText, sources, queries }
}
Loading
Loading