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,109 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
availableProviders,
|
||||
buildRouterPrompt,
|
||||
parseProviderChoice,
|
||||
routeSearch,
|
||||
ROUTE_CACHE_LIMIT,
|
||||
type RouterContext,
|
||||
type SearchProvider,
|
||||
} from "../router.ts";
|
||||
|
||||
const ALL: SearchProvider[] = ["tavily", "exa", "keenable"];
|
||||
const ENV_ALL_KEYS = { TAVILY_API_KEY: "test-key" };
|
||||
|
||||
function ctxAnswering(answer: string, calls: { count: number } = { count: 0 }): RouterContext {
|
||||
return {
|
||||
model: { provider: "test", id: "main" },
|
||||
modelRegistry: {
|
||||
complete: async () => {
|
||||
calls.count += 1;
|
||||
return { content: [{ type: "text", text: answer }] };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("availability: tavily needs a key, exa and keenable are keyless", () => {
|
||||
assert.deepEqual(availableProviders({}), ["exa", "keenable"]);
|
||||
assert.deepEqual(availableProviders(ENV_ALL_KEYS), ["tavily", "exa", "keenable"]);
|
||||
});
|
||||
|
||||
test("router prompt lists only candidate rules plus the query and caller hints", () => {
|
||||
const prompt = buildRouterPrompt(
|
||||
{ query: "python 3.14 release notes", site: "python.org", published_after: "2025-10-01" },
|
||||
ALL,
|
||||
);
|
||||
assert.match(prompt, /- tavily: /);
|
||||
assert.match(prompt, /- exa: /);
|
||||
assert.match(prompt, /- keenable: /);
|
||||
assert.match(prompt, /Query: python 3\.14 release notes/);
|
||||
assert.match(prompt, /site filter.*python\.org|site: python\.org/);
|
||||
assert.match(prompt, /Provider:$/);
|
||||
|
||||
const subset = buildRouterPrompt({ query: "hi" }, ["exa", "keenable"]);
|
||||
assert.doesNotMatch(subset, /- tavily/);
|
||||
assert.match(subset, /exactly one word: exa, keenable/);
|
||||
});
|
||||
|
||||
test("parseProviderChoice takes the first available candidate token", () => {
|
||||
assert.equal(parseProviderChoice("tavily", ALL), "tavily");
|
||||
assert.equal(parseProviderChoice("I would pick Exa here.", ALL), "exa");
|
||||
assert.equal(parseProviderChoice("keenable!", ALL), "keenable");
|
||||
assert.equal(parseProviderChoice("google maybe", ALL), undefined);
|
||||
// Model picked an unavailable provider but mentioned a valid one later.
|
||||
assert.equal(parseProviderChoice("tavily or keenable", ["exa", "keenable"]), "keenable");
|
||||
assert.equal(parseProviderChoice("tavily", ["exa", "keenable"]), undefined);
|
||||
});
|
||||
|
||||
test("routeSearch honors the model pick and caches identical requests", async () => {
|
||||
const calls = { count: 0 };
|
||||
const ctx = ctxAnswering("exa", calls);
|
||||
const cache = new Map<string, SearchProvider>();
|
||||
const request = { query: "official MCP spec" };
|
||||
|
||||
const first = await routeSearch(request, ctx, cache, undefined, ENV_ALL_KEYS);
|
||||
assert.deepEqual(first, { provider: "exa", routedBy: "model" });
|
||||
|
||||
const second = await routeSearch(request, ctx, cache, undefined, ENV_ALL_KEYS);
|
||||
assert.deepEqual(second, { provider: "exa", routedBy: "cache" });
|
||||
assert.equal(calls.count, 1);
|
||||
});
|
||||
|
||||
test("routeSearch falls back to the first available provider on unroutable or failed model calls", async () => {
|
||||
const cache = new Map<string, SearchProvider>();
|
||||
const unparseable = await routeSearch({ query: "x" }, ctxAnswering("no idea"), cache, undefined, ENV_ALL_KEYS);
|
||||
assert.deepEqual(unparseable, { provider: "tavily", routedBy: "fallback" });
|
||||
|
||||
const throwing: RouterContext = {
|
||||
model: {},
|
||||
modelRegistry: { complete: async () => { throw new Error("boom"); } },
|
||||
};
|
||||
const failed = await routeSearch({ query: "x" }, throwing, cache, undefined, ENV_ALL_KEYS);
|
||||
assert.deepEqual(failed, { provider: "tavily", routedBy: "fallback" });
|
||||
});
|
||||
|
||||
test("routeSearch without a tavily key excludes it from candidates", async () => {
|
||||
const cache = new Map<string, SearchProvider>();
|
||||
const decision = await routeSearch({ query: "x" }, ctxAnswering("tavily"), cache, undefined, {});
|
||||
// "tavily" is not a candidate → unroutable → first available fallback.
|
||||
assert.deepEqual(decision, { provider: "exa", routedBy: "fallback" });
|
||||
});
|
||||
|
||||
test("routeSearch works without a model", async () => {
|
||||
const cache = new Map<string, SearchProvider>();
|
||||
const decision = await routeSearch({ query: "x" }, {}, cache, undefined, ENV_ALL_KEYS);
|
||||
assert.deepEqual(decision, { provider: "tavily", routedBy: "fallback" });
|
||||
});
|
||||
|
||||
test("route cache evicts the oldest entry at the limit", async () => {
|
||||
const cache = new Map<string, SearchProvider>();
|
||||
for (let i = 0; i < ROUTE_CACHE_LIMIT; i++) cache.set(`dummy-${i}`, "exa");
|
||||
const decision = await routeSearch({ query: "fresh query" }, ctxAnswering("keenable"), cache, undefined, ENV_ALL_KEYS);
|
||||
assert.equal(decision.provider, "keenable");
|
||||
assert.equal(cache.size, ROUTE_CACHE_LIMIT);
|
||||
assert.equal(cache.has("dummy-0"), false);
|
||||
assert.equal(cache.has("dummy-1"), true);
|
||||
});
|
||||
Reference in New Issue
Block a user