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:
@@ -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());
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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";
|
import { SettingsGroup, SettingsRow } from "./controls.tsx";
|
||||||
|
|
||||||
/** One pi-ai provider with plain API-key auth (the Platform seam). */
|
/** 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;
|
/** The curated tools group, pinned by id (the catalog owns the copy;
|
||||||
* these ids are the contract). */
|
* 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 —
|
/** The classification that drives the section's two groups. Pure —
|
||||||
* exported for tests. */
|
* exported for tests. */
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { lhTool } from "../lh-tool.ts";
|
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 { Readability } from "@mozilla/readability";
|
||||||
import { parseHTML } from "linkedom";
|
import { parseHTML } from "linkedom";
|
||||||
import TurndownService from "turndown";
|
import TurndownService from "turndown";
|
||||||
@@ -22,12 +27,7 @@ import TurndownService from "turndown";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// ── web_search ───────────────────────────────────────────────────────────────
|
// ── web_search ───────────────────────────────────────────────────────────────
|
||||||
|
// (WebSearchResult is the core SearchHit, re-exported above.)
|
||||||
export interface WebSearchResult {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
snippet: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchCredentials(): { apiKey: string; cseId: string } | null {
|
function searchCredentials(): { apiKey: string; cseId: string } | null {
|
||||||
const apiKey = process.env.GOOGLE_SEARCH_API_KEY;
|
const apiKey = process.env.GOOGLE_SEARCH_API_KEY;
|
||||||
|
|||||||
+17
-20
@@ -37,16 +37,16 @@ export const AUTH_KEYS: readonly AuthKeyInfo[] = [
|
|||||||
docsUrl: "https://console.deepgram.com/",
|
docsUrl: "https://console.deepgram.com/",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "GOOGLE_SEARCH_API_KEY",
|
id: "TAVILY_API_KEY",
|
||||||
label: "Google Search API key",
|
label: "Tavily",
|
||||||
description: "Web-search half of the Google pair (with the CSE ID) — powers the researcher sub-agent.",
|
description: "Web-search engine for the researcher sub-agent (AI-native search API). Setting the key activates it — no engine toggle needed.",
|
||||||
docsUrl: "https://developers.google.com/custom-search/v1/introduction",
|
docsUrl: "https://app.tavily.com/",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "GOOGLE_CSE_ID",
|
id: "BRAVE_API_KEY",
|
||||||
label: "Google CSE ID",
|
label: "Brave Search",
|
||||||
description: "Programmable Search Engine ID — which engine the search API queries.",
|
description: "Web-search engine for the researcher sub-agent — the alternative to Tavily. Setting the key activates it.",
|
||||||
docsUrl: "https://programmablesearchengine.google.com/controlpanel",
|
docsUrl: "https://brave.com/search/api/",
|
||||||
},
|
},
|
||||||
] as const;
|
] 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
|
* a truncated or wrong-family key can never land in the store). Values are
|
||||||
* TRIMMED first (paste hygiene); empty-after-trim always fails.
|
* TRIMMED first (paste hygiene); empty-after-trim always fails.
|
||||||
*
|
*
|
||||||
* CURATED keys carry family rules (Google AIza…, the CSE forms,
|
* CURATED keys carry family rules (Tavily's tvly-… prefix; Brave and
|
||||||
* Deepgram's length floor — a truncation guard; it publishes no format).
|
* Deepgram publish no format — length floors + paste hygiene only).
|
||||||
* MODEL-PROVIDER keys use genericProviderKeyGuard below instead (families
|
* MODEL-PROVIDER keys use genericProviderKeyGuard below instead (families
|
||||||
* vary by provider — only paste hygiene applies; owner spec: min length
|
* vary by provider — only paste hygiene applies; owner spec: min length
|
||||||
* ~12, no whitespace). OPENROUTER_API_KEY moved to the generic guard with
|
* ~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: true as const }
|
||||||
: { ok: false as const, error: `${id} must be at least ${min} characters with no whitespace` };
|
: { ok: false as const, error: `${id} must be at least ${min} characters with no whitespace` };
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case "GOOGLE_SEARCH_API_KEY":
|
case "TAVILY_API_KEY":
|
||||||
return value.startsWith("AIza")
|
return value.startsWith("tvly-")
|
||||||
? nonEmpty(10)
|
? nonEmpty(20)
|
||||||
: { ok: false, error: `${id} must start with "AIza" (Google's API-key format)` };
|
: { ok: false, error: `${id} must start with "tvly-" (Tavily's key format)` };
|
||||||
case "GOOGLE_CSE_ID":
|
case "BRAVE_API_KEY":
|
||||||
return (
|
// No public format — a length floor + paste hygiene only.
|
||||||
/^[0-9]{6,}:[0-9A-Za-z_-]+$/.test(value) || /^[0-9A-Za-z]{12,}$/.test(value)
|
return nonEmpty(20);
|
||||||
)
|
|
||||||
? { ok: true }
|
|
||||||
: { ok: false, error: `${id} must be the Programmable Search Engine ID (digits:digits, or the plain alphanumeric form)` };
|
|
||||||
case "DEEPGRAM_API_KEY":
|
case "DEEPGRAM_API_KEY":
|
||||||
// No public format — the length floor only catches truncated pastes.
|
// No public format — the length floor only catches truncated pastes.
|
||||||
return nonEmpty(20);
|
return nonEmpty(20);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export * from "./fuzzy.ts";
|
|||||||
// view. Browser-safe — the Wave-4 panel imports these directly.
|
// view. Browser-safe — the Wave-4 panel imports these directly.
|
||||||
export * from "./settings.ts";
|
export * from "./settings.ts";
|
||||||
export * from "./settings-resolution.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 +
|
// 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
|
// shape rules + masking — pure, browser-safe (the auth page binds to the
|
||||||
// /api/keys routes built on it).
|
// /api/keys routes built on it).
|
||||||
|
|||||||
@@ -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<SearchHit[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
|
|
||||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import type { ChatSettings } from "./contracts/chat.ts";
|
import type { ChatSettings } from "./contracts/chat.ts";
|
||||||
|
import type { SearchEngineId } from "./search.ts";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_MODEL_ROLES,
|
BACKGROUND_MODEL_ROLES,
|
||||||
FACTORY_SETTINGS,
|
FACTORY_SETTINGS,
|
||||||
@@ -62,6 +63,10 @@ export interface EffectiveSettings {
|
|||||||
tools: Record<string, boolean>;
|
tools: Record<string, boolean>;
|
||||||
/** Delta map as stored — absent key → override off. */
|
/** Delta map as stored — absent key → override off. */
|
||||||
prompts: Record<string, boolean>;
|
prompts: Record<string, boolean>;
|
||||||
|
/** 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;
|
appearance: EffectiveAppearance;
|
||||||
graph: Required<GraphSettings>;
|
graph: Required<GraphSettings>;
|
||||||
@@ -105,6 +110,7 @@ export function resolveSettings(record: UserSettings | null, factory: ModelRoleS
|
|||||||
models,
|
models,
|
||||||
tools: agent.tools ?? {},
|
tools: agent.tools ?? {},
|
||||||
prompts: agent.prompts ?? {},
|
prompts: agent.prompts ?? {},
|
||||||
|
search: agent.search ?? {},
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
mode: appearance.mode ?? factoryAppearance.mode,
|
mode: appearance.mode ?? factoryAppearance.mode,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
|
|
||||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import type { ChatSettings } from "./contracts/chat.ts";
|
import type { ChatSettings } from "./contracts/chat.ts";
|
||||||
|
import type { SearchEngineId } from "./search.ts";
|
||||||
|
|
||||||
/** The schema version this code understands. Bump ONLY for breaking shape
|
/** The schema version this code understands. Bump ONLY for breaking shape
|
||||||
* changes (a field renamed/meaning changed); pure additions never bump. */
|
* 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
|
/** Tool toggles keyed by tool id (absent key = enabled). User-level
|
||||||
* only — no per-chat override (owner decision 2026-09-19). */
|
* only — no per-chat override (owner decision 2026-09-19). */
|
||||||
tools?: Record<string, boolean>;
|
tools?: Record<string, boolean>;
|
||||||
|
/** 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 =
|
/** System-prompt override SWITCHES keyed by segment path (absent key =
|
||||||
* off). Switch-on requires the override file to exist (enforced by the
|
* 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
|
* route); the switch is explicit so users keep a file on disk but run
|
||||||
@@ -261,6 +269,13 @@ function sanitizeAgent(raw: Record<string, unknown>): AgentSettings {
|
|||||||
if (tools) out.tools = tools;
|
if (tools) out.tools = tools;
|
||||||
const prompts = sanitizeBooleanMap(raw.prompts);
|
const prompts = sanitizeBooleanMap(raw.prompts);
|
||||||
if (prompts) out.prompts = 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<string, unknown>).engine;
|
||||||
|
if (engine === "tavily" || engine === "brave") out.search = { engine };
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export { effectivePromptSegments, allSegmentPaths, readPromptOverride } from "..
|
|||||||
// Importing this module runs the idempotent boot fill-gap — every consumer
|
// Importing this module runs the idempotent boot fill-gap — every consumer
|
||||||
// keeps its plain process.env reads (that seam is the point).
|
// keeps its plain process.env reads (that seam is the point).
|
||||||
export * from "./keys.ts";
|
export * from "./keys.ts";
|
||||||
|
export * from "./search.ts";
|
||||||
export { authStore } from "../store/auth.ts";
|
export { authStore } from "../store/auth.ts";
|
||||||
export * from "./settings-body.ts";
|
export * from "./settings-body.ts";
|
||||||
export * from "./turn-liveness.ts";
|
export * from "./turn-liveness.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<Response>;
|
||||||
|
|
||||||
|
/** 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<SearchHit[]> {
|
||||||
|
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<SearchHit[]> {
|
||||||
|
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<Record<SearchEngineId, string | undefined>>,
|
||||||
|
): 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);
|
||||||
|
}
|
||||||
@@ -184,6 +184,16 @@ export function parseAgentSettingsBody(body: unknown): SettingsBodyResult<AgentS
|
|||||||
const prompts = parseBooleanMap(raw.prompts, "prompts");
|
const prompts = parseBooleanMap(raw.prompts, "prompts");
|
||||||
if (prompts.error) return { ok: false, error: prompts.error };
|
if (prompts.error) return { ok: false, error: prompts.error };
|
||||||
if (prompts.map) settings.prompts = prompts.map;
|
if (prompts.map) settings.prompts = prompts.map;
|
||||||
|
if (raw.search !== undefined) {
|
||||||
|
const search = asRecord(raw.search);
|
||||||
|
if (!search) return { ok: false, error: "search must be an object" };
|
||||||
|
if (search.engine !== undefined) {
|
||||||
|
if (search.engine !== "tavily" && search.engine !== "brave") {
|
||||||
|
return { ok: false, error: 'search.engine must be "tavily" or "brave"' };
|
||||||
|
}
|
||||||
|
settings.search = { engine: search.engine };
|
||||||
|
}
|
||||||
|
}
|
||||||
return { ok: true, settings };
|
return { ok: true, settings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-15
@@ -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:
|
* — 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;
|
* - validation is SHAPE-ONLY (no live pings) and bad pastes are 400s;
|
||||||
* - masking is last-4 + length — plaintext never leaves the store.
|
* - 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)", () => {
|
it("is exactly the three CURATED app-service keys (provider keys live in the provider table)", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
AUTH_KEYS.map((k) => k.id),
|
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) {
|
for (const k of AUTH_KEYS) {
|
||||||
assert.ok(k.label.length > 0, `${k.id} has a label`);
|
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`);
|
assert.ok(k.docsUrl.startsWith("https://"), `${k.id} has a docs link`);
|
||||||
}
|
}
|
||||||
// OPENROUTER_API_KEY graduated to the provider table (owner decision,
|
// 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("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", () => {
|
it("isKnownAuthKey: catalog yes, everything else no", () => {
|
||||||
assert.ok(isKnownAuthKey("DEEPGRAM_API_KEY"));
|
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("OPENROUTER_API_KEY"));
|
||||||
assert.ok(!isKnownAuthKey("OPENAI_API_KEY"), "provider vars are a separate surface");
|
assert.ok(!isKnownAuthKey("OPENAI_API_KEY"), "provider vars are a separate surface");
|
||||||
assert.ok(!isKnownAuthKey("FUTURE_PLANS_TOKEN"));
|
assert.ok(!isKnownAuthKey("FUTURE_PLANS_TOKEN"));
|
||||||
@@ -37,18 +42,17 @@ describe("the catalog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("validateKeyShape (the 400 gate — curated keys)", () => {
|
describe("validateKeyShape (the 400 gate — curated keys)", () => {
|
||||||
it("Google API key: AIza prefix required", () => {
|
it("Tavily: tvly- prefix required, length floor past it", () => {
|
||||||
assert.ok(validateKeyShape("GOOGLE_SEARCH_API_KEY", "AIzaSyD-abcdef1234").ok);
|
assert.ok(validateKeyShape("TAVILY_API_KEY", "tvly-abc123def456ghi78").ok);
|
||||||
assert.ok(!validateKeyShape("GOOGLE_SEARCH_API_KEY", "sk-or-v1-abcdefghij").ok, "wrong family");
|
assert.ok(!validateKeyShape("TAVILY_API_KEY", "sk-or-v1-abcdefghij").ok, "wrong family");
|
||||||
assert.ok(!validateKeyShape("GOOGLE_SEARCH_API_KEY", "AIza").ok, "prefix alone fails");
|
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", () => {
|
it("Brave: no public format — a ≥20-char truncation guard, no whitespace", () => {
|
||||||
assert.ok(validateKeyShape("GOOGLE_CSE_ID", "017576662512468239146:omuauf_lfve").ok);
|
assert.ok(validateKeyShape("BRAVE_API_KEY", "b".repeat(40)).ok);
|
||||||
assert.ok(validateKeyShape("GOOGLE_CSE_ID", "a1b2c3d4e5f6g7h8j9k0lmnop").ok);
|
assert.ok(!validateKeyShape("BRAVE_API_KEY", "b".repeat(19)).ok, "truncated paste");
|
||||||
assert.ok(!validateKeyShape("GOOGLE_CSE_ID", "17:short").ok, "short legacy form fails");
|
assert.ok(!validateKeyShape("BRAVE_API_KEY", "has whitespace here").ok);
|
||||||
assert.ok(!validateKeyShape("GOOGLE_CSE_ID", "has whitespace here").ok);
|
|
||||||
assert.ok(!validateKeyShape("GOOGLE_CSE_ID", "sk-or-v1-abcdefg").ok, "wrong family");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Deepgram: no public format — a ≥20-char truncation guard, no whitespace", () => {
|
it("Deepgram: no public format — a ≥20-char truncation guard, no whitespace", () => {
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,8 @@ import { after, describe, it } from "node:test";
|
|||||||
for (const varName of [
|
for (const varName of [
|
||||||
"OPENROUTER_API_KEY",
|
"OPENROUTER_API_KEY",
|
||||||
"DEEPGRAM_API_KEY",
|
"DEEPGRAM_API_KEY",
|
||||||
|
"TAVILY_API_KEY",
|
||||||
|
"BRAVE_API_KEY",
|
||||||
"GOOGLE_SEARCH_API_KEY",
|
"GOOGLE_SEARCH_API_KEY",
|
||||||
"GOOGLE_CSE_ID",
|
"GOOGLE_CSE_ID",
|
||||||
"OPENAI_API_KEY",
|
"OPENAI_API_KEY",
|
||||||
@@ -196,10 +198,10 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => {
|
|||||||
describe("resolveKeyStatuses (pure projection)", () => {
|
describe("resolveKeyStatuses (pure projection)", () => {
|
||||||
it("same value in env and file is NOT shadowed (redundant, not misleading)", () => {
|
it("same value in env and file is NOT shadowed (redundant, not misleading)", () => {
|
||||||
const row = resolveKeyStatuses(
|
const row = resolveKeyStatuses(
|
||||||
{ GOOGLE_SEARCH_API_KEY: "AIzaSame1234" },
|
{ BRAVE_API_KEY: "BSA-same-value-throwaway-01234" },
|
||||||
{ GOOGLE_SEARCH_API_KEY: "AIzaSame1234" },
|
{ BRAVE_API_KEY: "BSA-same-value-throwaway-01234" },
|
||||||
new Set(["GOOGLE_SEARCH_API_KEY"]),
|
new Set(["BRAVE_API_KEY"]),
|
||||||
).find((s) => s.id === "GOOGLE_SEARCH_API_KEY")!;
|
).find((s) => s.id === "BRAVE_API_KEY")!;
|
||||||
assert.ok(row, "the curated id is always in the projection");
|
assert.ok(row, "the curated id is always in the projection");
|
||||||
assert.equal(row.source, "env");
|
assert.equal(row.source, "env");
|
||||||
assert.ok(!row.shadowedByEnv, "same value both places: redundant, not shadowed");
|
assert.ok(!row.shadowedByEnv, "same value both places: redundant, not shadowed");
|
||||||
|
|||||||
@@ -64,6 +64,18 @@ describe("parseAgentSettingsBody", () => {
|
|||||||
// cross-validation — the parser only sees shapes.
|
// 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)", () => {
|
it("an empty {} agent section parses to {} (replace-whole clear)", () => {
|
||||||
const r = parseAgentSettingsBody({});
|
const r = parseAgentSettingsBody({});
|
||||||
assert.ok(r.ok);
|
assert.ok(r.ok);
|
||||||
|
|||||||
@@ -176,8 +176,8 @@ describe("classifyKeys — the Keys section's two-group projection", () => {
|
|||||||
const out = classifyKeys([synth("DEEPGRAM_API_KEY", true)], undefined);
|
const out = classifyKeys([synth("DEEPGRAM_API_KEY", true)], undefined);
|
||||||
assert.deepEqual(out.toolRows.map((r) => r.id), [
|
assert.deepEqual(out.toolRows.map((r) => r.id), [
|
||||||
"DEEPGRAM_API_KEY",
|
"DEEPGRAM_API_KEY",
|
||||||
"GOOGLE_SEARCH_API_KEY",
|
"TAVILY_API_KEY",
|
||||||
"GOOGLE_CSE_ID",
|
"BRAVE_API_KEY",
|
||||||
]);
|
]);
|
||||||
assert.ok(out.toolRows[0]!.present);
|
assert.ok(out.toolRows[0]!.present);
|
||||||
assert.ok(!out.toolRows[1]!.present);
|
assert.ok(!out.toolRows[1]!.present);
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ describe("sanitizeUserSettings", () => {
|
|||||||
},
|
},
|
||||||
tools: { research: false, quiz: true },
|
tools: { research: false, quiz: true },
|
||||||
prompts: { "system-prompt.md": true, "teacher/20-teaching.md": false },
|
prompts: { "system-prompt.md": true, "teacher/20-teaching.md": false },
|
||||||
|
search: { engine: "brave" },
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
mode: "light",
|
mode: "light",
|
||||||
@@ -153,6 +154,15 @@ describe("sanitizeUserSettings", () => {
|
|||||||
assert.equal("auditor" in models, false);
|
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", () => {
|
it("partial maps: booleans survive, non-boolean entries drop individually", () => {
|
||||||
const out = sanitizeUserSettings({ agent: { tools: { quiz: false, demo: true, visual: "x" } } });
|
const out = sanitizeUserSettings({ agent: { tools: { quiz: false, demo: true, visual: "x" } } });
|
||||||
assert.ok(out);
|
assert.ok(out);
|
||||||
|
|||||||
Reference in New Issue
Block a user