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:
云服务部-叶林立
2026-10-19 20:13:37 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 6fe6cb29ae
commit ee1e6999d7
32 changed files with 1690 additions and 1188 deletions
+243
View File
@@ -0,0 +1,243 @@
/**
* Exa client: direct JSON-RPC over the hosted endpoint https://mcp.exa.ai/mcp.
* This is a plain HTTPS POST client (no MCP adapter, no SDK): initialize once,
* cache the `mcp-session-id` response header, then POST `tools/call` per call.
* Keyless works; EXA_API_KEY is sent as the `x-api-key` header when present.
*/
export class ExaError extends Error {}
const EXA_MCP_URL = "https://mcp.exa.ai/mcp?tools=web_search_exa,web_search_advanced_exa,web_fetch_exa";
const PROTOCOL_VERSION = "2025-03-26";
const SESSION_TIMEOUT_MS = 15_000;
const CALL_TIMEOUT_MS = 60_000;
/** Cap page text per result so advanced search responses stay bounded. */
export const EXA_SEARCH_TEXT_MAX_CHARACTERS = 2000;
export const EXA_FETCH_MAX_CHARACTERS = 3000;
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
export interface ExaSearchParams {
query: string;
numResults?: number;
includeDomains?: string[];
startPublishedDate?: string;
endPublishedDate?: string;
textMaxCharacters?: number;
}
let cachedSessionId: string | undefined;
let pendingSession: Promise<string> | undefined;
let nextRequestId = 1;
/** Clear the cached MCP session (used on expiry and by tests). */
export function resetExaSession(): void {
cachedSessionId = undefined;
}
function baseHeaders(): Record<string, string> {
const headers: Record<string, string> = {
"content-type": "application/json",
accept: "application/json, text/event-stream",
};
const key = process.env.EXA_API_KEY?.trim();
if (key) headers["x-api-key"] = key;
return headers;
}
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
/**
* Extract the JSON-RPC message for `id` from a raw JSON body or an SSE stream
* (one or more `data: {...}` lines).
*/
export function parseRpcPayload(bodyText: string, id: number): Record<string, unknown> {
const candidates: string[] = [];
const trimmed = bodyText.trim();
if (trimmed.startsWith("{")) candidates.push(trimmed);
for (const line of trimmed.split(/\r?\n/u)) {
if (line.startsWith("data:")) candidates.push(line.slice(5).trim());
}
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate) as Record<string, unknown>;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && parsed.id === id) {
return parsed;
}
} catch {
// Not JSON — try the next candidate line.
}
}
throw new ExaError("Exa endpoint returned an unreadable response.");
}
async function post(
body: Record<string, unknown>,
sessionId: string | undefined,
timeoutMs: number,
signal: AbortSignal | undefined,
fetchImpl: FetchLike,
): Promise<{ status: number; sessionId?: string; text: string }> {
const headers = baseHeaders();
if (sessionId) headers["mcp-session-id"] = sessionId;
let response: Response;
try {
response = await fetchImpl(EXA_MCP_URL, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: withTimeout(signal, timeoutMs),
});
} catch (error) {
throw new ExaError(`Could not reach the Exa endpoint: ${(error as Error).message}`);
}
return {
status: response.status,
sessionId: response.headers.get("mcp-session-id") ?? undefined,
text: await response.text(),
};
}
async function initialize(signal: AbortSignal | undefined, fetchImpl: FetchLike): Promise<string> {
const id = nextRequestId++;
const res = await post(
{
jsonrpc: "2.0",
id,
method: "initialize",
params: {
protocolVersion: PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "my-pi-web-search", version: "0.1.0" },
},
},
undefined,
SESSION_TIMEOUT_MS,
signal,
fetchImpl,
);
if (res.status !== 200) throw new ExaError(`Exa initialize failed (HTTP ${res.status}).`);
const sessionId = res.sessionId;
if (!sessionId) throw new ExaError("Exa endpoint did not return an MCP session id.");
const payload = parseRpcPayload(res.text, id);
if (payload.error) throw new ExaError(`Exa initialize error: ${(payload.error as { message?: string }).message ?? "unknown"}`);
const notice = await post(
{ jsonrpc: "2.0", method: "notifications/initialized" },
sessionId,
SESSION_TIMEOUT_MS,
signal,
fetchImpl,
);
if (notice.status >= 400) {
throw new ExaError(`Exa initialized notification failed (HTTP ${notice.status}).`);
}
return sessionId;
}
function sessionId(signal: AbortSignal | undefined, fetchImpl: FetchLike): Promise<string> {
if (cachedSessionId) return Promise.resolve(cachedSessionId);
pendingSession ??= initialize(signal, fetchImpl)
.then((id) => {
cachedSessionId = id;
return id;
})
.finally(() => {
pendingSession = undefined;
});
return pendingSession;
}
function contentText(result: Record<string, unknown>): string {
const content = Array.isArray(result.content) ? (result.content as Record<string, unknown>[]) : [];
return content
.filter((part) => part?.type === "text" && typeof part.text === "string")
.map((part) => part.text as string)
.join("\n")
.trim();
}
async function callTool(
toolName: string,
args: Record<string, unknown>,
signal: AbortSignal | undefined,
fetchImpl: FetchLike,
allowRetry: boolean,
): Promise<Record<string, unknown>> {
const sid = await sessionId(signal, fetchImpl);
const id = nextRequestId++;
const res = await post(
{ jsonrpc: "2.0", id, method: "tools/call", params: { name: toolName, arguments: args } },
sid,
CALL_TIMEOUT_MS,
signal,
fetchImpl,
);
if (res.status === 404 && allowRetry) {
// Session expired server-side: drop it, re-initialize, retry once.
cachedSessionId = undefined;
return callTool(toolName, args, signal, fetchImpl, false);
}
if (res.status !== 200) {
throw new ExaError(`Exa ${toolName} failed (HTTP ${res.status}).`);
}
const payload = parseRpcPayload(res.text, id);
if (payload.error) {
const message = (payload.error as { message?: string }).message ?? "unknown";
if (allowRetry && /session/iu.test(message)) {
cachedSessionId = undefined;
return callTool(toolName, args, signal, fetchImpl, false);
}
throw new ExaError(`Exa ${toolName} error: ${message}`);
}
const result = (payload.result ?? {}) as Record<string, unknown>;
if (result.isError) {
throw new ExaError(`Exa ${toolName} failed: ${contentText(result) || "unknown error"}`);
}
return result;
}
/** tools/call web_search_advanced_exa — precise search with bounded text per result. */
export async function exaSearch(
params: ExaSearchParams,
signal?: AbortSignal,
fetchImpl: FetchLike = fetch,
): Promise<string> {
const result = await callTool(
"web_search_advanced_exa",
{
query: params.query,
type: "auto",
numResults: params.numResults ?? 8,
textMaxCharacters: params.textMaxCharacters ?? EXA_SEARCH_TEXT_MAX_CHARACTERS,
...(params.includeDomains?.length ? { includeDomains: params.includeDomains } : {}),
...(params.startPublishedDate ? { startPublishedDate: params.startPublishedDate } : {}),
...(params.endPublishedDate ? { endPublishedDate: params.endPublishedDate } : {}),
},
signal,
fetchImpl,
true,
);
return contentText(result) || "Exa returned no content.";
}
/** tools/call web_fetch_exa — full page content as clean markdown for a batch of URLs. */
export async function exaFetchPages(
urls: string[],
maxCharacters?: number,
signal?: AbortSignal,
fetchImpl: FetchLike = fetch,
): Promise<string> {
const result = await callTool(
"web_fetch_exa",
{ urls, maxCharacters: maxCharacters ?? EXA_FETCH_MAX_CHARACTERS },
signal,
fetchImpl,
true,
);
return contentText(result) || "Exa returned no content.";
}
+222
View File
@@ -0,0 +1,222 @@
/**
* Keenable REST client, adapted from @keenable/pi-search (keenable-client.ts).
* Keyless by default (`/v1/*\/public`), authenticated when KEENABLE_API_KEY is
* present (`/v1/*`), HTTPS-only base URL, `X-Keenable-Title` attribution.
*/
import { isIP } from "node:net";
export class KeenableError extends Error {}
const CLIENT_VERSION = "0.1.0";
const DEFAULT_BASE_URL = "https://api.keenable.ai";
const BASE_URL_ENV = "KEENABLE_API_URL";
const API_KEY_ENV = "KEENABLE_API_KEY";
const ATTRIBUTION_TITLE = "Pi";
const USER_AGENT = `pi-web-search/${CLIENT_VERSION}`;
const DEFAULT_TIMEOUT_MS = 30_000;
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
export interface KeenableSearchParams {
query: string;
mode?: "pro";
site?: string;
published_after?: string;
published_before?: string;
}
export interface KeenableSearchResult {
title?: string;
url?: string;
description?: string;
published_at?: string;
acquired_at?: string;
[key: string]: unknown;
}
export interface KeenableFetchResult {
url?: string;
title?: string;
content?: string;
description?: string;
author?: string;
published_at?: string;
[key: string]: unknown;
}
/** Resolve the API base URL from `KEENABLE_API_URL`, enforcing HTTPS (http only for loopback). */
function resolveBaseUrl(): string {
const base = (process.env[BASE_URL_ENV] || DEFAULT_BASE_URL).replace(/\/+$/u, "");
let parsed: URL;
try {
parsed = new URL(base);
} catch {
throw new KeenableError(`${BASE_URL_ENV} must be a valid URL with a host, got ${base}`);
}
if (parsed.hostname) {
if (parsed.protocol === "https:") return base;
if (parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname)) {
return base;
}
}
throw new KeenableError(`${BASE_URL_ENV} must be an https:// URL with a host, got ${base}`);
}
/** Non-blank API key from the env, or undefined to use the keyless public tier. */
function resolveApiKey(): string | undefined {
const key = (process.env[API_KEY_ENV] ?? "").trim();
return key || undefined;
}
function buildHeaders(apiKey: string | undefined): Record<string, string> {
const headers: Record<string, string> = {
Accept: "application/json",
"User-Agent": USER_AGENT,
"X-Keenable-Title": ATTRIBUTION_TITLE,
};
if (apiKey) headers["X-API-Key"] = apiKey;
return headers;
}
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
function isPrivateIp(host: string): boolean {
const v = isIP(host);
if (v === 4) {
const p = host.split(".").map(Number);
return (
p[0] === 10 ||
p[0] === 127 ||
p[0] === 0 ||
(p[0] === 169 && p[1] === 254) ||
(p[0] === 172 && p[1] >= 16 && p[1] <= 31) ||
(p[0] === 192 && p[1] === 168) ||
(p[0] === 100 && p[1] >= 64 && p[1] <= 127) ||
(p[0] === 198 && (p[1] === 18 || p[1] === 19)) ||
p[0] >= 224
);
}
if (v === 6) {
const h = host.toLowerCase();
return h === "::1" || h === "::" || h.startsWith("fe80") || h.startsWith("fc") || h.startsWith("fd");
}
return false;
}
/** Refuse private/internal fetch targets before sending (SSRF). */
function assertPublicFetchTarget(rawUrl: string): void {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new KeenableError(`Not a valid URL: ${rawUrl}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new KeenableError(`Refusing to fetch a non-http(s) URL: ${rawUrl}`);
}
const host = (parsed.hostname || "").toLowerCase();
if (!host) throw new KeenableError(`Refusing to fetch a URL with no host: ${rawUrl}`);
if (host === "localhost" || host === "metadata.google.internal" || isPrivateIp(host)) {
throw new KeenableError(`Refusing to fetch a private/internal host: ${host}`);
}
}
async function raiseForStatus(response: Response, keyed: boolean): Promise<void> {
if (response.ok) return;
if (!keyed && response.status === 429) {
throw new KeenableError(
"Keenable keyless requests hit their rate limit. Set KEENABLE_API_KEY to raise the limits.",
);
}
let detail = "";
try {
const body = (await response.json()) as Record<string, unknown>;
detail = String(body.message ?? body.error ?? body.detail ?? "");
} catch {
detail = (await response.text().catch(() => "")).trim();
}
const label =
{
401: "Keenable authentication failed (401)",
402: "Keenable: insufficient credits (402)",
429: "Keenable rate limit exceeded (429)",
}[response.status] ?? `Keenable API error (${response.status})`;
throw new KeenableError(detail ? `${label}: ${detail}` : label);
}
async function readJsonObject(response: Response): Promise<Record<string, unknown>> {
let data: unknown;
try {
data = await response.json();
} catch {
throw new KeenableError("Keenable API returned a non-JSON response.");
}
if (!data || typeof data !== "object" || Array.isArray(data)) {
throw new KeenableError("Unexpected (non-object) response from the Keenable API.");
}
return data as Record<string, unknown>;
}
/** POST /v1/search (keyed) or /v1/search/public (keyless). */
export async function keenableSearch(
params: KeenableSearchParams,
signal: AbortSignal | undefined,
fetchImpl: FetchLike = fetch,
): Promise<KeenableSearchResult[]> {
const apiKey = resolveApiKey();
const path = apiKey ? "/v1/search" : "/v1/search/public";
const payload: Record<string, unknown> = { query: params.query, mode: params.mode ?? "pro" };
for (const field of ["site", "published_after", "published_before"] as const) {
const value = params[field];
if (value) payload[field] = value;
}
let response: Response;
try {
response = await fetchImpl(`${resolveBaseUrl()}${path}`, {
method: "POST",
headers: { ...buildHeaders(apiKey), "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: withTimeout(signal, DEFAULT_TIMEOUT_MS),
});
} catch (err) {
throw new KeenableError(`Could not reach the Keenable API: ${(err as Error).message}`);
}
await raiseForStatus(response, Boolean(apiKey));
const data = await readJsonObject(response);
const results = data.results;
if (!Array.isArray(results)) {
throw new KeenableError("Unexpected response from the Keenable search API (no results array).");
}
return results as KeenableSearchResult[];
}
/** GET /v1/fetch?url= (keyed) or /v1/fetch/public?url= (keyless). */
export async function keenableFetch(
url: string,
signal: AbortSignal | undefined,
fetchImpl: FetchLike = fetch,
): Promise<KeenableFetchResult> {
assertPublicFetchTarget(url);
const apiKey = resolveApiKey();
const path = apiKey ? "/v1/fetch" : "/v1/fetch/public";
const endpoint = new URL(`${resolveBaseUrl()}${path}`);
endpoint.searchParams.set("url", url);
let response: Response;
try {
response = await fetchImpl(endpoint, {
method: "GET",
headers: buildHeaders(apiKey),
signal: withTimeout(signal, DEFAULT_TIMEOUT_MS),
});
} catch (err) {
throw new KeenableError(`Could not reach the Keenable API: ${(err as Error).message}`);
}
await raiseForStatus(response, Boolean(apiKey));
return (await readJsonObject(response)) as KeenableFetchResult;
}
+125
View File
@@ -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")),
};
}