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,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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user