Files
my-pi/pi-web-search/clients/tavily.ts
T
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ee1e6999d7 feat(pi-web-search): 统一 web_search/web_fetch 并由主模型路由搜索厂商
7 个按厂商命名的搜索工具收敛为 2 个统一工具,schema 与提示词不再暴露
provider 概念:web_search 由当前主模型按内置规则在 Tavily/Exa/Keenable
间路由(会话内 LRU 缓存,失败按可用性降级),web_fetch 固定走 Exa 并
静默降级。三家均改为纯 fetch 直连——Exa 直接对 mcp.exa.ai 发 JSON-RPC
并缓存会话,彻底移除 pi-mcp-adapter、@tavily/pi-extension 与
@keenable/pi-search 依赖。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 20:13:37 -07:00

126 lines
4.1 KiB
TypeScript

/**
* Tavily REST client: POST https://api.tavily.com/search|extract with a Bearer
* key. Requires TAVILY_API_KEY — there is no keyless tier.
*/
export class TavilyError extends Error {}
const TAVILY_BASE_URL = "https://api.tavily.com";
const SEARCH_TIMEOUT_MS = 45_000;
const EXTRACT_TIMEOUT_MS = 60_000;
const CLIENT_SOURCE = "my-pi-web-search";
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
export interface TavilySearchParams {
query: string;
max_results?: number;
search_depth?: "basic" | "advanced";
topic?: "general" | "news" | "finance";
include_domains?: string[];
start_date?: string;
end_date?: string;
}
export interface TavilySearchResult {
title?: string;
url?: string;
content?: string;
published_date?: string;
[key: string]: unknown;
}
export function tavilyAvailable(env: Record<string, string | undefined> = process.env): boolean {
return Boolean(env.TAVILY_API_KEY?.trim());
}
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
async function post(
endpoint: string,
body: Record<string, unknown>,
timeoutMs: number,
signal: AbortSignal | undefined,
fetchImpl: FetchLike,
): Promise<Record<string, unknown>> {
const apiKey = process.env.TAVILY_API_KEY?.trim();
if (!apiKey) {
throw new TavilyError("Tavily is not configured: TAVILY_API_KEY is missing.");
}
let response: Response;
try {
response = await fetchImpl(`${TAVILY_BASE_URL}/${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"X-Client-Source": CLIENT_SOURCE,
},
body: JSON.stringify(body),
signal: withTimeout(signal, timeoutMs),
});
} catch (error) {
throw new TavilyError(`Could not reach the Tavily API: ${(error as Error).message}`);
}
if (!response.ok) {
let detail = "";
try {
const data = (await response.json()) as Record<string, unknown>;
detail = String(data.error ?? data.message ?? "");
} catch {
detail = "";
}
throw new TavilyError(`Tavily API error (${response.status})${detail ? `: ${detail}` : ""}`);
}
return (await response.json()) as Record<string, unknown>;
}
/** POST /search — broad discovery; returns the answer plus a compact result list. */
export async function tavilySearch(
params: TavilySearchParams,
signal?: AbortSignal,
fetchImpl: FetchLike = fetch,
): Promise<{ answer?: string; results: TavilySearchResult[] }> {
const payload: Record<string, unknown> = {
query: params.query,
max_results: params.max_results,
search_depth: params.search_depth,
topic: params.topic,
include_domains: params.include_domains,
start_date: params.start_date,
end_date: params.end_date,
};
for (const key of Object.keys(payload)) {
if (payload[key] === undefined) delete payload[key];
}
const data = await post("search", payload, SEARCH_TIMEOUT_MS, signal, fetchImpl);
const rawResults = Array.isArray(data.results) ? (data.results as Record<string, unknown>[]) : [];
return {
answer: typeof data.answer === "string" ? data.answer : undefined,
results: rawResults.map((result) => ({
title: result.title,
url: result.url,
content: result.content,
published_date: result.publishedDate ?? result.published_date,
})),
};
}
/** POST /extract — full page content as markdown for a batch of URLs. */
export async function tavilyExtract(
urls: string[],
signal?: AbortSignal,
fetchImpl: FetchLike = fetch,
): Promise<{ results: Array<{ url?: string; content?: string }>; failed: string[] }> {
const data = await post("extract", { urls, format: "markdown" }, EXTRACT_TIMEOUT_MS, signal, fetchImpl);
const rawResults = Array.isArray(data.results) ? (data.results as Record<string, unknown>[]) : [];
const rawFailed = Array.isArray(data.failed_results) ? (data.failed_results as Record<string, unknown>[]) : [];
return {
results: rawResults.map((result) => ({ url: result.url as string | undefined, content: result.raw_content as string | undefined })),
failed: rawFailed.map((result) => String(result.url ?? "unknown")),
};
}