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
+11 -5
View File
@@ -15,16 +15,22 @@ Unified web search and fetch for Pi. The main model only ever sees two tools —
Plain `fetch` calls — no MCP adapter, no upstream extension wrappers:
- `clients/tavily.ts` — `POST api.tavily.com/search|extract`, Bearer key.
Requires `TAVILY_API_KEY`.
Requires a `tavily` credential.
- `clients/keenable.ts` — `api.keenable.ai`, keyless `/public` endpoints by
default, `KEENABLE_API_KEY` raises rate limits. Adapted from
default, a `keenable` credential raises rate limits. Adapted from
`@keenable/pi-search`.
- `clients/exa.ts` — JSON-RPC over `mcp.exa.ai/mcp` (Streamable HTTP):
`initialize` once, cache `mcp-session-id`, `tools/call` per request, re-init
on session expiry. `EXA_API_KEY` optional (`x-api-key` header).
on session expiry. An `exa` credential is optional (`x-api-key` header).
Keys are loaded from `~/.config/my-pi/search.env` by `extensions/search-config.ts`
(written by `search_config.sh`); explicit environment variables win.
Keys live in Pi's `auth.json` as one `api_key` credential per provider
(`tavily`, `exa`, `keenable`), read by `auth.ts` with upstream resolution
semantics — literal values, `$VAR`/`${VAR}` interpolation, and `!command`
indirection (e.g. `"!op read op://Vault/Tavily/key"`). `search_config.sh`
writes them (atomically, mode 600, preserving all other entries); process
environment variables are not read directly — use `"key": "$TAVILY_API_KEY"`
to indirect through env. `KEENABLE_API_URL` stays env-only, or can be stored
in the keenable credential's `env` map.
## Routing
+201
View File
@@ -0,0 +1,201 @@
/**
* Search provider API keys live in Pi's own auth.json — one `api_key`
* credential per provider id (`tavily`, `exa`, `keenable`) next to Pi's
* model credentials. Key values follow upstream resolve-config-value
* semantics (replicated here because the upstream resolver is not exported):
*
* - `!command` executes the rest of the string and uses trimmed stdout,
* cached for the process lifetime (same as upstream's command cache)
* - `$NAME` / `${NAME}` interpolate environment variables; a credential-level
* `env` map wins over process env; a missing variable resolves to undefined
* - `$$` and `$!` escape to literal `$` and `!`
* - anything else is a literal key
*/
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export type SearchAuthProvider = "tavily" | "exa" | "keenable";
export const SEARCH_AUTH_PROVIDERS: readonly SearchAuthProvider[] = ["tavily", "exa", "keenable"];
/** Resolves a provider's API key; injectable so tests never touch real auth.json. */
export type SearchKeyResolver = (provider: SearchAuthProvider) => string | undefined;
export type SearchAuthEnvironment = Record<string, string | undefined>;
export interface SearchAuthOptions {
/** auth.json location; defaults to `<agent dir>/auth.json`. */
authPath?: string;
/**
* Environment used for the agent dir and `$VAR` interpolation. When
* provided it fully replaces `process.env` so tests stay isolated.
*/
env?: SearchAuthEnvironment;
/** Executes `!command` key references; defaults to a cached execSync runner. */
runCommand?: (command: string) => string | undefined;
}
export interface SearchApiKeyCredential {
type: "api_key";
key?: string;
env?: Record<string, string>;
}
const COMMAND_TIMEOUT_MS = 10_000;
const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
const ENV_VAR_NAME_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*/;
const commandResultCache = new Map<string, string | undefined>();
/** auth.json lives in Pi's agent dir: $PI_CODING_AGENT_DIR or ~/.pi/agent. */
export function getSearchAuthPath(env: SearchAuthEnvironment = process.env): string {
const agentDir = env.PI_CODING_AGENT_DIR?.trim() || join(env.HOME?.trim() || homedir(), ".pi", "agent");
return join(agentDir, "auth.json");
}
/** Read the raw `api_key` credential for a provider; undefined when absent or malformed. */
export function readSearchCredential(
provider: SearchAuthProvider,
options: SearchAuthOptions = {},
): SearchApiKeyCredential | undefined {
let data: unknown;
try {
data = JSON.parse(readFileSync(options.authPath ?? getSearchAuthPath(options.env), "utf8"));
} catch {
return undefined;
}
if (!data || typeof data !== "object" || Array.isArray(data)) return undefined;
const entry = (data as Record<string, unknown>)[provider];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined;
const credential = entry as Record<string, unknown>;
if (credential.type !== "api_key") return undefined;
if (credential.key !== undefined && typeof credential.key !== "string") return undefined;
const env = credential.env;
const scopedEnv =
env && typeof env === "object" && !Array.isArray(env)
? Object.fromEntries(
Object.entries(env as Record<string, unknown>).filter(
(pair): pair is [string, string] => typeof pair[1] === "string",
),
)
: undefined;
return { type: "api_key", key: credential.key as string | undefined, env: scopedEnv };
}
type TemplatePart = { type: "literal"; value: string } | { type: "env"; name: string };
/** Mirrors upstream parseConfigValueTemplate: `$$`/`$!` escape, `${A}`/`$A` interpolate. */
export function parseAuthKeyTemplate(config: string): TemplatePart[] {
const parts: TemplatePart[] = [];
const appendLiteral = (value: string) => {
if (!value) return;
const previous = parts[parts.length - 1];
if (previous?.type === "literal") {
previous.value += value;
return;
}
parts.push({ type: "literal", value });
};
let index = 0;
while (index < config.length) {
const dollarIndex = config.indexOf("$", index);
if (dollarIndex < 0) {
appendLiteral(config.slice(index));
break;
}
appendLiteral(config.slice(index, dollarIndex));
const nextChar = config[dollarIndex + 1];
if (nextChar === "$" || nextChar === "!") {
appendLiteral(nextChar);
index = dollarIndex + 2;
continue;
}
if (nextChar === "{") {
const endIndex = config.indexOf("}", dollarIndex + 2);
if (endIndex < 0) {
appendLiteral("$");
index = dollarIndex + 1;
continue;
}
const name = config.slice(dollarIndex + 2, endIndex);
if (ENV_VAR_NAME.test(name)) {
parts.push({ type: "env", name });
} else {
appendLiteral(config.slice(dollarIndex, endIndex + 1));
}
index = endIndex + 1;
continue;
}
const match = config.slice(dollarIndex + 1).match(ENV_VAR_NAME_PREFIX);
if (match) {
parts.push({ type: "env", name: match[0] });
index = dollarIndex + 1 + match[0].length;
continue;
}
appendLiteral("$");
index = dollarIndex + 1;
}
return parts;
}
function defaultRunCommand(command: string): string | undefined {
if (commandResultCache.has(command)) return commandResultCache.get(command);
let value: string | undefined;
try {
const output = execSync(command, {
encoding: "utf-8",
timeout: COMMAND_TIMEOUT_MS,
stdio: ["ignore", "pipe", "ignore"],
});
value = output.trim() || undefined;
} catch {
value = undefined;
}
commandResultCache.set(command, value);
return value;
}
/** Mirrors upstream resolveConfigValue, including credential-scoped env precedence. */
export function resolveAuthKeyValue(
config: string,
credentialEnv: Record<string, string> | undefined,
options: SearchAuthOptions = {},
): string | undefined {
if (config.startsWith("!")) {
return (options.runCommand ?? defaultRunCommand)(config.slice(1));
}
const ambient = options.env ?? process.env;
let resolved = "";
for (const part of parseAuthKeyTemplate(config)) {
if (part.type === "literal") {
resolved += part.value;
continue;
}
const value = credentialEnv?.[part.name] || ambient[part.name];
if (value === undefined) return undefined;
resolved += value;
}
return resolved;
}
/** Provider API key from auth.json, or undefined when not configured. */
export function getSearchApiKey(
provider: SearchAuthProvider,
options: SearchAuthOptions = {},
): string | undefined {
const credential = readSearchCredential(provider, options);
if (!credential?.key) return undefined;
const resolved = resolveAuthKeyValue(credential.key, credential.env, options);
return resolved?.trim() || undefined;
}
/** A credential-scoped config value (e.g. KEENABLE_API_URL), or undefined. */
export function getSearchCredentialEnv(
provider: SearchAuthProvider,
name: string,
options: SearchAuthOptions = {},
): string | undefined {
const value = readSearchCredential(provider, options)?.env?.[name]?.trim();
return value || undefined;
}
+5 -2
View File
@@ -2,9 +2,12 @@
* 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.
* Keyless works; the `exa` api_key credential from auth.json is sent as the
* `x-api-key` header when present.
*/
import { getSearchApiKey } from "../auth.ts";
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";
@@ -41,7 +44,7 @@ function baseHeaders(): Record<string, string> {
"content-type": "application/json",
accept: "application/json, text/event-stream",
};
const key = process.env.EXA_API_KEY?.trim();
const key = getSearchApiKey("exa");
if (key) headers["x-api-key"] = key;
return headers;
}
+16 -9
View File
@@ -1,17 +1,19 @@
/**
* 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.
* Keyless by default (`/v1/*\/public`), authenticated when the `keenable`
* api_key credential exists in auth.json (`/v1/*`), HTTPS-only base URL,
* `X-Keenable-Title` attribution.
*/
import { isIP } from "node:net";
import { getSearchApiKey, getSearchCredentialEnv } from "../auth.ts";
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;
@@ -45,9 +47,15 @@ export interface KeenableFetchResult {
[key: string]: unknown;
}
/** Resolve the API base URL from `KEENABLE_API_URL`, enforcing HTTPS (http only for loopback). */
/**
* Resolve the API base URL from `KEENABLE_API_URL` (process env first, then
* the keenable credential's `env` map), enforcing HTTPS (http only for loopback).
*/
function resolveBaseUrl(): string {
const base = (process.env[BASE_URL_ENV] || DEFAULT_BASE_URL).replace(/\/+$/u, "");
const base = (process.env[BASE_URL_ENV] || getSearchCredentialEnv("keenable", BASE_URL_ENV) || DEFAULT_BASE_URL).replace(
/\/+$/u,
"",
);
let parsed: URL;
try {
parsed = new URL(base);
@@ -63,10 +71,9 @@ function resolveBaseUrl(): string {
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. */
/** API key from auth.json, or undefined to use the keyless public tier. */
function resolveApiKey(): string | undefined {
const key = (process.env[API_KEY_ENV] ?? "").trim();
return key || undefined;
return getSearchApiKey("keenable");
}
function buildHeaders(apiKey: string | undefined): Record<string, string> {
@@ -129,7 +136,7 @@ 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.",
'Keenable keyless requests hit their rate limit. Add a "keenable" api_key credential to auth.json to raise the limits.',
);
}
let detail = "";
+7 -5
View File
@@ -1,8 +1,10 @@
/**
* Tavily REST client: POST https://api.tavily.com/search|extract with a Bearer
* key. Requires TAVILY_API_KEY — there is no keyless tier.
* key from auth.json (`tavily` api_key credential) — there is no keyless tier.
*/
import { getSearchApiKey } from "../auth.ts";
export class TavilyError extends Error {}
const TAVILY_BASE_URL = "https://api.tavily.com";
@@ -30,8 +32,8 @@ export interface TavilySearchResult {
[key: string]: unknown;
}
export function tavilyAvailable(env: Record<string, string | undefined> = process.env): boolean {
return Boolean(env.TAVILY_API_KEY?.trim());
export function tavilyAvailable(resolve: (provider: "tavily") => string | undefined = getSearchApiKey): boolean {
return Boolean(resolve("tavily"));
}
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
@@ -46,9 +48,9 @@ async function post(
signal: AbortSignal | undefined,
fetchImpl: FetchLike,
): Promise<Record<string, unknown>> {
const apiKey = process.env.TAVILY_API_KEY?.trim();
const apiKey = getSearchApiKey("tavily");
if (!apiKey) {
throw new TavilyError("Tavily is not configured: TAVILY_API_KEY is missing.");
throw new TavilyError('Tavily is not configured: no "tavily" api_key credential in auth.json.');
}
let response: Response;
try {
+6 -7
View File
@@ -9,7 +9,7 @@
import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { loadSearchConfigEnv } from "../extensions/search-config.ts";
import { getSearchApiKey, type SearchKeyResolver } from "./auth.ts";
import { exaFetchPages, exaSearch } from "./clients/exa.ts";
import { keenableFetch, keenableSearch } from "./clients/keenable.ts";
import { tavilyAvailable, tavilyExtract, tavilySearch } from "./clients/tavily.ts";
@@ -108,9 +108,10 @@ export async function dispatchSearch(
routeCache: Map<string, SearchProvider>,
signal?: AbortSignal,
runners: Record<SearchProvider, ProviderRunner> = createProviderRunners(),
resolve: SearchKeyResolver = getSearchApiKey,
): Promise<AgentToolResult> {
const decision = await routeSearch(params, ctx, routeCache, signal);
const order = [decision.provider, ...availableProviders().filter((p) => p !== decision.provider)];
const decision = await routeSearch(params, ctx, routeCache, signal, resolve);
const order = [decision.provider, ...availableProviders(resolve).filter((p) => p !== decision.provider)];
let lastError: unknown;
for (const provider of order) {
try {
@@ -161,6 +162,7 @@ export async function dispatchFetch(
params: WebFetchParams,
signal?: AbortSignal,
fetchers: Record<SearchProvider, FetchRunner> = createFetchRunners(),
resolve: SearchKeyResolver = getSearchApiKey,
): Promise<AgentToolResult> {
const urls = normalizeUrls(params.urls);
if (!urls.length) {
@@ -171,7 +173,7 @@ export async function dispatchFetch(
}
const maxCharacters = params.max_characters ?? DEFAULT_FETCH_MAX_CHARACTERS;
const order: SearchProvider[] = ["exa", "keenable"];
if (tavilyAvailable()) order.push("tavily");
if (tavilyAvailable(resolve)) order.push("tavily");
let lastError: unknown;
for (const provider of order) {
@@ -186,9 +188,6 @@ export async function dispatchFetch(
}
export default function webSearchExtension(pi: ExtensionAPI): void {
// Keys may already be loaded by extensions/search-config.ts; this is a
// harmless idempotent safety net for standalone loading.
loadSearchConfigEnv();
const routeCache = new Map<string, SearchProvider>();
pi.registerTool({
+1
View File
@@ -11,6 +11,7 @@
},
"files": [
"index.ts",
"auth.ts",
"router.ts",
"clients",
"README.md",
+7 -5
View File
@@ -5,6 +5,8 @@
* and failure fallbacks, never the routing itself.
*/
import { getSearchApiKey, type SearchKeyResolver } from "./auth.ts";
export type SearchProvider = "tavily" | "exa" | "keenable";
export interface SearchRequest {
@@ -43,10 +45,10 @@ const PROVIDER_RULES: Record<SearchProvider, string> = {
"content length.",
};
/** Tavily requires a key; Exa and Keenable both work keyless. */
export function availableProviders(env: Record<string, string | undefined> = process.env): SearchProvider[] {
/** Tavily requires a key in auth.json; Exa and Keenable both work keyless. */
export function availableProviders(resolve: SearchKeyResolver = getSearchApiKey): SearchProvider[] {
const providers: SearchProvider[] = [];
if (env.TAVILY_API_KEY?.trim()) providers.push("tavily");
if (resolve("tavily")) providers.push("tavily");
providers.push("exa", "keenable");
return providers;
}
@@ -111,9 +113,9 @@ export async function routeSearch(
ctx: RouterContext,
cache: Map<string, SearchProvider>,
signal?: AbortSignal,
env: Record<string, string | undefined> = process.env,
resolve: SearchKeyResolver = getSearchApiKey,
): Promise<RouteDecision> {
const candidates = availableProviders(env);
const candidates = availableProviders(resolve);
const fallback = PROVIDER_FALLBACK_ORDER.find((provider) => candidates.includes(provider)) ?? candidates[0];
if (!fallback || candidates.length <= 1) {
return { provider: fallback ?? "keenable", routedBy: "fallback" };
+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 () => {
+11 -10
View File
@@ -12,7 +12,8 @@ import {
} from "../router.ts";
const ALL: SearchProvider[] = ["tavily", "exa", "keenable"];
const ENV_ALL_KEYS = { TAVILY_API_KEY: "test-key" };
const ALL_KEYS = () => "test-key";
const NO_KEYS = () => undefined;
function ctxAnswering(answer: string, calls: { count: number } = { count: 0 }): RouterContext {
return {
@@ -27,8 +28,8 @@ function ctxAnswering(answer: string, calls: { count: number } = { count: 0 }):
}
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"]);
assert.deepEqual(availableProviders(NO_KEYS), ["exa", "keenable"]);
assert.deepEqual(availableProviders(ALL_KEYS), ["tavily", "exa", "keenable"]);
});
test("router prompt lists only candidate rules plus the query and caller hints", () => {
@@ -64,44 +65,44 @@ test("routeSearch honors the model pick and caches identical requests", async ()
const cache = new Map<string, SearchProvider>();
const request = { query: "official MCP spec" };
const first = await routeSearch(request, ctx, cache, undefined, ENV_ALL_KEYS);
const first = await routeSearch(request, ctx, cache, undefined, ALL_KEYS);
assert.deepEqual(first, { provider: "exa", routedBy: "model" });
const second = await routeSearch(request, ctx, cache, undefined, ENV_ALL_KEYS);
const second = await routeSearch(request, ctx, cache, undefined, 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);
const unparseable = await routeSearch({ query: "x" }, ctxAnswering("no idea"), cache, undefined, 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);
const failed = await routeSearch({ query: "x" }, throwing, cache, undefined, 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, {});
const decision = await routeSearch({ query: "x" }, ctxAnswering("tavily"), cache, undefined, NO_KEYS);
// "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);
const decision = await routeSearch({ query: "x" }, {}, cache, undefined, 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);
const decision = await routeSearch({ query: "fresh query" }, ctxAnswering("keenable"), cache, undefined, ALL_KEYS);
assert.equal(decision.provider, "keenable");
assert.equal(cache.size, ROUTE_CACHE_LIMIT);
assert.equal(cache.has("dummy-0"), false);