diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..114c1aa --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,18 @@ +/** + * GET /api/search — the search engines' status (search wave, 2026-09-20): + * the honest availability signal the settings UI explains from (the + * gray-out affordance) and any client surface can read. + * + * { available, engine, reason } — never a key, never a secret: `engine` is + * the ACTIVE engine when live (auto-detection or the explicit pin), and + * `reason` is the one-line state explanation ("set a Tavily or Brave + * search API key", or the pinned-engine-key-missing case). + */ + +import { searchStatus } from "@/src/server"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + return Response.json(searchStatus()); +} diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 5c41c5b..5192655 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -28,7 +28,7 @@ */ import { useEffect, useState } from "react"; -import { type AuthKeyStatus } from "@/src/core"; +import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core"; import { SettingsGroup, SettingsRow } from "./controls.tsx"; /** One pi-ai provider with plain API-key auth (the Platform seam). */ @@ -58,7 +58,10 @@ const OPENROUTER_FALLBACK = { /** The curated tools group, pinned by id (the catalog owns the copy; * these ids are the contract). */ -const TOOL_IDS = ["DEEPGRAM_API_KEY", "GOOGLE_SEARCH_API_KEY", "GOOGLE_CSE_ID"] as const; +// The curated app-service keys, derived from the catalog (search wave, +// 2026-09-20: the Google CSE pair retired, Tavily + Brave joined) — the +// section can never drift from core/auth-keys.ts again. +const TOOL_IDS = AUTH_KEYS.map((k) => k.id) as readonly string[]; /** The classification that drives the section's two groups. Pure — * exported for tests. */ diff --git a/src/agent/tools/web.ts b/src/agent/tools/web.ts index 296d750..8fd2528 100644 --- a/src/agent/tools/web.ts +++ b/src/agent/tools/web.ts @@ -1,5 +1,10 @@ import { z } from "zod"; import { lhTool } from "../lh-tool.ts"; +// The search-hit shape lives in the core port since the search wave +// (2026-09-20) — this module keeps the historical alias so callers don't +// churn; the tool's rewiring onto the port is the Teacher stream's. +import type { SearchHit as WebSearchResult } from "../../core/search.ts"; +export type { WebSearchResult }; import { Readability } from "@mozilla/readability"; import { parseHTML } from "linkedom"; import TurndownService from "turndown"; @@ -22,12 +27,7 @@ import TurndownService from "turndown"; */ // ── web_search ─────────────────────────────────────────────────────────────── - -export interface WebSearchResult { - title: string; - url: string; - snippet: string; -} +// (WebSearchResult is the core SearchHit, re-exported above.) function searchCredentials(): { apiKey: string; cseId: string } | null { const apiKey = process.env.GOOGLE_SEARCH_API_KEY; diff --git a/src/core/auth-keys.ts b/src/core/auth-keys.ts index 84ed016..79169cc 100644 --- a/src/core/auth-keys.ts +++ b/src/core/auth-keys.ts @@ -37,16 +37,16 @@ export const AUTH_KEYS: readonly AuthKeyInfo[] = [ docsUrl: "https://console.deepgram.com/", }, { - id: "GOOGLE_SEARCH_API_KEY", - label: "Google Search API key", - description: "Web-search half of the Google pair (with the CSE ID) — powers the researcher sub-agent.", - docsUrl: "https://developers.google.com/custom-search/v1/introduction", + id: "TAVILY_API_KEY", + label: "Tavily", + description: "Web-search engine for the researcher sub-agent (AI-native search API). Setting the key activates it — no engine toggle needed.", + docsUrl: "https://app.tavily.com/", }, { - id: "GOOGLE_CSE_ID", - label: "Google CSE ID", - description: "Programmable Search Engine ID — which engine the search API queries.", - docsUrl: "https://programmablesearchengine.google.com/controlpanel", + id: "BRAVE_API_KEY", + label: "Brave Search", + description: "Web-search engine for the researcher sub-agent — the alternative to Tavily. Setting the key activates it.", + docsUrl: "https://brave.com/search/api/", }, ] as const; @@ -138,8 +138,8 @@ export type KeyShapeResult = { ok: true } | { ok: false; error: string }; * a truncated or wrong-family key can never land in the store). Values are * TRIMMED first (paste hygiene); empty-after-trim always fails. * - * CURATED keys carry family rules (Google AIza…, the CSE forms, - * Deepgram's length floor — a truncation guard; it publishes no format). + * CURATED keys carry family rules (Tavily's tvly-… prefix; Brave and + * Deepgram publish no format — length floors + paste hygiene only). * MODEL-PROVIDER keys use genericProviderKeyGuard below instead (families * vary by provider — only paste hygiene applies; owner spec: min length * ~12, no whitespace). OPENROUTER_API_KEY moved to the generic guard with @@ -152,16 +152,13 @@ export function validateKeyShape(id: string, rawValue: string): KeyShapeResult { ? { ok: true as const } : { ok: false as const, error: `${id} must be at least ${min} characters with no whitespace` }; switch (id) { - case "GOOGLE_SEARCH_API_KEY": - return value.startsWith("AIza") - ? nonEmpty(10) - : { ok: false, error: `${id} must start with "AIza" (Google's API-key format)` }; - case "GOOGLE_CSE_ID": - return ( - /^[0-9]{6,}:[0-9A-Za-z_-]+$/.test(value) || /^[0-9A-Za-z]{12,}$/.test(value) - ) - ? { ok: true } - : { ok: false, error: `${id} must be the Programmable Search Engine ID (digits:digits, or the plain alphanumeric form)` }; + case "TAVILY_API_KEY": + return value.startsWith("tvly-") + ? nonEmpty(20) + : { ok: false, error: `${id} must start with "tvly-" (Tavily's key format)` }; + case "BRAVE_API_KEY": + // No public format — a length floor + paste hygiene only. + return nonEmpty(20); case "DEEPGRAM_API_KEY": // No public format — the length floor only catches truncated pastes. return nonEmpty(20); diff --git a/src/core/index.ts b/src/core/index.ts index fc26695..589f559 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -41,6 +41,7 @@ export * from "./fuzzy.ts"; // view. Browser-safe — the Wave-4 panel imports these directly. export * from "./settings.ts"; export * from "./settings-resolution.ts"; +export * from "./search.ts"; // search port (search wave, 2026-09-20) // auth-keys.ts (auth-keys wave, 2026-09-19): the curated key catalog + // shape rules + masking — pure, browser-safe (the auth page binds to the // /api/keys routes built on it). diff --git a/src/core/search.ts b/src/core/search.ts new file mode 100644 index 0000000..c2c5887 --- /dev/null +++ b/src/core/search.ts @@ -0,0 +1,53 @@ +/** + * The search-provider port (search port + model slots wave, 2026-09-20). + * + * Search engines are pluggable behind ONE seam: the settings record names a + * preferred engine (agent.search.engine — optional), keys live in the auth + * store (TAVILY_API_KEY / BRAVE_API_KEY — curated catalog), and the server + * composes the live adapter. The Google CSE engine retired with this wave + * (closed to new customers; dies Jan 2027) — replaced by Tavily + Brave. + * + * Owner decisions baked here: + * - NO default engine in the factory record: an engine is live iff its + * key is present. With a key, the engine auto-activates (the user never + * chooses an engine to make search work — setting the key is enough); + * agent.search.engine is only an explicit preference. + * - Nothing silently falls back: no engine live = search is honestly + * unavailable (the tool says so; the researcher sub-agent is disabled + * by the agent layer on the same signal — that rewiring is the Teacher + * stream's, on this port). + * + * Pure types + protocol docs only (browser-safe, no node imports): the + * adapters are server-side (src/server/search.ts) and arrive as injected + * dependencies — the agent-layer tool depends on THIS port only. + */ + +/** One search result row — the shape every adapter must produce (the + * researcher's tool contract; same fields the old Google adapter emitted). */ +export interface SearchHit { + title: string; + url: string; + snippet: string; +} + +/** The engine ids this wave ships (the catalog: curated keys + adapters). */ +export type SearchEngineId = "tavily" | "brave"; + +/** A live search engine: an id (for status/labels) + one operation. The + * adapters own their wire protocol (fetch, timeouts, error mapping) — + * callers see only this. `search` throws on failure (adapters map HTTP + * errors to clear Error messages); the tool layer catches and reports. */ +export interface SearchProvider { + id: SearchEngineId; + search(query: string, count: number): Promise; +} + +/** Why search is (un)available — the honest status the UI and the tool + * layer explain from. `engine` is the ACTIVE engine when live. */ +export interface SearchStatus { + available: boolean; + /** The active engine (null when unavailable). */ + engine: SearchEngineId | null; + /** The one-line reason for the state (status text for the UI). */ + reason: string; +} diff --git a/src/core/settings-resolution.ts b/src/core/settings-resolution.ts index 2711f26..057b24b 100644 --- a/src/core/settings-resolution.ts +++ b/src/core/settings-resolution.ts @@ -24,6 +24,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ChatSettings } from "./contracts/chat.ts"; +import type { SearchEngineId } from "./search.ts"; import { BACKGROUND_MODEL_ROLES, FACTORY_SETTINGS, @@ -62,6 +63,10 @@ export interface EffectiveSettings { tools: Record; /** Delta map as stored — absent key → override off. */ prompts: Record; + /** The search-engine preference as stored (search wave, 2026-09-20): + * passthrough delta — engine resolution (pin → auto → unavailable) + * lives in server/search.ts, where keys are visible. */ + search: { engine?: SearchEngineId }; }; appearance: EffectiveAppearance; graph: Required; @@ -105,6 +110,7 @@ export function resolveSettings(record: UserSettings | null, factory: ModelRoleS models, tools: agent.tools ?? {}, prompts: agent.prompts ?? {}, + search: agent.search ?? {}, }, appearance: { mode: appearance.mode ?? factoryAppearance.mode, diff --git a/src/core/settings.ts b/src/core/settings.ts index 721cd0a..85c4268 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -27,6 +27,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ChatSettings } from "./contracts/chat.ts"; +import type { SearchEngineId } from "./search.ts"; /** The schema version this code understands. Bump ONLY for breaking shape * changes (a field renamed/meaning changed); pure additions never bump. */ @@ -70,6 +71,13 @@ export interface AgentSettings { /** Tool toggles keyed by tool id (absent key = enabled). User-level * only — no per-chat override (owner decision 2026-09-19). */ tools?: Record; + /** Search-engine preference (search wave, 2026-09-20). OPTIONAL by + * owner decision: setting a key auto-activates its engine — the user + * never chooses an engine to make search work. An explicit choice only + * pins the preference (e.g. when BOTH keys are present); the chosen + * engine must still have its key, else search is honestly unavailable. + * Absent + no key = unavailable (no silent fallback, no default). */ + search?: { engine?: SearchEngineId }; /** System-prompt override SWITCHES keyed by segment path (absent key = * off). Switch-on requires the override file to exist (enforced by the * route); the switch is explicit so users keep a file on disk but run @@ -261,6 +269,13 @@ function sanitizeAgent(raw: Record): AgentSettings { if (tools) out.tools = tools; const prompts = sanitizeBooleanMap(raw.prompts); if (prompts) out.prompts = prompts; + // Search preference: lenient read side (an unknown engine id drops; + // the write side's parser is strict — a stale client can't brick the + // record, it just loses the pin and auto-detection resumes). + if (raw.search !== null && typeof raw.search === "object" && !Array.isArray(raw.search)) { + const engine = (raw.search as Record).engine; + if (engine === "tavily" || engine === "brave") out.search = { engine }; + } return out; } diff --git a/src/server/index.ts b/src/server/index.ts index 58a36ef..fd23306 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -32,6 +32,7 @@ export { effectivePromptSegments, allSegmentPaths, readPromptOverride } from ".. // Importing this module runs the idempotent boot fill-gap — every consumer // keeps its plain process.env reads (that seam is the point). export * from "./keys.ts"; +export * from "./search.ts"; export { authStore } from "../store/auth.ts"; export * from "./settings-body.ts"; export * from "./turn-liveness.ts"; diff --git a/src/server/search.ts b/src/server/search.ts new file mode 100644 index 0000000..c5b959d --- /dev/null +++ b/src/server/search.ts @@ -0,0 +1,189 @@ +/** + * The search engines' server composition (search port + model slots wave, + * 2026-09-20): the Tavily + Brave adapters behind the core port + * (core/search.ts) + the live-engine resolution the settings UI and the + * agent-layer tool both read. + * + * Owner decisions (2026-09-20) baked in: + * - NO default engine: an engine is live iff its key is present (auth + * store or env — env wins, keyed via server/keys.ts keyValue). + * - Setting a key is ALL the user does — auto-detection: explicit + * agent.search.engine pins the preference (when BOTH keys are present + * the preference order is Tavily → Brave); without a pin, whichever + * engine has a key is the engine. + * - An explicit choice does NOT silently fall back to the other engine: + * engine pinned but its key missing = honestly unavailable (reason + * says so) — no guessing what the user meant. + * + * The adapters are injected dependencies (fetch-shaped) so the agent-layer + * tool depends on the PORT only and tests never touch the network. + */ + +import type { SearchEngineId, SearchHit, SearchProvider, SearchStatus } from "../core/search.ts"; +import { keyValue } from "./keys.ts"; +import { effectiveSettings } from "./stores.ts"; + +/** The wire-callable shape the adapters close over (tests inject a stub). */ +type FetchLike = (input: string, init: RequestInit) => Promise; + +/** Tavily: POST https://api.tavily.com/search — Bearer auth in the + * Authorization header (the current documented scheme; the legacy + * api_key-in-body form is retired), query + max_results (0–20) in the + * JSON body. VERIFIED against docs.tavily.com's OpenAPI, 2026-09-20: + * response {results: [{title, url, content, score, …}]}, errors + * {detail: {error}}. */ +export function tavilyProvider(apiKey: string, fetchFn: FetchLike = fetch): SearchProvider { + return { + id: "tavily", + async search(query: string, count: number): Promise { + const resp = await fetchFn("https://api.tavily.com/search", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ query, max_results: Math.min(count, 20) }), + signal: AbortSignal.timeout(30_000), + }); + if (!resp.ok) { + const body = await resp.text(); + // Their error body is {detail: {error: "…"}} — surface the + // message when present, else the raw slice. + let detail = body.slice(0, 200); + try { + const parsed = JSON.parse(body) as { detail?: { error?: string } }; + if (parsed.detail?.error) detail = parsed.detail.error.slice(0, 200); + } catch { + /* keep the raw slice */ + } + throw new Error(`Tavily search API ${resp.status}: ${detail}`); + } + const data = (await resp.json()) as { + results?: Array<{ title?: string; url?: string; content?: string }>; + }; + return (data.results ?? []) + .filter((r) => typeof r.url === "string" && r.url) + .map((r) => ({ + title: r.title ?? r.url ?? "", + url: r.url ?? "", + snippet: (r.content ?? "").replace(/\n/g, " ").slice(0, 500), + })); + }, + }; +} + +/** Brave: GET https://api.search.brave.com/res/v1/web/search with the + * key in the X-Subscription-Token header. VERIFIED against Brave's + * dashboard docs, 2026-09-20: count max 20; response + * {web: {results: [{title, url, description, …}]}}. */ +export function braveProvider(apiKey: string, fetchFn: FetchLike = fetch): SearchProvider { + return { + id: "brave", + async search(query: string, count: number): Promise { + const url = new URL("https://api.search.brave.com/res/v1/web/search"); + url.searchParams.set("q", query); + url.searchParams.set("count", String(Math.min(count, 20))); + const resp = await fetchFn(url.toString(), { + headers: { + accept: "application/json", + "x-subscription-token": apiKey, + }, + signal: AbortSignal.timeout(30_000), + }); + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Brave search API ${resp.status}: ${body.slice(0, 200)}`); + } + const data = (await resp.json()) as { + web?: { results?: Array<{ title?: string; url?: string; description?: string }> }; + }; + return (data.web?.results ?? []) + .filter((r) => typeof r.url === "string" && r.url) + .map((r) => ({ + title: r.title ?? r.url ?? "", + url: r.url ?? "", + snippet: (r.description ?? "").replace(/\n/g, " ").slice(0, 500), + })); + }, + }; +} + +/** The engines this wave ships, in PREFERENCE order (both keys present, + * no explicit pin → the first live engine wins; owner decision + * 2026-09-20: Tavily first — the AI-native search API the wave chose). */ +const ENGINE_ORDER: ReadonlyArray<{ id: SearchEngineId; envVar: string }> = [ + { id: "tavily", envVar: "TAVILY_API_KEY" }, + { id: "brave", envVar: "BRAVE_API_KEY" }, +]; + +/** The engine's key (env-or-file; env wins) — null when the engine is + * not keyed, which by owner decision means NOT LIVE. */ +function engineKey(id: SearchEngineId): string | null { + const envVar = ENGINE_ORDER.find((e) => e.id === id)?.envVar; + const value = envVar ? keyValue(envVar) : undefined; + return value ? value : null; +} + +/** Resolve the search state PURELY from the given pins (testable): the + * explicit engine (if pinned) wins but must be keyed; absent pin → first + * keyed engine in preference order. Never throws. */ +export function resolveSearchEngineFor( + pinned: SearchEngineId | undefined, + /** Per-engine live keys (absent engine = absent property is fine). */ + keys: Partial>, +): SearchStatus & { key: string | null } { + const pinnedKey = pinned ? keys[pinned] ?? null : null; + if (pinned) { + if (!pinnedKey) { + return { + available: false, + engine: null, + reason: `search engine is set to "${pinned}" but its API key is missing — set the ${pinned === "tavily" ? "TAVILY_API_KEY" : "BRAVE_API_KEY"} key`, + key: null, + }; + } + return { available: true, engine: pinned, reason: `search engine: ${pinned} (chosen)`, key: pinnedKey }; + } + for (const { id } of ENGINE_ORDER) { + const key = keys[id] ?? null; + if (key) return { available: true, engine: id, reason: `search engine: ${id} (auto)`, key }; + } + return { + available: false, + engine: null, + reason: "no search engine configured — set a Tavily or Brave search API key", + key: null, + }; +} + +/** The live search state (the honest availability signal): reads the + * settings record's pin + the auth store fresh each call, so a key saved + * mid-session activates on the next check (no restart). */ +export function searchStatus(): SearchStatus { + const { available, engine, reason } = resolveSearchEngineFor( + effectiveSettings().agent.search?.engine, + { + tavily: engineKey("tavily") ?? undefined, + brave: engineKey("brave") ?? undefined, + }, + ); + return { available, engine, reason }; +} + +/** Search available at all? (the settings UI's gray-out signal + the + * researcher's enable/disable pin — Teacher stream's rewiring reads this.) */ +export function searchAvailable(): boolean { + return searchStatus().available; +} + +/** The LIVE adapter, or null when unavailable. Composed fresh each call — + * the tool layer (Teacher stream) calls this once per search, so key + * rotations take effect immediately. */ +export function searchProvider(): SearchProvider | null { + const resolved = resolveSearchEngineFor(effectiveSettings().agent.search?.engine, { + tavily: engineKey("tavily") ?? undefined, + brave: engineKey("brave") ?? undefined, + }); + if (!resolved.available || !resolved.key) return null; + return resolved.engine === "tavily" ? tavilyProvider(resolved.key) : braveProvider(resolved.key); +} diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index d61e03b..d7a7dbf 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -184,6 +184,16 @@ export function parseAgentSettingsBody(body: unknown): SettingsBodyResult { it("is exactly the three CURATED app-service keys (provider keys live in the provider table)", () => { assert.deepEqual( AUTH_KEYS.map((k) => k.id), - ["DEEPGRAM_API_KEY", "GOOGLE_SEARCH_API_KEY", "GOOGLE_CSE_ID"], + ["DEEPGRAM_API_KEY", "TAVILY_API_KEY", "BRAVE_API_KEY"], ); for (const k of AUTH_KEYS) { assert.ok(k.label.length > 0, `${k.id} has a label`); @@ -22,13 +23,17 @@ describe("the catalog", () => { assert.ok(k.docsUrl.startsWith("https://"), `${k.id} has a docs link`); } // OPENROUTER_API_KEY graduated to the provider table (owner decision, - // provider-keys interlude) — not curated anymore. + // provider-keys interlude) — not curated anymore. The Google CSE + // pair retired with its engine (search wave, 2026-09-20). assert.ok(!isKnownAuthKey("OPENROUTER_API_KEY")); + assert.ok(!isKnownAuthKey("GOOGLE_SEARCH_API_KEY"), "the CSE pair is retired"); + assert.ok(!isKnownAuthKey("GOOGLE_CSE_ID")); }); it("isKnownAuthKey: catalog yes, everything else no", () => { assert.ok(isKnownAuthKey("DEEPGRAM_API_KEY")); - assert.ok(isKnownAuthKey("GOOGLE_CSE_ID")); + assert.ok(isKnownAuthKey("TAVILY_API_KEY")); + assert.ok(isKnownAuthKey("BRAVE_API_KEY")); assert.ok(!isKnownAuthKey("OPENROUTER_API_KEY")); assert.ok(!isKnownAuthKey("OPENAI_API_KEY"), "provider vars are a separate surface"); assert.ok(!isKnownAuthKey("FUTURE_PLANS_TOKEN")); @@ -37,18 +42,17 @@ describe("the catalog", () => { }); describe("validateKeyShape (the 400 gate — curated keys)", () => { - it("Google API key: AIza prefix required", () => { - assert.ok(validateKeyShape("GOOGLE_SEARCH_API_KEY", "AIzaSyD-abcdef1234").ok); - assert.ok(!validateKeyShape("GOOGLE_SEARCH_API_KEY", "sk-or-v1-abcdefghij").ok, "wrong family"); - assert.ok(!validateKeyShape("GOOGLE_SEARCH_API_KEY", "AIza").ok, "prefix alone fails"); + it("Tavily: tvly- prefix required, length floor past it", () => { + assert.ok(validateKeyShape("TAVILY_API_KEY", "tvly-abc123def456ghi78").ok); + assert.ok(!validateKeyShape("TAVILY_API_KEY", "sk-or-v1-abcdefghij").ok, "wrong family"); + assert.ok(!validateKeyShape("TAVILY_API_KEY", "tvly-").ok, "prefix alone is a truncation"); + assert.ok(!validateKeyShape("TAVILY_API_KEY", " ").ok, "empty fails"); }); - it("CSE ID: legacy digits:digits OR plain token form", () => { - assert.ok(validateKeyShape("GOOGLE_CSE_ID", "017576662512468239146:omuauf_lfve").ok); - assert.ok(validateKeyShape("GOOGLE_CSE_ID", "a1b2c3d4e5f6g7h8j9k0lmnop").ok); - assert.ok(!validateKeyShape("GOOGLE_CSE_ID", "17:short").ok, "short legacy form fails"); - assert.ok(!validateKeyShape("GOOGLE_CSE_ID", "has whitespace here").ok); - assert.ok(!validateKeyShape("GOOGLE_CSE_ID", "sk-or-v1-abcdefg").ok, "wrong family"); + it("Brave: no public format — a ≥20-char truncation guard, no whitespace", () => { + assert.ok(validateKeyShape("BRAVE_API_KEY", "b".repeat(40)).ok); + assert.ok(!validateKeyShape("BRAVE_API_KEY", "b".repeat(19)).ok, "truncated paste"); + assert.ok(!validateKeyShape("BRAVE_API_KEY", "has whitespace here").ok); }); it("Deepgram: no public format — a ≥20-char truncation guard, no whitespace", () => { diff --git a/tests/search.test.ts b/tests/search.test.ts new file mode 100644 index 0000000..a8747b3 --- /dev/null +++ b/tests/search.test.ts @@ -0,0 +1,133 @@ +/** + * The search engines (search port + model slots wave, 2026-09-20): + * engine resolution (the owner's auto-detect decision — a key is enough, + * no engine choice needed; explicit pin requires the key; no silent + * fallback) + the Tavily/Brave adapters against a STUB fetch (never the + * network — the wire shapes are verified against the published docs, and + * the tests pin those shapes). + * + * PURE + injected fetch — this file owns no fs. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { resolveSearchEngineFor, tavilyProvider, braveProvider } from "../src/server/search.ts"; + +describe("resolveSearchEngineFor (the auto-detect decision)", () => { + it("no keys: honestly unavailable, no default", () => { + const out = resolveSearchEngineFor(undefined, {}); + assert.ok(!out.available); + assert.equal(out.engine, null); + assert.match(out.reason, /no search engine configured/i); + }); + + it("a key alone is enough — no engine choice needed", () => { + const tav = resolveSearchEngineFor(undefined, { tavily: "tvly-throwaway-key-00001" }); + assert.ok(tav.available); + assert.equal(tav.engine, "tavily"); + assert.match(tav.reason, /auto/); + + const brv = resolveSearchEngineFor(undefined, { brave: "brave-throwaway-key-00001" }); + assert.ok(brv.available); + assert.equal(brv.engine, "brave"); + }); + + it("both keys, no pin: preference order — Tavily first (owner decision)", () => { + const out = resolveSearchEngineFor(undefined, { + tavily: "tvly-throwaway-key-00001", + brave: "brave-throwaway-key-00001", + }); + assert.ok(out.available); + assert.equal(out.engine, "tavily"); + }); + + it("the explicit pin wins when its key is present", () => { + const out = resolveSearchEngineFor("brave", { + tavily: "tvly-throwaway-key-00001", + brave: "brave-throwaway-key-00001", + }); + assert.ok(out.available); + assert.equal(out.engine, "brave"); + assert.match(out.reason, /chosen/); + }); + + it("the explicit pin does NOT silently fall back to the other engine", () => { + const out = resolveSearchEngineFor("tavily", { brave: "brave-throwaway-key-00001" }); + assert.ok(!out.available); + assert.equal(out.engine, null); + assert.match(out.reason, /tavily/); + assert.match(out.reason, /API key is missing/); + }); +}); + +/** A one-shot stub fetch: asserts the request shape and returns the canned response. */ +function stubFetch(canned: unknown, expect: (url: string, init: RequestInit) => void) { + return async (url: string, init: RequestInit): Promise => { + expect(url, init); + return new Response(JSON.stringify(canned), { status: 200 }); + }; +} + +describe("tavilyProvider (wire shape per docs.tavily.com, verified 2026-09-20)", () => { + it("POSTs api.tavily.com/search with Bearer auth + the query body", async () => { + const provider = tavilyProvider("tvly-throwaway-key-00001", stubFetch( + { + results: [ + { title: "Example", url: "https://example.com/a", content: "line1\nline2" }, + { title: "No URL", content: "dropped" }, + ], + }, + (url, init) => { + assert.equal(url, "https://api.tavily.com/search"); + assert.equal(init.method, "POST"); + const headers = init.headers as Record; + assert.equal(headers.authorization, "Bearer tvly-throwaway-key-00001"); + const body = JSON.parse(init.body as string); + assert.equal(body.query, "test query"); + assert.equal(body.max_results, 5); + // The legacy api_key-in-body form is retired — auth is the header. + assert.equal(body.api_key, undefined); + }, + )); + const hits = await provider.search("test query", 5); + assert.deepEqual(hits, [{ title: "Example", url: "https://example.com/a", snippet: "line1 line2" }]); + }); + + it("maps the {detail:{error}} error body into the message", async () => { + const provider = tavilyProvider("tvly-throwaway-key-00001", async () => + new Response(JSON.stringify({ detail: { error: "Unauthorized: missing or invalid API key." } }), { status: 401 }), + ); + await assert.rejects(provider.search("q", 5), /Tavily search API 401: Unauthorized/); + }); +}); + +describe("braveProvider (wire shape per Brave's dashboard docs, verified 2026-09-20)", () => { + it("GETs res/v1/web/search with the X-Subscription-Token header", async () => { + const provider = braveProvider("brave-throwaway-key-00001", stubFetch( + { + web: { + results: [ + { title: "Example", url: "https://example.com/b", description: "a description" }, + { description: "dropped — no url" }, + ], + }, + }, + (url, init) => { + assert.ok(url.startsWith("https://api.search.brave.com/res/v1/web/search?"), url); + assert.ok(url.includes("q=test+query"), url); // URLSearchParams: spaces as + + assert.ok(url.includes("count=5")); + const headers = init.headers as Record; + assert.equal(headers["x-subscription-token"], "brave-throwaway-key-00001"); + }, + )); + const hits = await provider.search("test query", 5); + assert.deepEqual(hits, [{ title: "Example", url: "https://example.com/b", snippet: "a description" }]); + }); + + it("surfaces the HTTP status on failure", async () => { + const provider = braveProvider("brave-throwaway-key-00001", async () => + new Response("rate limited", { status: 429 }), + ); + await assert.rejects(provider.search("q", 5), /Brave search API 429/); + }); +}); diff --git a/tests/server-keys.test.ts b/tests/server-keys.test.ts index 8b683e8..1ffd108 100644 --- a/tests/server-keys.test.ts +++ b/tests/server-keys.test.ts @@ -24,6 +24,8 @@ import { after, describe, it } from "node:test"; for (const varName of [ "OPENROUTER_API_KEY", "DEEPGRAM_API_KEY", + "TAVILY_API_KEY", + "BRAVE_API_KEY", "GOOGLE_SEARCH_API_KEY", "GOOGLE_CSE_ID", "OPENAI_API_KEY", @@ -196,10 +198,10 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => { describe("resolveKeyStatuses (pure projection)", () => { it("same value in env and file is NOT shadowed (redundant, not misleading)", () => { const row = resolveKeyStatuses( - { GOOGLE_SEARCH_API_KEY: "AIzaSame1234" }, - { GOOGLE_SEARCH_API_KEY: "AIzaSame1234" }, - new Set(["GOOGLE_SEARCH_API_KEY"]), - ).find((s) => s.id === "GOOGLE_SEARCH_API_KEY")!; + { BRAVE_API_KEY: "BSA-same-value-throwaway-01234" }, + { BRAVE_API_KEY: "BSA-same-value-throwaway-01234" }, + new Set(["BRAVE_API_KEY"]), + ).find((s) => s.id === "BRAVE_API_KEY")!; assert.ok(row, "the curated id is always in the projection"); assert.equal(row.source, "env"); assert.ok(!row.shadowedByEnv, "same value both places: redundant, not shadowed"); diff --git a/tests/settings-body.test.ts b/tests/settings-body.test.ts index b838315..b55f4cc 100644 --- a/tests/settings-body.test.ts +++ b/tests/settings-body.test.ts @@ -64,6 +64,18 @@ describe("parseAgentSettingsBody", () => { // cross-validation — the parser only sees shapes. }); + it("search: engine pin is tavily|brave; anything else is a 400 (search wave, 2026-09-20)", () => { + const ok = parseAgentSettingsBody({ search: { engine: "tavily" } }); + assert.ok(ok.ok); + assert.deepEqual(ok.settings.search, { engine: "tavily" }); + const clear = parseAgentSettingsBody({ search: {} }); + assert.ok(clear.ok); + assert.equal(clear.settings.search, undefined, "absent engine = back to auto-detect"); + assert.equal(parseAgentSettingsBody({ search: { engine: "google" } }).ok, false); + assert.equal(parseAgentSettingsBody({ search: "tavily" }).ok, false, "search must be an object"); + assert.equal(parseAgentSettingsBody({ search: { engine: 1 } }).ok, false); + }); + it("an empty {} agent section parses to {} (replace-whole clear)", () => { const r = parseAgentSettingsBody({}); assert.ok(r.ok); diff --git a/tests/settings-pure.test.ts b/tests/settings-pure.test.ts index d532936..ad29e39 100644 --- a/tests/settings-pure.test.ts +++ b/tests/settings-pure.test.ts @@ -176,8 +176,8 @@ describe("classifyKeys — the Keys section's two-group projection", () => { const out = classifyKeys([synth("DEEPGRAM_API_KEY", true)], undefined); assert.deepEqual(out.toolRows.map((r) => r.id), [ "DEEPGRAM_API_KEY", - "GOOGLE_SEARCH_API_KEY", - "GOOGLE_CSE_ID", + "TAVILY_API_KEY", + "BRAVE_API_KEY", ]); assert.ok(out.toolRows[0]!.present); assert.ok(!out.toolRows[1]!.present); diff --git a/tests/settings.test.ts b/tests/settings.test.ts index e9dc4d1..8dab06f 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -61,6 +61,7 @@ describe("sanitizeUserSettings", () => { }, tools: { research: false, quiz: true }, prompts: { "system-prompt.md": true, "teacher/20-teaching.md": false }, + search: { engine: "brave" }, }, appearance: { mode: "light", @@ -153,6 +154,15 @@ describe("sanitizeUserSettings", () => { assert.equal("auditor" in models, false); }); + it("search engine pin survives; unknown engine ids drop (auto-detect resumes) — search wave, 2026-09-20", () => { + const good = sanitizeUserSettings({ version: 1, agent: { search: { engine: "tavily" } } }); + assert.deepEqual(good?.agent?.search, { engine: "tavily" }); + const bad = sanitizeUserSettings({ version: 1, agent: { search: { engine: "google" } } }); + assert.equal(bad?.agent?.search, undefined); + const notObject = sanitizeUserSettings({ version: 1, agent: { search: "tavily" } }); + assert.equal(notObject?.agent?.search, undefined); + }); + it("partial maps: booleans survive, non-boolean entries drop individually", () => { const out = sanitizeUserSettings({ agent: { tools: { quiz: false, demo: true, visual: "x" } } }); assert.ok(out);