feat(search): Tavily + Brave behind a search-provider port; Google CSE retires

Search port + model slots wave, chunk A (owner design, 2026-09-20):
- core/search.ts: SearchHit + SearchProvider port + SearchStatus —
  pure, browser-safe; adapters arrive as injected dependencies.
- server/search.ts: Tavily + Brave adapters (wire shapes VERIFIED
  against docs.tavily.com's OpenAPI + Brave's dashboard docs — Tavily
  auth is the Bearer header, not the legacy body api_key; Brave is
  X-Subscription-Token) + engine resolution per owner decisions: a key
  ALONE activates its engine (no engine choice needed), explicit
  agent.search.engine pins the preference (both keys: Tavily first),
  a pinned engine with a missing key is honestly unavailable — no
  silent fallback, no default engine.
- Curated keys catalog: GOOGLE_SEARCH_API_KEY/GOOGLE_CSE_ID retire
  (CSE closed to new customers, dies Jan 2027 — stored values become
  preserved unknown vars); TAVILY_API_KEY (tvly- shape) + BRAVE_API_KEY
  (length guard) join. UI keys-section TOOL_IDS now derives from the
  catalog — cannot drift.
- GET /api/search: {available, engine, reason} — the honest status for
  the settings gray-out; researcher-disable semantics ride the same
  signal (Teacher stream rewires the tool onto the port).
- agent.search settings field (schema + sanitizer + strict parser);
  web.ts's WebSearchResult is now the core SearchHit (re-exported).
This commit is contained in:
amosblomqvist
2026-09-22 00:02:07 +03:00
parent bd66b31853
commit f1373d5de5
17 changed files with 503 additions and 49 deletions
+19 -15
View File
@@ -1,7 +1,8 @@
/**
* The curated auth-key catalog + shape rules (auth-keys wave, 2026-09-19)
* The curated auth-key catalog + shape rules (auth-keys wave, 2026-09-19;
* search wave 2026-09-20: the Google CSE pair retired, Tavily + Brave in)
* — PURE module, so this file owns no fs. Owner decisions being pinned:
* - the catalog is exactly the four known keys, ids = env-var names;
* - the catalog is exactly the three known keys, ids = env-var names;
* - validation is SHAPE-ONLY (no live pings) and bad pastes are 400s;
* - masking is last-4 + length — plaintext never leaves the store.
*/
@@ -14,7 +15,7 @@ describe("the catalog", () => {
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", () => {
+133
View File
@@ -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<Response> => {
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<string, string>;
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<string, string>;
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/);
});
});
+6 -4
View File
@@ -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");
+12
View File
@@ -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);
+2 -2
View File
@@ -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);
+10
View File
@@ -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);