mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
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:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
ee1e6999d7
commit
0c9823ef5c
+11
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"files": [
|
||||
"index.ts",
|
||||
"auth.ts",
|
||||
"router.ts",
|
||||
"clients",
|
||||
"README.md",
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user