172 lines
6.2 KiB
TypeScript
172 lines
6.2 KiB
TypeScript
/**
|
|
* Claude Web Search integration.
|
|
*
|
|
* Uses the AI SDK Anthropic provider with Anthropic's native server-side
|
|
* web_search tool: Claude runs the searches and the adapter returns the
|
|
* model's synthesized answer plus its cited sources. Result pages are never
|
|
* fetched by this server — the answer already incorporates their content,
|
|
* and a page-content enrichment fetch would be an SSRF vector.
|
|
*/
|
|
|
|
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
import { callLLM } from '@/lib/ai/llm';
|
|
import { proxyFetch } from '@/lib/server/proxy-fetch';
|
|
import { createLogger } from '@/lib/logger';
|
|
import { CLAUDE_WEB_SEARCH_DEFAULT_MODEL, WEB_SEARCH_PROVIDERS } from './constants';
|
|
import type { WebSearchResult, WebSearchSource } from '@/lib/types/web-search';
|
|
|
|
const CLAUDE_MAX_OUTPUT_TOKENS = 4096;
|
|
|
|
/** Anthropic's basic web-search tool, for models without dynamic filtering. */
|
|
const BASIC_WEB_SEARCH_TOOL_TYPE = 'web_search_20250305';
|
|
|
|
const CLAUDE_DEFAULT_BASE_URL = WEB_SEARCH_PROVIDERS.claude.defaultBaseUrl ?? '';
|
|
|
|
const log = createLogger('ClaudeSearch');
|
|
|
|
/**
|
|
* Anthropic's dynamic-filtering web_search_20260209 tool is limited to the 4.6+
|
|
* Opus/Sonnet models and the 5 series (Opus 5, Sonnet 5, Fable 5, Mythos 5).
|
|
* Everything else — Haiku, Claude 4.5 and older (including dated ids such as
|
|
* `claude-sonnet-4-20250514`), and any id we don't recognize — takes the basic
|
|
* web_search_20250305 tool, which the newer models accept too.
|
|
*/
|
|
const DYNAMIC_WEB_SEARCH_MODEL =
|
|
/^claude-(?:opus|sonnet)-4-[6-9](?:-|$)|^claude-(?:opus|sonnet|fable|mythos)-[5-9](?:-|$)/;
|
|
|
|
function usesBasicWebSearchTool(modelId: string): boolean {
|
|
return !DYNAMIC_WEB_SEARCH_MODEL.test(modelId);
|
|
}
|
|
|
|
/**
|
|
* The AI SDK serializes its provider-defined web_search tools without
|
|
* `allowed_callers`, but Anthropic requires `allowed_callers: ["direct"]` on the
|
|
* basic tool, whose models have no programmatic tool support (the request 400s
|
|
* otherwise). Patch it into outgoing request bodies at the fetch layer. The
|
|
* dynamic-filtering tool is left alone: it runs searches through the
|
|
* code-execution caller, which pinning to "direct" would disable.
|
|
*/
|
|
async function fetchWithAllowedCallers(url: string, init?: RequestInit): Promise<Response> {
|
|
if (init?.method === 'POST' && typeof init.body === 'string') {
|
|
try {
|
|
const body = JSON.parse(init.body);
|
|
if (Array.isArray(body?.tools)) {
|
|
let patched = false;
|
|
body.tools = body.tools.map((tool: Record<string, unknown>) => {
|
|
if (tool.type !== BASIC_WEB_SEARCH_TOOL_TYPE || tool.allowed_callers) return tool;
|
|
patched = true;
|
|
return { ...tool, allowed_callers: ['direct'] };
|
|
});
|
|
if (patched) init = { ...init, body: JSON.stringify(body) };
|
|
}
|
|
} catch {
|
|
/* leave body unchanged if it can't be parsed */
|
|
}
|
|
}
|
|
return proxyFetch(url, init);
|
|
}
|
|
|
|
/**
|
|
* `@ai-sdk/anthropic` appends `/messages` to the base URL verbatim, so the bare
|
|
* Anthropic root would request `https://api.anthropic.com/messages` and 404.
|
|
* Both Settings and the server allowlist accept that form, so restore the
|
|
* versioned root before handing it to the SDK. Other hosts (self-hosted
|
|
* gateways) are passed through untouched.
|
|
*/
|
|
function resolveClaudeBaseUrl(baseUrl?: string): string {
|
|
const normalized = (baseUrl || CLAUDE_DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
if (!normalized) return '';
|
|
try {
|
|
const parsed = new URL(normalized);
|
|
const official = new URL(CLAUDE_DEFAULT_BASE_URL);
|
|
if (parsed.origin === official.origin && parsed.pathname === '/') {
|
|
return CLAUDE_DEFAULT_BASE_URL;
|
|
}
|
|
} catch {
|
|
/* unparsable base URL: leave it for the SDK to surface */
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function isHttpUrl(value: string): boolean {
|
|
try {
|
|
const parsed = new URL(value);
|
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search the web using Claude's native web search tool via the AI SDK.
|
|
*/
|
|
export async function searchWithClaude(params: {
|
|
query: string;
|
|
apiKey: string;
|
|
modelId?: string;
|
|
baseUrl?: string;
|
|
maxResults?: number;
|
|
}): Promise<WebSearchResult> {
|
|
const { query, maxResults } = params;
|
|
const modelId = params.modelId?.trim() || CLAUDE_WEB_SEARCH_DEFAULT_MODEL;
|
|
// Keys and URLs are pasted by hand into Settings; surrounding whitespace
|
|
// would otherwise be sent verbatim and come back as "invalid x-api-key".
|
|
const apiKey = params.apiKey.trim();
|
|
const baseUrl = params.baseUrl?.trim();
|
|
|
|
const provider = createAnthropic({
|
|
apiKey,
|
|
baseURL: resolveClaudeBaseUrl(baseUrl),
|
|
fetch: fetchWithAllowedCallers as typeof fetch,
|
|
});
|
|
|
|
const toolArgs = maxResults && maxResults > 0 ? { maxUses: maxResults } : {};
|
|
const webSearch = usesBasicWebSearchTool(modelId)
|
|
? provider.tools.webSearch_20250305(toolArgs)
|
|
: provider.tools.webSearch_20260209(toolArgs);
|
|
|
|
const startTime = Date.now();
|
|
try {
|
|
const result = await callLLM(
|
|
{
|
|
model: provider(modelId),
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: `Search for the following and provide a comprehensive summary with source links: ${query}.`,
|
|
},
|
|
],
|
|
maxOutputTokens: CLAUDE_MAX_OUTPUT_TOKENS,
|
|
tools: { web_search: webSearch },
|
|
},
|
|
'web-search-claude',
|
|
);
|
|
|
|
// The AI SDK surfaces the tool's citations as sources (url + title only).
|
|
// Claude's answer already synthesizes the page contents, so sources are
|
|
// returned as references, deduplicated by URL.
|
|
const sources = new Map<string, WebSearchSource>();
|
|
for (const source of result.sources) {
|
|
if (source.sourceType !== 'url') continue;
|
|
if (!isHttpUrl(source.url) || sources.has(source.url)) continue;
|
|
sources.set(source.url, {
|
|
title: source.title?.trim() || new URL(source.url).hostname,
|
|
url: source.url,
|
|
content: 'Referenced by the Claude web-search answer.',
|
|
score: 1,
|
|
});
|
|
}
|
|
|
|
return {
|
|
answer: result.text,
|
|
sources: [...sources.values()],
|
|
query,
|
|
// Seconds, matching every sibling adapter's WebSearchResult contract.
|
|
responseTime: (Date.now() - startTime) / 1000,
|
|
};
|
|
} catch (e) {
|
|
log.error(`Claude web search failed [model="${modelId}"]:`, e);
|
|
throw e;
|
|
}
|
|
}
|