feat(pi-web-search): 搜索凭据迁移到 Pi auth.json

三个搜索 key 改为 auth.json 中的 tavily/exa/keenable api_key 凭据,
pi-web-search/auth.ts 复刻上游 resolve-config-value 语义(!command、
$VAR 插值、credential env 优先、$$/$! 转义)并在每次调用时读取,
进程环境变量与 search.env 不再被直接读取。search_config.sh 重写为
scripts/search-auth.mjs 的薄封装:加锁原子写、权限 600、保留其他
凭据条目,支持 --migrate 从旧 search.env 一次性导入。

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:39:13 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent ee1e6999d7
commit 0c9823ef5c
20 changed files with 909 additions and 335 deletions
+41 -65
View File
@@ -29,24 +29,8 @@ function ctxAnswering(answer: string, calls: { count: number } = { count: 0 }) {
};
}
function withEnv<T>(key: string, value: string | undefined, fn: () => Promise<T> | T): Promise<T> | T {
const previous = process.env[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
const restore = () => {
if (previous === undefined) delete process.env[key];
else process.env[key] = previous;
};
try {
const out = fn();
if (out instanceof Promise) return out.finally(restore);
restore();
return out;
} catch (error) {
restore();
throw error;
}
}
const WITH_TAVILY = () => "k";
const KEYLESS = () => undefined;
test("extension registers only web_search and web_fetch, with no provider in the schema", () => {
const tools = new Map<string, { parameters?: { properties?: Record<string, unknown> }; description?: string }>();
@@ -64,52 +48,46 @@ test("extension registers only web_search and web_fetch, with no provider in the
});
test("dispatchSearch runs the model-picked provider first and records details", async () => {
await withEnv("TAVILY_API_KEY", "k", async () => {
const ran: string[] = [];
const runners = createProviderRunners({
exa: async () => {
ran.push("exa");
return "exa results";
},
});
const result = await dispatchSearch(PARAMS, ctxAnswering("exa"), new Map(), undefined, runners);
assert.deepEqual(ran, ["exa"]);
assert.equal(result.content[0]!.type === "text" && result.content[0]!.text, "exa results");
assert.deepEqual(result.details, { provider: "exa", routedBy: "model" });
const ran: string[] = [];
const runners = createProviderRunners({
exa: async () => {
ran.push("exa");
return "exa results";
},
});
const result = await dispatchSearch(PARAMS, ctxAnswering("exa"), new Map(), undefined, runners, WITH_TAVILY);
assert.deepEqual(ran, ["exa"]);
assert.equal(result.content[0]!.type === "text" && result.content[0]!.text, "exa results");
assert.deepEqual(result.details, { provider: "exa", routedBy: "model" });
});
test("dispatchSearch falls back across remaining providers when the pick fails", async () => {
await withEnv("TAVILY_API_KEY", "k", async () => {
const ran: string[] = [];
const runners = createProviderRunners({
tavily: async () => {
ran.push("tavily");
throw new Error("tavily down");
},
exa: async () => {
ran.push("exa");
return "exa results";
},
});
const result = await dispatchSearch(PARAMS, ctxAnswering("tavily"), new Map(), undefined, runners);
assert.deepEqual(ran, ["tavily", "exa"]);
assert.equal((result.details as Record<string, unknown>).provider, "exa");
assert.equal((result.details as Record<string, unknown>).routedBy, "provider-fallback");
const ran: string[] = [];
const runners = createProviderRunners({
tavily: async () => {
ran.push("tavily");
throw new Error("tavily down");
},
exa: async () => {
ran.push("exa");
return "exa results";
},
});
const result = await dispatchSearch(PARAMS, ctxAnswering("tavily"), new Map(), undefined, runners, WITH_TAVILY);
assert.deepEqual(ran, ["tavily", "exa"]);
assert.equal((result.details as Record<string, unknown>).provider, "exa");
assert.equal((result.details as Record<string, unknown>).routedBy, "provider-fallback");
});
test("dispatchSearch reports an error result when every provider fails", async () => {
await withEnv("TAVILY_API_KEY", "k", async () => {
const runners = createProviderRunners({
tavily: async () => { throw new Error("t"); },
exa: async () => { throw new Error("e"); },
keenable: async () => { throw new Error("k"); },
});
const result = await dispatchSearch(PARAMS, ctxAnswering("exa"), new Map(), undefined, runners);
assert.equal((result.details as Record<string, unknown>).error, true);
assert.match(result.content[0]!.type === "text" ? result.content[0]!.text : "", /failed on every available provider/);
const runners = createProviderRunners({
tavily: async () => { throw new Error("t"); },
exa: async () => { throw new Error("e"); },
keenable: async () => { throw new Error("k"); },
});
const result = await dispatchSearch(PARAMS, ctxAnswering("exa"), new Map(), undefined, runners, WITH_TAVILY);
assert.equal((result.details as Record<string, unknown>).error, true);
assert.match(result.content[0]!.type === "text" ? result.content[0]!.text : "", /failed on every available provider/);
});
test("normalizeUrls accepts a string or array, trims, and deduplicates", () => {
@@ -118,17 +96,15 @@ test("normalizeUrls accepts a string or array, trims, and deduplicates", () => {
});
test("dispatchFetch prefers exa and degrades to keenable without a model call", async () => {
await withEnv("TAVILY_API_KEY", undefined, async () => {
const fetchers = createFetchRunners({
exa: async (urls) => {
throw new Error(`exa down for ${urls.length}`);
},
keenable: async (urls) => `keenable pages: ${urls.join(",")}`,
});
const result = await dispatchFetch({ urls: ["https://a.example", "https://b.example"] }, undefined, fetchers);
assert.equal((result.details as Record<string, unknown>).provider, "keenable");
assert.match(result.content[0]!.type === "text" ? result.content[0]!.text : "", /keenable pages: https:\/\/a\.example,https:\/\/b\.example/);
const fetchers = createFetchRunners({
exa: async (urls) => {
throw new Error(`exa down for ${urls.length}`);
},
keenable: async (urls) => `keenable pages: ${urls.join(",")}`,
});
const result = await dispatchFetch({ urls: ["https://a.example", "https://b.example"] }, undefined, fetchers, KEYLESS);
assert.equal((result.details as Record<string, unknown>).provider, "keenable");
assert.match(result.content[0]!.type === "text" ? result.content[0]!.text : "", /keenable pages: https:\/\/a\.example,https:\/\/b\.example/);
});
test("dispatchFetch validates input and caps the URL count", async () => {