mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
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>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
6fe6cb29ae
commit
ee1e6999d7
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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")),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user