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,163 @@
|
||||
/**
|
||||
* Provider router for web_search. The decision is delegated to the session's
|
||||
* current main model (ctx.model via ctx.modelRegistry.complete) so no extra
|
||||
* model configuration is needed; deterministic rules only handle availability
|
||||
* and failure fallbacks, never the routing itself.
|
||||
*/
|
||||
|
||||
export type SearchProvider = "tavily" | "exa" | "keenable";
|
||||
|
||||
export interface SearchRequest {
|
||||
query: string;
|
||||
site?: string;
|
||||
published_after?: string;
|
||||
published_before?: string;
|
||||
depth?: string;
|
||||
}
|
||||
|
||||
export type RoutedBy = "model" | "cache" | "fallback";
|
||||
|
||||
export interface RouteDecision {
|
||||
provider: SearchProvider;
|
||||
routedBy: RoutedBy;
|
||||
}
|
||||
|
||||
const ROUTER_TIMEOUT_MS = 15_000;
|
||||
export const ROUTE_CACHE_LIMIT = 32;
|
||||
|
||||
/** Order used only when the model cannot decide or no candidates remain. */
|
||||
export const PROVIDER_FALLBACK_ORDER: SearchProvider[] = ["tavily", "exa", "keenable"];
|
||||
|
||||
const PROVIDER_RULES: Record<SearchProvider, string> = {
|
||||
tavily:
|
||||
"Broad discovery. Best when keywords are uncertain, for news and recent or trending topics, " +
|
||||
"and when many candidate sources are useful. Results can mix official pages, blogs, forums, " +
|
||||
"and secondary articles; returned content is naturally long.",
|
||||
exa:
|
||||
"Precision sources. Best for official or API documentation, release notes and version " +
|
||||
"information, academic papers and research material, and fact confirmation that needs " +
|
||||
"high-quality sources.",
|
||||
keenable:
|
||||
"Focused and compact. Best for Chinese-language pages (policies, government notices), " +
|
||||
"site-restricted search, publication-date filtering, and tight control over returned " +
|
||||
"content length.",
|
||||
};
|
||||
|
||||
/** Tavily requires a key; Exa and Keenable both work keyless. */
|
||||
export function availableProviders(env: Record<string, string | undefined> = process.env): SearchProvider[] {
|
||||
const providers: SearchProvider[] = [];
|
||||
if (env.TAVILY_API_KEY?.trim()) providers.push("tavily");
|
||||
providers.push("exa", "keenable");
|
||||
return providers;
|
||||
}
|
||||
|
||||
export function buildRouterPrompt(request: SearchRequest, candidates: SearchProvider[]): string {
|
||||
const rules = candidates.map((provider) => `- ${provider}: ${PROVIDER_RULES[provider]}`).join("\n");
|
||||
const lines = [
|
||||
"Pick the single best web search provider for this query.",
|
||||
`Answer with exactly one word: ${candidates.join(", ")}.`,
|
||||
"",
|
||||
rules,
|
||||
"",
|
||||
`Query: ${request.query}`,
|
||||
];
|
||||
if (request.site) lines.push(`The caller asked to restrict results to the site: ${request.site}`);
|
||||
if (request.published_after || request.published_before) {
|
||||
lines.push(
|
||||
`The caller asked for a publication-date filter: ${request.published_after ?? "…"} to ${request.published_before ?? "…"}`,
|
||||
);
|
||||
}
|
||||
if (request.depth === "advanced") lines.push("The caller asked for a deep, high-precision search.");
|
||||
lines.push("Provider:");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** First candidate-matching provider token in the model's reply, else undefined. */
|
||||
export function parseProviderChoice(text: string, candidates: SearchProvider[]): SearchProvider | undefined {
|
||||
const matches = text.toLowerCase().match(/\b(tavily|exa|keenable)\b/gu) ?? [];
|
||||
for (const match of matches) {
|
||||
const pick = match as SearchProvider;
|
||||
if (candidates.includes(pick)) return pick;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function routeCacheKey(request: SearchRequest): string {
|
||||
return [
|
||||
request.query.trim().toLowerCase(),
|
||||
request.site ?? "",
|
||||
request.published_after ?? "",
|
||||
request.published_before ?? "",
|
||||
request.depth ?? "",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
interface ModelMessage {
|
||||
stopReason?: string;
|
||||
errorMessage?: string;
|
||||
content?: Array<{ type?: string; text?: string }>;
|
||||
}
|
||||
|
||||
/** Minimal structural view of the extension context needed for routing. */
|
||||
export interface RouterContext {
|
||||
model?: unknown;
|
||||
modelRegistry?: {
|
||||
complete?: (model: unknown, request: unknown, options: unknown) => Promise<ModelMessage>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function routeSearch(
|
||||
request: SearchRequest,
|
||||
ctx: RouterContext,
|
||||
cache: Map<string, SearchProvider>,
|
||||
signal?: AbortSignal,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): Promise<RouteDecision> {
|
||||
const candidates = availableProviders(env);
|
||||
const fallback = PROVIDER_FALLBACK_ORDER.find((provider) => candidates.includes(provider)) ?? candidates[0];
|
||||
if (!fallback || candidates.length <= 1) {
|
||||
return { provider: fallback ?? "keenable", routedBy: "fallback" };
|
||||
}
|
||||
|
||||
const key = routeCacheKey(request);
|
||||
const cached = cache.get(key);
|
||||
if (cached !== undefined && candidates.includes(cached)) {
|
||||
return { provider: cached, routedBy: "cache" };
|
||||
}
|
||||
|
||||
const complete = ctx.modelRegistry?.complete;
|
||||
if (!ctx.model || typeof complete !== "function") {
|
||||
return { provider: fallback, routedBy: "fallback" };
|
||||
}
|
||||
|
||||
try {
|
||||
const timeout = AbortSignal.timeout(ROUTER_TIMEOUT_MS);
|
||||
const message = await complete.call(ctx.modelRegistry, ctx.model, {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: buildRouterPrompt(request, candidates) }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}, {
|
||||
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
});
|
||||
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
||||
throw new Error(message.errorMessage ?? `routing stopped: ${message.stopReason}`);
|
||||
}
|
||||
const text = (message.content ?? [])
|
||||
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
|
||||
.join("\n");
|
||||
const pick = parseProviderChoice(text, candidates);
|
||||
if (!pick) return { provider: fallback, routedBy: "fallback" };
|
||||
if (!cache.has(key) && cache.size >= ROUTE_CACHE_LIMIT) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest !== undefined) cache.delete(oldest);
|
||||
}
|
||||
cache.set(key, pick);
|
||||
return { provider: pick, routedBy: "model" };
|
||||
} catch {
|
||||
return { provider: fallback, routedBy: "fallback" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user