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
+17 -20
View File
@@ -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);