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,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 sia
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,35 @@
|
||||
# pi-web-search
|
||||
|
||||
Unified web search and fetch for Pi. The main model only ever sees two tools —
|
||||
`web_search` and `web_fetch` — provider selection happens inside the extension.
|
||||
|
||||
- `web_search(query, max_results?, site?, published_after?, published_before?, depth?)`
|
||||
routes each query to Tavily, Exa, or Keenable using the session's current main
|
||||
model (`ctx.model` + `ctx.modelRegistry.complete`), guided by per-provider
|
||||
rules. Results include `details.provider` / `details.routedBy` for debugging.
|
||||
- `web_fetch(urls, max_characters?)` always reads pages via Exa
|
||||
(`web_fetch_exa`), degrading silently to Keenable then Tavily.
|
||||
|
||||
## Providers (`clients/`)
|
||||
|
||||
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`.
|
||||
- `clients/keenable.ts` — `api.keenable.ai`, keyless `/public` endpoints by
|
||||
default, `KEENABLE_API_KEY` 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).
|
||||
|
||||
Keys are loaded from `~/.config/my-pi/search.env` by `extensions/search-config.ts`
|
||||
(written by `search_config.sh`); explicit environment variables win.
|
||||
|
||||
## Routing
|
||||
|
||||
`router.ts` asks the current model to answer with exactly one provider name,
|
||||
given a prompt containing the per-provider strengths and the query's requested
|
||||
filters. Decisions are cached per session (LRU, 32 entries). Failure paths are
|
||||
deterministic: an unroutable answer or a dead provider falls back across the
|
||||
remaining available providers — never a heuristic choice over the model.
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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";
|
||||
const PROTOCOL_VERSION = "2025-03-26";
|
||||
const SESSION_TIMEOUT_MS = 15_000;
|
||||
const CALL_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** Cap page text per result so advanced search responses stay bounded. */
|
||||
export const EXA_SEARCH_TEXT_MAX_CHARACTERS = 2000;
|
||||
export const EXA_FETCH_MAX_CHARACTERS = 3000;
|
||||
|
||||
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface ExaSearchParams {
|
||||
query: string;
|
||||
numResults?: number;
|
||||
includeDomains?: string[];
|
||||
startPublishedDate?: string;
|
||||
endPublishedDate?: string;
|
||||
textMaxCharacters?: number;
|
||||
}
|
||||
|
||||
let cachedSessionId: string | undefined;
|
||||
let pendingSession: Promise<string> | undefined;
|
||||
let nextRequestId = 1;
|
||||
|
||||
/** Clear the cached MCP session (used on expiry and by tests). */
|
||||
export function resetExaSession(): void {
|
||||
cachedSessionId = undefined;
|
||||
}
|
||||
|
||||
function baseHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json, text/event-stream",
|
||||
};
|
||||
const key = process.env.EXA_API_KEY?.trim();
|
||||
if (key) headers["x-api-key"] = key;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the JSON-RPC message for `id` from a raw JSON body or an SSE stream
|
||||
* (one or more `data: {...}` lines).
|
||||
*/
|
||||
export function parseRpcPayload(bodyText: string, id: number): Record<string, unknown> {
|
||||
const candidates: string[] = [];
|
||||
const trimmed = bodyText.trim();
|
||||
if (trimmed.startsWith("{")) candidates.push(trimmed);
|
||||
for (const line of trimmed.split(/\r?\n/u)) {
|
||||
if (line.startsWith("data:")) candidates.push(line.slice(5).trim());
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate) as Record<string, unknown>;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && parsed.id === id) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — try the next candidate line.
|
||||
}
|
||||
}
|
||||
throw new ExaError("Exa endpoint returned an unreadable response.");
|
||||
}
|
||||
|
||||
async function post(
|
||||
body: Record<string, unknown>,
|
||||
sessionId: string | undefined,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal | undefined,
|
||||
fetchImpl: FetchLike,
|
||||
): Promise<{ status: number; sessionId?: string; text: string }> {
|
||||
const headers = baseHeaders();
|
||||
if (sessionId) headers["mcp-session-id"] = sessionId;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(EXA_MCP_URL, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: withTimeout(signal, timeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ExaError(`Could not reach the Exa endpoint: ${(error as Error).message}`);
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
sessionId: response.headers.get("mcp-session-id") ?? undefined,
|
||||
text: await response.text(),
|
||||
};
|
||||
}
|
||||
|
||||
async function initialize(signal: AbortSignal | undefined, fetchImpl: FetchLike): Promise<string> {
|
||||
const id = nextRequestId++;
|
||||
const res = await post(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: { name: "my-pi-web-search", version: "0.1.0" },
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
SESSION_TIMEOUT_MS,
|
||||
signal,
|
||||
fetchImpl,
|
||||
);
|
||||
if (res.status !== 200) throw new ExaError(`Exa initialize failed (HTTP ${res.status}).`);
|
||||
const sessionId = res.sessionId;
|
||||
if (!sessionId) throw new ExaError("Exa endpoint did not return an MCP session id.");
|
||||
const payload = parseRpcPayload(res.text, id);
|
||||
if (payload.error) throw new ExaError(`Exa initialize error: ${(payload.error as { message?: string }).message ?? "unknown"}`);
|
||||
|
||||
const notice = await post(
|
||||
{ jsonrpc: "2.0", method: "notifications/initialized" },
|
||||
sessionId,
|
||||
SESSION_TIMEOUT_MS,
|
||||
signal,
|
||||
fetchImpl,
|
||||
);
|
||||
if (notice.status >= 400) {
|
||||
throw new ExaError(`Exa initialized notification failed (HTTP ${notice.status}).`);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function sessionId(signal: AbortSignal | undefined, fetchImpl: FetchLike): Promise<string> {
|
||||
if (cachedSessionId) return Promise.resolve(cachedSessionId);
|
||||
pendingSession ??= initialize(signal, fetchImpl)
|
||||
.then((id) => {
|
||||
cachedSessionId = id;
|
||||
return id;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingSession = undefined;
|
||||
});
|
||||
return pendingSession;
|
||||
}
|
||||
|
||||
function contentText(result: Record<string, unknown>): string {
|
||||
const content = Array.isArray(result.content) ? (result.content as Record<string, unknown>[]) : [];
|
||||
return content
|
||||
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
||||
.map((part) => part.text as string)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function callTool(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
signal: AbortSignal | undefined,
|
||||
fetchImpl: FetchLike,
|
||||
allowRetry: boolean,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const sid = await sessionId(signal, fetchImpl);
|
||||
const id = nextRequestId++;
|
||||
const res = await post(
|
||||
{ jsonrpc: "2.0", id, method: "tools/call", params: { name: toolName, arguments: args } },
|
||||
sid,
|
||||
CALL_TIMEOUT_MS,
|
||||
signal,
|
||||
fetchImpl,
|
||||
);
|
||||
if (res.status === 404 && allowRetry) {
|
||||
// Session expired server-side: drop it, re-initialize, retry once.
|
||||
cachedSessionId = undefined;
|
||||
return callTool(toolName, args, signal, fetchImpl, false);
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new ExaError(`Exa ${toolName} failed (HTTP ${res.status}).`);
|
||||
}
|
||||
const payload = parseRpcPayload(res.text, id);
|
||||
if (payload.error) {
|
||||
const message = (payload.error as { message?: string }).message ?? "unknown";
|
||||
if (allowRetry && /session/iu.test(message)) {
|
||||
cachedSessionId = undefined;
|
||||
return callTool(toolName, args, signal, fetchImpl, false);
|
||||
}
|
||||
throw new ExaError(`Exa ${toolName} error: ${message}`);
|
||||
}
|
||||
const result = (payload.result ?? {}) as Record<string, unknown>;
|
||||
if (result.isError) {
|
||||
throw new ExaError(`Exa ${toolName} failed: ${contentText(result) || "unknown error"}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** tools/call web_search_advanced_exa — precise search with bounded text per result. */
|
||||
export async function exaSearch(
|
||||
params: ExaSearchParams,
|
||||
signal?: AbortSignal,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<string> {
|
||||
const result = await callTool(
|
||||
"web_search_advanced_exa",
|
||||
{
|
||||
query: params.query,
|
||||
type: "auto",
|
||||
numResults: params.numResults ?? 8,
|
||||
textMaxCharacters: params.textMaxCharacters ?? EXA_SEARCH_TEXT_MAX_CHARACTERS,
|
||||
...(params.includeDomains?.length ? { includeDomains: params.includeDomains } : {}),
|
||||
...(params.startPublishedDate ? { startPublishedDate: params.startPublishedDate } : {}),
|
||||
...(params.endPublishedDate ? { endPublishedDate: params.endPublishedDate } : {}),
|
||||
},
|
||||
signal,
|
||||
fetchImpl,
|
||||
true,
|
||||
);
|
||||
return contentText(result) || "Exa returned no content.";
|
||||
}
|
||||
|
||||
/** tools/call web_fetch_exa — full page content as clean markdown for a batch of URLs. */
|
||||
export async function exaFetchPages(
|
||||
urls: string[],
|
||||
maxCharacters?: number,
|
||||
signal?: AbortSignal,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<string> {
|
||||
const result = await callTool(
|
||||
"web_fetch_exa",
|
||||
{ urls, maxCharacters: maxCharacters ?? EXA_FETCH_MAX_CHARACTERS },
|
||||
signal,
|
||||
fetchImpl,
|
||||
true,
|
||||
);
|
||||
return contentText(result) || "Exa returned no content.";
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { isIP } from "node:net";
|
||||
|
||||
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;
|
||||
|
||||
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface KeenableSearchParams {
|
||||
query: string;
|
||||
mode?: "pro";
|
||||
site?: string;
|
||||
published_after?: string;
|
||||
published_before?: string;
|
||||
}
|
||||
|
||||
export interface KeenableSearchResult {
|
||||
title?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
published_at?: string;
|
||||
acquired_at?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface KeenableFetchResult {
|
||||
url?: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
published_at?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Resolve the API base URL from `KEENABLE_API_URL`, enforcing HTTPS (http only for loopback). */
|
||||
function resolveBaseUrl(): string {
|
||||
const base = (process.env[BASE_URL_ENV] || DEFAULT_BASE_URL).replace(/\/+$/u, "");
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(base);
|
||||
} catch {
|
||||
throw new KeenableError(`${BASE_URL_ENV} must be a valid URL with a host, got ${base}`);
|
||||
}
|
||||
if (parsed.hostname) {
|
||||
if (parsed.protocol === "https:") return base;
|
||||
if (parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname)) {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
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. */
|
||||
function resolveApiKey(): string | undefined {
|
||||
const key = (process.env[API_KEY_ENV] ?? "").trim();
|
||||
return key || undefined;
|
||||
}
|
||||
|
||||
function buildHeaders(apiKey: string | undefined): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
"X-Keenable-Title": ATTRIBUTION_TITLE,
|
||||
};
|
||||
if (apiKey) headers["X-API-Key"] = apiKey;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
function isPrivateIp(host: string): boolean {
|
||||
const v = isIP(host);
|
||||
if (v === 4) {
|
||||
const p = host.split(".").map(Number);
|
||||
return (
|
||||
p[0] === 10 ||
|
||||
p[0] === 127 ||
|
||||
p[0] === 0 ||
|
||||
(p[0] === 169 && p[1] === 254) ||
|
||||
(p[0] === 172 && p[1] >= 16 && p[1] <= 31) ||
|
||||
(p[0] === 192 && p[1] === 168) ||
|
||||
(p[0] === 100 && p[1] >= 64 && p[1] <= 127) ||
|
||||
(p[0] === 198 && (p[1] === 18 || p[1] === 19)) ||
|
||||
p[0] >= 224
|
||||
);
|
||||
}
|
||||
if (v === 6) {
|
||||
const h = host.toLowerCase();
|
||||
return h === "::1" || h === "::" || h.startsWith("fe80") || h.startsWith("fc") || h.startsWith("fd");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Refuse private/internal fetch targets before sending (SSRF). */
|
||||
function assertPublicFetchTarget(rawUrl: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new KeenableError(`Not a valid URL: ${rawUrl}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new KeenableError(`Refusing to fetch a non-http(s) URL: ${rawUrl}`);
|
||||
}
|
||||
const host = (parsed.hostname || "").toLowerCase();
|
||||
if (!host) throw new KeenableError(`Refusing to fetch a URL with no host: ${rawUrl}`);
|
||||
if (host === "localhost" || host === "metadata.google.internal" || isPrivateIp(host)) {
|
||||
throw new KeenableError(`Refusing to fetch a private/internal host: ${host}`);
|
||||
}
|
||||
}
|
||||
|
||||
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.",
|
||||
);
|
||||
}
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
detail = String(body.message ?? body.error ?? body.detail ?? "");
|
||||
} catch {
|
||||
detail = (await response.text().catch(() => "")).trim();
|
||||
}
|
||||
const label =
|
||||
{
|
||||
401: "Keenable authentication failed (401)",
|
||||
402: "Keenable: insufficient credits (402)",
|
||||
429: "Keenable rate limit exceeded (429)",
|
||||
}[response.status] ?? `Keenable API error (${response.status})`;
|
||||
throw new KeenableError(detail ? `${label}: ${detail}` : label);
|
||||
}
|
||||
|
||||
async function readJsonObject(response: Response): Promise<Record<string, unknown>> {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
throw new KeenableError("Keenable API returned a non-JSON response.");
|
||||
}
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
throw new KeenableError("Unexpected (non-object) response from the Keenable API.");
|
||||
}
|
||||
return data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** POST /v1/search (keyed) or /v1/search/public (keyless). */
|
||||
export async function keenableSearch(
|
||||
params: KeenableSearchParams,
|
||||
signal: AbortSignal | undefined,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<KeenableSearchResult[]> {
|
||||
const apiKey = resolveApiKey();
|
||||
const path = apiKey ? "/v1/search" : "/v1/search/public";
|
||||
const payload: Record<string, unknown> = { query: params.query, mode: params.mode ?? "pro" };
|
||||
for (const field of ["site", "published_after", "published_before"] as const) {
|
||||
const value = params[field];
|
||||
if (value) payload[field] = value;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(`${resolveBaseUrl()}${path}`, {
|
||||
method: "POST",
|
||||
headers: { ...buildHeaders(apiKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: withTimeout(signal, DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new KeenableError(`Could not reach the Keenable API: ${(err as Error).message}`);
|
||||
}
|
||||
await raiseForStatus(response, Boolean(apiKey));
|
||||
const data = await readJsonObject(response);
|
||||
const results = data.results;
|
||||
if (!Array.isArray(results)) {
|
||||
throw new KeenableError("Unexpected response from the Keenable search API (no results array).");
|
||||
}
|
||||
return results as KeenableSearchResult[];
|
||||
}
|
||||
|
||||
/** GET /v1/fetch?url= (keyed) or /v1/fetch/public?url= (keyless). */
|
||||
export async function keenableFetch(
|
||||
url: string,
|
||||
signal: AbortSignal | undefined,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<KeenableFetchResult> {
|
||||
assertPublicFetchTarget(url);
|
||||
const apiKey = resolveApiKey();
|
||||
const path = apiKey ? "/v1/fetch" : "/v1/fetch/public";
|
||||
const endpoint = new URL(`${resolveBaseUrl()}${path}`);
|
||||
endpoint.searchParams.set("url", url);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(endpoint, {
|
||||
method: "GET",
|
||||
headers: buildHeaders(apiKey),
|
||||
signal: withTimeout(signal, DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new KeenableError(`Could not reach the Keenable API: ${(err as Error).message}`);
|
||||
}
|
||||
await raiseForStatus(response, Boolean(apiKey));
|
||||
return (await readJsonObject(response)) as KeenableFetchResult;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Tavily REST client: POST https://api.tavily.com/search|extract with a Bearer
|
||||
* key. Requires TAVILY_API_KEY — there is no keyless tier.
|
||||
*/
|
||||
|
||||
export class TavilyError extends Error {}
|
||||
|
||||
const TAVILY_BASE_URL = "https://api.tavily.com";
|
||||
const SEARCH_TIMEOUT_MS = 45_000;
|
||||
const EXTRACT_TIMEOUT_MS = 60_000;
|
||||
const CLIENT_SOURCE = "my-pi-web-search";
|
||||
|
||||
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface TavilySearchParams {
|
||||
query: string;
|
||||
max_results?: number;
|
||||
search_depth?: "basic" | "advanced";
|
||||
topic?: "general" | "news" | "finance";
|
||||
include_domains?: string[];
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
export interface TavilySearchResult {
|
||||
title?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
published_date?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function tavilyAvailable(env: Record<string, string | undefined> = process.env): boolean {
|
||||
return Boolean(env.TAVILY_API_KEY?.trim());
|
||||
}
|
||||
|
||||
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
async function post(
|
||||
endpoint: string,
|
||||
body: Record<string, unknown>,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal | undefined,
|
||||
fetchImpl: FetchLike,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const apiKey = process.env.TAVILY_API_KEY?.trim();
|
||||
if (!apiKey) {
|
||||
throw new TavilyError("Tavily is not configured: TAVILY_API_KEY is missing.");
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(`${TAVILY_BASE_URL}/${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"X-Client-Source": CLIENT_SOURCE,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: withTimeout(signal, timeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TavilyError(`Could not reach the Tavily API: ${(error as Error).message}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
detail = String(data.error ?? data.message ?? "");
|
||||
} catch {
|
||||
detail = "";
|
||||
}
|
||||
throw new TavilyError(`Tavily API error (${response.status})${detail ? `: ${detail}` : ""}`);
|
||||
}
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** POST /search — broad discovery; returns the answer plus a compact result list. */
|
||||
export async function tavilySearch(
|
||||
params: TavilySearchParams,
|
||||
signal?: AbortSignal,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<{ answer?: string; results: TavilySearchResult[] }> {
|
||||
const payload: Record<string, unknown> = {
|
||||
query: params.query,
|
||||
max_results: params.max_results,
|
||||
search_depth: params.search_depth,
|
||||
topic: params.topic,
|
||||
include_domains: params.include_domains,
|
||||
start_date: params.start_date,
|
||||
end_date: params.end_date,
|
||||
};
|
||||
for (const key of Object.keys(payload)) {
|
||||
if (payload[key] === undefined) delete payload[key];
|
||||
}
|
||||
const data = await post("search", payload, SEARCH_TIMEOUT_MS, signal, fetchImpl);
|
||||
const rawResults = Array.isArray(data.results) ? (data.results as Record<string, unknown>[]) : [];
|
||||
return {
|
||||
answer: typeof data.answer === "string" ? data.answer : undefined,
|
||||
results: rawResults.map((result) => ({
|
||||
title: result.title,
|
||||
url: result.url,
|
||||
content: result.content,
|
||||
published_date: result.publishedDate ?? result.published_date,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** POST /extract — full page content as markdown for a batch of URLs. */
|
||||
export async function tavilyExtract(
|
||||
urls: string[],
|
||||
signal?: AbortSignal,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<{ results: Array<{ url?: string; content?: string }>; failed: string[] }> {
|
||||
const data = await post("extract", { urls, format: "markdown" }, EXTRACT_TIMEOUT_MS, signal, fetchImpl);
|
||||
const rawResults = Array.isArray(data.results) ? (data.results as Record<string, unknown>[]) : [];
|
||||
const rawFailed = Array.isArray(data.failed_results) ? (data.failed_results as Record<string, unknown>[]) : [];
|
||||
return {
|
||||
results: rawResults.map((result) => ({ url: result.url as string | undefined, content: result.raw_content as string | undefined })),
|
||||
failed: rawFailed.map((result) => String(result.url ?? "unknown")),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* pi-web-search: one unified web_search + web_fetch surface.
|
||||
*
|
||||
* The main model never sees provider names — web_search routes internally via
|
||||
* the session's current model (see router.ts), and web_fetch always reads
|
||||
* pages through Exa with graceful degradation. Provider clients are plain
|
||||
* fetch calls (clients/*) — no MCP adapter, no upstream extension wrappers.
|
||||
*/
|
||||
|
||||
import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
import { loadSearchConfigEnv } from "../extensions/search-config.ts";
|
||||
import { exaFetchPages, exaSearch } from "./clients/exa.ts";
|
||||
import { keenableFetch, keenableSearch } from "./clients/keenable.ts";
|
||||
import { tavilyAvailable, tavilyExtract, tavilySearch } from "./clients/tavily.ts";
|
||||
import {
|
||||
availableProviders,
|
||||
routeSearch,
|
||||
type SearchProvider,
|
||||
type RouterContext,
|
||||
} from "./router.ts";
|
||||
|
||||
const DEFAULT_MAX_RESULTS = 8;
|
||||
const MAX_FETCH_URLS = 10;
|
||||
const DEFAULT_FETCH_MAX_CHARACTERS = 3000;
|
||||
|
||||
export interface WebSearchParams {
|
||||
query: string;
|
||||
max_results?: number;
|
||||
site?: string;
|
||||
published_after?: string;
|
||||
published_before?: string;
|
||||
depth?: "basic" | "advanced";
|
||||
}
|
||||
|
||||
export interface WebFetchParams {
|
||||
urls: string | string[];
|
||||
max_characters?: number;
|
||||
}
|
||||
|
||||
export type ProviderRunner = (params: WebSearchParams, signal?: AbortSignal) => Promise<string>;
|
||||
export type FetchRunner = (urls: string[], maxCharacters: number, signal?: AbortSignal) => Promise<string>;
|
||||
|
||||
function formatSearchPayload(payload: unknown): string {
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
export function toTavilyParams(params: WebSearchParams) {
|
||||
return {
|
||||
query: params.query,
|
||||
max_results: params.max_results ?? DEFAULT_MAX_RESULTS,
|
||||
search_depth: (params.depth ?? "basic") as "basic" | "advanced",
|
||||
include_domains: params.site ? [params.site] : undefined,
|
||||
start_date: params.published_after,
|
||||
end_date: params.published_before,
|
||||
};
|
||||
}
|
||||
|
||||
export function toExaParams(params: WebSearchParams) {
|
||||
return {
|
||||
query: params.query,
|
||||
numResults: params.max_results ?? DEFAULT_MAX_RESULTS,
|
||||
includeDomains: params.site ? [params.site] : undefined,
|
||||
startPublishedDate: params.published_after,
|
||||
endPublishedDate: params.published_before,
|
||||
};
|
||||
}
|
||||
|
||||
export function toKeenableParams(params: WebSearchParams) {
|
||||
return {
|
||||
query: params.query,
|
||||
site: params.site,
|
||||
published_after: params.published_after,
|
||||
published_before: params.published_before,
|
||||
};
|
||||
}
|
||||
|
||||
/** Default per-provider parameter mapping from the unified schema. */
|
||||
export function createProviderRunners(
|
||||
overrides: Partial<Record<SearchProvider, ProviderRunner>> = {},
|
||||
): Record<SearchProvider, ProviderRunner> {
|
||||
return {
|
||||
tavily:
|
||||
overrides.tavily ??
|
||||
(async (params, signal) => formatSearchPayload(await tavilySearch(toTavilyParams(params), signal))),
|
||||
exa: overrides.exa ?? ((params, signal) => exaSearch(toExaParams(params), signal)),
|
||||
keenable:
|
||||
overrides.keenable ??
|
||||
(async (params, signal) => formatSearchPayload(await keenableSearch(toKeenableParams(params), signal))),
|
||||
};
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function toResult(text: string, details: Record<string, unknown>): AgentToolResult {
|
||||
return { content: [{ type: "text", text }], details };
|
||||
}
|
||||
|
||||
/**
|
||||
* Route once via the main model, then try the picked provider first and fall
|
||||
* back across the remaining available providers on transport errors.
|
||||
*/
|
||||
export async function dispatchSearch(
|
||||
params: WebSearchParams,
|
||||
ctx: RouterContext,
|
||||
routeCache: Map<string, SearchProvider>,
|
||||
signal?: AbortSignal,
|
||||
runners: Record<SearchProvider, ProviderRunner> = createProviderRunners(),
|
||||
): Promise<AgentToolResult> {
|
||||
const decision = await routeSearch(params, ctx, routeCache, signal);
|
||||
const order = [decision.provider, ...availableProviders().filter((p) => p !== decision.provider)];
|
||||
let lastError: unknown;
|
||||
for (const provider of order) {
|
||||
try {
|
||||
const text = await runners[provider](params, signal);
|
||||
return toResult(text, {
|
||||
provider,
|
||||
routedBy: provider === decision.provider ? decision.routedBy : "provider-fallback",
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
return toResult(`web_search failed on every available provider: ${errorText(lastError)}`, { error: true });
|
||||
}
|
||||
|
||||
export function normalizeUrls(urls: string | string[]): string[] {
|
||||
const list = (Array.isArray(urls) ? urls : [urls]).map((url) => url.trim()).filter(Boolean);
|
||||
return [...new Set(list)];
|
||||
}
|
||||
|
||||
/** web_fetch always prefers Exa; Keenable and Tavily are silent degradation paths. */
|
||||
export function createFetchRunners(
|
||||
overrides: Partial<Record<SearchProvider, FetchRunner>> = {},
|
||||
): Record<SearchProvider, FetchRunner> {
|
||||
return {
|
||||
exa: overrides.exa ?? ((urls, maxCharacters, signal) => exaFetchPages(urls, maxCharacters, signal)),
|
||||
keenable:
|
||||
overrides.keenable ??
|
||||
(async (urls, _maxCharacters, signal) => {
|
||||
const pages = await Promise.all(urls.map((url) => keenableFetch(url, signal)));
|
||||
return pages
|
||||
.map((page) => `# ${page.title ?? page.url ?? "page"}\n${page.content ?? "(no content)"}`)
|
||||
.join("\n\n---\n\n");
|
||||
}),
|
||||
tavily:
|
||||
overrides.tavily ??
|
||||
(async (urls, _maxCharacters, signal) => {
|
||||
const { results, failed } = await tavilyExtract(urls, signal);
|
||||
const body = results
|
||||
.map((page) => `# ${page.url ?? "page"}\n${page.content ?? "(no content)"}`)
|
||||
.join("\n\n---\n\n");
|
||||
return failed.length ? `${body}\n\nFailed URLs: ${failed.join(", ")}` : body;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function dispatchFetch(
|
||||
params: WebFetchParams,
|
||||
signal?: AbortSignal,
|
||||
fetchers: Record<SearchProvider, FetchRunner> = createFetchRunners(),
|
||||
): Promise<AgentToolResult> {
|
||||
const urls = normalizeUrls(params.urls);
|
||||
if (!urls.length) {
|
||||
return toResult("web_fetch needs at least one http(s) URL.", { error: true });
|
||||
}
|
||||
if (urls.length > MAX_FETCH_URLS) {
|
||||
return toResult(`web_fetch accepts at most ${MAX_FETCH_URLS} URLs per call; received ${urls.length}.`, { error: true });
|
||||
}
|
||||
const maxCharacters = params.max_characters ?? DEFAULT_FETCH_MAX_CHARACTERS;
|
||||
const order: SearchProvider[] = ["exa", "keenable"];
|
||||
if (tavilyAvailable()) order.push("tavily");
|
||||
|
||||
let lastError: unknown;
|
||||
for (const provider of order) {
|
||||
try {
|
||||
const text = await fetchers[provider](urls, maxCharacters, signal);
|
||||
return toResult(text, { provider });
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
return toResult(`web_fetch failed on every available provider: ${errorText(lastError)}`, { error: true });
|
||||
}
|
||||
|
||||
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({
|
||||
name: "web_search",
|
||||
label: "Web Search",
|
||||
description:
|
||||
"Search the web for current or citable information and return a compact list of candidate " +
|
||||
"pages (title, url, summary). Provider selection is automatic. Follow up with web_fetch to " +
|
||||
"read the most relevant pages.",
|
||||
promptSnippet: "Search the web for current or citable information.",
|
||||
promptGuidelines: [
|
||||
"Use web_search when the task needs current, real-world, or citable web information.",
|
||||
"Keep queries focused; split multi-topic requests into separate web_search calls.",
|
||||
"Use site and published_* filters only when they materially narrow the results.",
|
||||
"After web_search, read the most relevant pages with web_fetch before relying on a claim.",
|
||||
],
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "The search query — a natural-language description of the ideal page, not just keywords.",
|
||||
},
|
||||
max_results: {
|
||||
type: "number",
|
||||
description: `Maximum number of results to return (default ${DEFAULT_MAX_RESULTS}).`,
|
||||
},
|
||||
site: {
|
||||
type: "string",
|
||||
description: "Restrict results to a single domain, e.g. 'arxiv.org'.",
|
||||
},
|
||||
published_after: {
|
||||
type: "string",
|
||||
description: "Only pages published on/after this date (YYYY-MM-DD).",
|
||||
},
|
||||
published_before: {
|
||||
type: "string",
|
||||
description: "Only pages published on/before this date (YYYY-MM-DD).",
|
||||
},
|
||||
depth: {
|
||||
type: "string",
|
||||
enum: ["basic", "advanced"],
|
||||
description: "'basic' (default) balanced search; 'advanced' for higher precision at higher cost.",
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||
return dispatchSearch(params as WebSearchParams, ctx as unknown as RouterContext, routeCache, signal);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "web_fetch",
|
||||
label: "Web Fetch",
|
||||
description:
|
||||
"Fetch the full content of one or more http(s) URLs as clean markdown. Use after web_search " +
|
||||
"to read selected pages, or directly when the user provides a page to read.",
|
||||
promptSnippet: "Read the full content of known URLs.",
|
||||
promptGuidelines: [
|
||||
"Use web_fetch to read the full content of a URL found via web_search or provided by the user.",
|
||||
"Batch multiple URLs into a single web_fetch call when possible.",
|
||||
],
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
urls: {
|
||||
anyOf: [
|
||||
{ type: "string", description: "A single http(s) URL to read." },
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
maxItems: MAX_FETCH_URLS,
|
||||
description: `A list of http(s) URLs to read (max ${MAX_FETCH_URLS}).`,
|
||||
},
|
||||
],
|
||||
},
|
||||
max_characters: {
|
||||
type: "number",
|
||||
description: `Maximum characters to extract per page (default ${DEFAULT_FETCH_MAX_CHARACTERS}).`,
|
||||
},
|
||||
},
|
||||
required: ["urls"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_toolCallId, params, signal) {
|
||||
return dispatchFetch(params as WebFetchParams, signal);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "pi-web-search",
|
||||
"version": "0.1.0",
|
||||
"description": "Unified web search and fetch tools with main-model provider routing (Tavily, Exa, Keenable).",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"main": "./index.ts",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"files": [
|
||||
"index.ts",
|
||||
"router.ts",
|
||||
"clients",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
"typebox": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@earendil-works/pi-coding-agent": {
|
||||
"optional": true
|
||||
},
|
||||
"typebox": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
import webSearchExtension, {
|
||||
createFetchRunners,
|
||||
createProviderRunners,
|
||||
dispatchFetch,
|
||||
dispatchSearch,
|
||||
normalizeUrls,
|
||||
toExaParams,
|
||||
toKeenableParams,
|
||||
toTavilyParams,
|
||||
type WebSearchParams,
|
||||
} from "../index.ts";
|
||||
|
||||
const PARAMS: WebSearchParams = { query: "release notes" };
|
||||
|
||||
function ctxAnswering(answer: string, calls: { count: number } = { count: 0 }) {
|
||||
return {
|
||||
model: { provider: "test", id: "main" },
|
||||
modelRegistry: {
|
||||
complete: async () => {
|
||||
calls.count += 1;
|
||||
return { content: [{ type: "text", text: answer }] };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 }>();
|
||||
const pi = {
|
||||
registerTool(definition: { name: string }) {
|
||||
tools.set(definition.name, definition as never);
|
||||
},
|
||||
on() {},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
webSearchExtension(pi);
|
||||
assert.deepEqual([...tools.keys()].sort(), ["web_fetch", "web_search"]);
|
||||
const schemaText = JSON.stringify([...tools.values()].map((t) => t.parameters));
|
||||
assert.doesNotMatch(schemaText, /tavily|exa|keenable|"provider"/iu);
|
||||
});
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizeUrls accepts a string or array, trims, and deduplicates", () => {
|
||||
assert.deepEqual(normalizeUrls(" https://a.example "), ["https://a.example"]);
|
||||
assert.deepEqual(normalizeUrls(["https://a", " https://a ", "https://b"]), ["https://a", "https://b"]);
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatchFetch validates input and caps the URL count", async () => {
|
||||
const empty = await dispatchFetch({ urls: [] }, undefined);
|
||||
assert.equal((empty.details as Record<string, unknown>).error, true);
|
||||
|
||||
const tooMany = await dispatchFetch({ urls: Array.from({ length: 11 }, (_, i) => `https://e${i}.example`) }, undefined);
|
||||
assert.equal((tooMany.details as Record<string, unknown>).error, true);
|
||||
assert.match(tooMany.content[0]!.type === "text" ? tooMany.content[0]!.text : "", /at most 10/);
|
||||
});
|
||||
|
||||
test("unified params map onto each provider's native arguments", () => {
|
||||
const params: WebSearchParams = {
|
||||
query: "q",
|
||||
max_results: 4,
|
||||
site: "a.dev",
|
||||
published_after: "2025-01-01",
|
||||
published_before: "2025-06-01",
|
||||
depth: "advanced",
|
||||
};
|
||||
assert.deepEqual(toTavilyParams(params), {
|
||||
query: "q",
|
||||
max_results: 4,
|
||||
search_depth: "advanced",
|
||||
include_domains: ["a.dev"],
|
||||
start_date: "2025-01-01",
|
||||
end_date: "2025-06-01",
|
||||
});
|
||||
assert.deepEqual(toExaParams(params), {
|
||||
query: "q",
|
||||
numResults: 4,
|
||||
includeDomains: ["a.dev"],
|
||||
startPublishedDate: "2025-01-01",
|
||||
endPublishedDate: "2025-06-01",
|
||||
});
|
||||
assert.deepEqual(toKeenableParams(params), {
|
||||
query: "q",
|
||||
site: "a.dev",
|
||||
published_after: "2025-01-01",
|
||||
published_before: "2025-06-01",
|
||||
});
|
||||
|
||||
const defaults = toTavilyParams({ query: "q" });
|
||||
assert.equal(defaults.max_results, 8);
|
||||
assert.equal(defaults.search_depth, "basic");
|
||||
assert.equal(defaults.include_domains, undefined);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { ExaError, exaFetchPages, exaSearch, parseRpcPayload, resetExaSession } from "../clients/exa.ts";
|
||||
|
||||
function sse(payload: Record<string, unknown>): string {
|
||||
return `event: message\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
interface RecordedCall {
|
||||
method?: string;
|
||||
params?: Record<string, unknown>;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fetch stub that speaks the Exa endpoint protocol: initialize answers with
|
||||
* the scripted session id and the request's own id, notifications get a bare
|
||||
* 202, and tools/call delegates to the supplied handler (default 404).
|
||||
*/
|
||||
function exaStub(options: {
|
||||
sessionIds?: string[];
|
||||
onToolCall?: (args: Record<string, unknown>, id: number, session: string | undefined) => Response;
|
||||
} = {}) {
|
||||
const calls: RecordedCall[] = [];
|
||||
let sessionIndex = 0;
|
||||
const fetchImpl = async (_url: string | URL, init?: RequestInit): Promise<Response> => {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
const body = JSON.parse(String(init?.body)) as { id?: number; method?: string; params?: Record<string, unknown> };
|
||||
calls.push({ method: body.method, params: body.params, headers });
|
||||
if (body.method === "initialize") {
|
||||
const sid = options.sessionIds?.[Math.min(sessionIndex++, (options.sessionIds?.length ?? 1) - 1)] ?? `sid-${sessionIndex}`;
|
||||
return new Response(
|
||||
sse({ jsonrpc: "2.0", id: body.id, result: { protocolVersion: "2025-03-26", capabilities: {}, serverInfo: { name: "exa" } } }),
|
||||
{ status: 200, headers: { "mcp-session-id": sid, "content-type": "text/event-stream" } },
|
||||
);
|
||||
}
|
||||
if (body.method === "notifications/initialized") return new Response("", { status: 202 });
|
||||
if (body.method === "tools/call") {
|
||||
return options.onToolCall?.(body.params ?? {}, body.id ?? 0, headers["mcp-session-id"]) ?? new Response("Not Found", { status: 404 });
|
||||
}
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
function okToolResult(id: number, text: string): Response {
|
||||
return new Response(
|
||||
sse({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text }] } }),
|
||||
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
||||
);
|
||||
}
|
||||
|
||||
test("parseRpcPayload reads JSON bodies and SSE data lines, matching the request id", () => {
|
||||
const direct = JSON.stringify({ jsonrpc: "2.0", id: 7, result: { ok: true } });
|
||||
assert.deepEqual(parseRpcPayload(direct, 7).result, { ok: true });
|
||||
|
||||
const body =
|
||||
`event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"a":1}}\n\n` +
|
||||
`event: message\ndata: {"jsonrpc":"2.0","id":2,"result":{"b":2}}\n\n`;
|
||||
assert.deepEqual(parseRpcPayload(body, 2).result, { b: 2 });
|
||||
|
||||
assert.throws(() => parseRpcPayload("not json at all", 1), ExaError);
|
||||
assert.throws(() => parseRpcPayload(sse({ jsonrpc: "2.0", id: 99, result: {} }), 1), ExaError);
|
||||
});
|
||||
|
||||
test("exaSearch initializes a session, notifies, then posts tools/call with the session id", async () => {
|
||||
resetExaSession();
|
||||
const { fetchImpl, calls } = exaStub({
|
||||
sessionIds: ["sid-A"],
|
||||
onToolCall: (_args, id) => okToolResult(id, "search results"),
|
||||
});
|
||||
|
||||
const text = await exaSearch({ query: "docs", numResults: 3, includeDomains: ["python.org"] }, undefined, fetchImpl);
|
||||
assert.equal(text, "search results");
|
||||
assert.deepEqual(calls.map((c) => c.method), ["initialize", "notifications/initialized", "tools/call"]);
|
||||
|
||||
const call = calls[2]!;
|
||||
assert.equal(call.headers["mcp-session-id"], "sid-A");
|
||||
assert.equal((call.params as { name: string }).name, "web_search_advanced_exa");
|
||||
const args = (call.params as { arguments: Record<string, unknown> }).arguments;
|
||||
assert.equal(args.numResults, 3);
|
||||
assert.equal(args.type, "auto");
|
||||
assert.deepEqual(args.includeDomains, ["python.org"]);
|
||||
assert.ok((args.textMaxCharacters as number) > 0);
|
||||
});
|
||||
|
||||
test("exaSearch reuses the cached session across calls", async () => {
|
||||
resetExaSession();
|
||||
const { fetchImpl, calls } = exaStub({
|
||||
sessionIds: ["sid-1"],
|
||||
onToolCall: (args, id) => okToolResult(id, `result for ${(args as { name: string }).name}`),
|
||||
});
|
||||
|
||||
await exaSearch({ query: "a" }, undefined, fetchImpl);
|
||||
const text = await exaFetchPages(["https://example.com"], 500, undefined, fetchImpl);
|
||||
assert.match(text, /web_fetch_exa/);
|
||||
assert.equal(calls.length, 4);
|
||||
assert.equal((calls[3]!.params as { name: string }).name, "web_fetch_exa");
|
||||
assert.equal(calls[3]!.headers["mcp-session-id"], "sid-1");
|
||||
assert.equal((calls[3]!.params as { arguments: { maxCharacters: number } }).arguments.maxCharacters, 500);
|
||||
});
|
||||
|
||||
test("a 404 from tools/call drops the session, re-initializes, and retries once", async () => {
|
||||
resetExaSession();
|
||||
let toolCalls = 0;
|
||||
const { fetchImpl, calls } = exaStub({
|
||||
sessionIds: ["stale-sid", "fresh-sid"],
|
||||
onToolCall: (_args, id) => {
|
||||
toolCalls += 1;
|
||||
return toolCalls === 1 ? new Response("Not Found", { status: 404 }) : okToolResult(id, "after re-init");
|
||||
},
|
||||
});
|
||||
|
||||
const text = await exaSearch({ query: "retry me" }, undefined, fetchImpl);
|
||||
assert.equal(text, "after re-init");
|
||||
assert.deepEqual(calls.map((c) => c.method), [
|
||||
"initialize",
|
||||
"notifications/initialized",
|
||||
"tools/call",
|
||||
"initialize",
|
||||
"notifications/initialized",
|
||||
"tools/call",
|
||||
]);
|
||||
assert.equal(calls[5]!.headers["mcp-session-id"], "fresh-sid");
|
||||
});
|
||||
|
||||
test("isError tool results and HTTP failures raise ExaError", async () => {
|
||||
resetExaSession();
|
||||
const isError = exaStub({
|
||||
onToolCall: (_args, id) =>
|
||||
new Response(sse({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: "bad args" }] } }), { status: 200 }),
|
||||
});
|
||||
await assert.rejects(exaFetchPages(["https://example.com"], undefined, undefined, isError.fetchImpl), /bad args/);
|
||||
|
||||
resetExaSession();
|
||||
const httpFail = exaStub({ onToolCall: () => new Response("boom", { status: 503 }) });
|
||||
// Session init succeeds; tools/call hits a non-404 HTTP error.
|
||||
await assert.rejects(exaSearch({ query: "x" }, undefined, httpFail.fetchImpl), /HTTP 503/);
|
||||
});
|
||||
@@ -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