@bitbaum/ai-kit 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -63,6 +63,13 @@ exports.FACT_KINDS = {
63
63
  assignment: ["title", "assignee", "status", "due", "fee", "why"],
64
64
  document: ["title", "source", "excerpt"],
65
65
  pending_action: ["title", "type", "reasoning", "proposed_on", "id"],
66
+ // Retrieved from the open web rather than stored. `url` is the field that
67
+ // makes these checkable by a human reader, which is what separates a cited
68
+ // claim from a confident one. The rest are declared precisely BECAUSE search
69
+ // engines so often omit them: a rendered `published: <not recorded>` is what
70
+ // stops a model putting a year on an undated page.
71
+ web_result: ["title", "url", "published", "snippet", "engine"],
72
+ web_page: ["title", "url", "retrieved", "truncated"],
66
73
  };
67
74
  /** Field list for a kind; unknown kinds fall back to whatever the fact carries. */
68
75
  function declaredFields(kind, fallback = []) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitbaum/ai-kit",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "One install for the AI layer of an app: which model to call, what to do when the vendor retires it, how to walk the fallback chain and know when none of it worked, how to read the three kinds of 429, a fair daily budget across users, headless AI form filling — and now the model registry (one SSOT for every callable id, with the paid/free boundary as a field) and the grounding harness (facts, contract, deterministic fabrication check).",
5
5
  "license": "MIT",
6
6
  "author": "Mao Nakamoto",
@@ -63,6 +63,10 @@
63
63
  "require": "./dist-cjs/registry.js",
64
64
  "default": "./dist/registry.js"
65
65
  },
66
+ "./web": {
67
+ "types": "./dist/web/index.d.ts",
68
+ "default": "./dist/web/index.js"
69
+ },
66
70
  "./grounding": {
67
71
  "types": "./dist/grounding/index.d.ts",
68
72
  "require": "./dist-cjs/grounding/index.js",
@@ -80,6 +80,13 @@ export const FACT_KINDS: Record<string, readonly string[]> = {
80
80
  assignment: ["title", "assignee", "status", "due", "fee", "why"],
81
81
  document: ["title", "source", "excerpt"],
82
82
  pending_action: ["title", "type", "reasoning", "proposed_on", "id"],
83
+ // Retrieved from the open web rather than stored. `url` is the field that
84
+ // makes these checkable by a human reader, which is what separates a cited
85
+ // claim from a confident one. The rest are declared precisely BECAUSE search
86
+ // engines so often omit them: a rendered `published: <not recorded>` is what
87
+ // stops a model putting a year on an undated page.
88
+ web_result: ["title", "url", "published", "snippet", "engine"],
89
+ web_page: ["title", "url", "retrieved", "truncated"],
83
90
  };
84
91
 
85
92
  /** Field list for a kind; unknown kinds fall back to whatever the fact carries. */
package/src/liveness.ts CHANGED
@@ -277,8 +277,15 @@ export interface AiHealthHandlerOptions extends LivenessOptions {
277
277
  * When absent, the handler NEVER probes — it only reports passive health.
278
278
  * That default is deliberate: an app that forgets to configure a secret gets
279
279
  * a route that cannot spend money, rather than an open endpoint that can.
280
+ *
281
+ * Pass a FUNCTION to read it per request. A handler is normally built once
282
+ * and reused (its cache has to live somewhere), so a plain string is captured
283
+ * at that moment — which means the secret is whatever the environment held on
284
+ * the first request, and rotating it needs a process restart. A getter also
285
+ * makes the route testable: with a captured string, the first test that runs
286
+ * without a secret configured pins every later one to 501.
280
287
  */
281
- secret?: string;
288
+ secret?: string | (() => string | undefined);
282
289
  /** Passive health to report alongside. Optional. */
283
290
  health?: HealthTracker;
284
291
  }
@@ -316,17 +323,21 @@ export function createAiHealthHandler(
316
323
  return json(200, { probed: false, ...passive });
317
324
  }
318
325
 
326
+ // Read per request when a getter was given, so rotating the secret does not
327
+ // need a restart and a route built before the env was set is not stuck.
328
+ const expected = typeof secret === "function" ? secret() : secret;
329
+
319
330
  // No secret configured means probing is switched off, which is a different
320
331
  // answer from "your secret is wrong" — say so, rather than implying the
321
332
  // caller could retry with a better credential.
322
- if (!secret) {
333
+ if (!expected) {
323
334
  return json(501, {
324
335
  probed: false,
325
336
  error: "Probing is not configured on this deployment (no secret set).",
326
337
  ...passive,
327
338
  });
328
339
  }
329
- if (!offered || !timingSafeEqual(offered, secret)) {
340
+ if (!offered || !timingSafeEqual(offered, expected)) {
330
341
  return json(401, { probed: false, error: "Bad or missing probe secret.", ...passive });
331
342
  }
332
343
 
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Web results → grounded Facts.
3
+ *
4
+ * This is the join that makes web search safe to give an agent. On its own,
5
+ * search makes hallucination WORSE, not better: the model now has a pile of
6
+ * plausible-sounding text from strangers, mixed into a context window beside
7
+ * the user's real data, with nothing marking which sentence came from where.
8
+ * The failure mode is not "the model made something up" — it is "the model
9
+ * attributed a real sentence to the wrong source", and that is indistinguishable
10
+ * from competence until someone clicks the link.
11
+ *
12
+ * The fix is the one the grounding harness already implements for database
13
+ * rows. Every retrieved item becomes a `Fact` with a citation handle ([F3]) and
14
+ * a `url` field, `verifyAnswer` then checks mechanically that every handle in
15
+ * the answer exists and that proper nouns and numbers in the answer appear in
16
+ * the evidence. A claim that cites nothing, or cites [F9] when only F1–F4 were
17
+ * retrieved, is caught by a string check rather than by a reviewer's judgement.
18
+ *
19
+ * Two sets come out of here on purpose:
20
+ *
21
+ * facts — the citable records. Short, uniform, one block per source.
22
+ * evidence — the actual prose (snippets, page text). The verifier needs the
23
+ * words themselves to judge whether "€4.2 billion" in the answer
24
+ * was read or invented; a fact's five short fields are not enough
25
+ * of a corpus for that, and passing the page text AS a fact field
26
+ * would bury the citable metadata in ten thousand characters.
27
+ */
28
+ import { makeFact, type Fact } from "../grounding/facts.js";
29
+ import type { PageOutcome, WebResult } from "./types.js";
30
+ import type { WebSearchResult } from "./search.js";
31
+
32
+ /** Search hits as citable records. Order is the engine's ranking, preserved. */
33
+ export function resultsToFacts(results: WebResult[], provider: string): Fact[] {
34
+ return results.map((r) =>
35
+ makeFact({
36
+ kind: "web_result",
37
+ subject: r.title,
38
+ source: `web search (${provider})`,
39
+ values: {
40
+ title: r.title,
41
+ url: r.url,
42
+ published: r.published ?? null,
43
+ snippet: r.snippet || null,
44
+ engine: r.engine ?? provider,
45
+ },
46
+ }),
47
+ );
48
+ }
49
+
50
+ /** One fetched page as a citable record. The prose goes to `pageEvidence`. */
51
+ export function pageToFact(page: Extract<PageOutcome, { ok: true }>): Fact {
52
+ return makeFact({
53
+ kind: "web_page",
54
+ subject: page.title || page.url,
55
+ source: "page read",
56
+ values: {
57
+ title: page.title || null,
58
+ url: page.url,
59
+ retrieved: new Date().toISOString().slice(0, 10),
60
+ // Stated rather than implied: a model told the text is partial will say
61
+ // "the first part of the page" instead of summarising a page it half read.
62
+ truncated: page.truncated ? "yes — only the first part of the page was read" : "no",
63
+ },
64
+ });
65
+ }
66
+
67
+ /**
68
+ * The prose a claim may draw on, one block per source, each labelled with the
69
+ * fact id that licenses citing it.
70
+ *
71
+ * `facts` must be the ID-ASSIGNED array (post `assignFactIds`) and must line up
72
+ * positionally with `results`. Passing un-assigned facts produces blocks labelled
73
+ * `[]`, which is the bug this note exists to prevent.
74
+ */
75
+ export function resultsEvidence(facts: Fact[], results: WebResult[]): string[] {
76
+ return results.map((r, i) => {
77
+ const id = facts[i]?.id ?? "";
78
+ const head = `${id ? `[${id}] ` : ""}${r.title} — ${r.url}`;
79
+ return r.snippet ? `${head}\n${r.snippet}` : head;
80
+ });
81
+ }
82
+
83
+ /** A fetched page's readable text as one evidence block, labelled with its id. */
84
+ export function pageEvidence(fact: Fact, page: Extract<PageOutcome, { ok: true }>): string {
85
+ const head = `[${fact.id}] ${page.title || page.url} — ${page.url}`;
86
+ const tail = page.truncated ? "\n[…the rest of the page was not read]" : "";
87
+ return `${head}\n${page.text}${tail}`;
88
+ }
89
+
90
+ /**
91
+ * The sentence a model is shown when a lookup produced no usable answer.
92
+ *
93
+ * Written as an INSTRUCTION rather than a status code because that is what the
94
+ * model actually acts on, and because the two cases need opposite replies: a
95
+ * genuine "nothing out there" may be reported as a finding, while a backend
96
+ * outage may not be reported at all — the honest reply is that we could not
97
+ * look. Handing both to the model as `results: []` is how a broken API key
98
+ * becomes a confident statement about the state of the world.
99
+ */
100
+ export function describeEmptySearch(outcome: WebSearchResult): string {
101
+ if (outcome.status === "nothing") {
102
+ return (
103
+ `The web search for "${outcome.query}" ran and returned no results. ` +
104
+ `You may tell the user nothing was found for that phrasing, and suggest a different one. ` +
105
+ `Do NOT present this as proof that the thing does not exist.`
106
+ );
107
+ }
108
+ const reasons = outcome.attempts
109
+ .map((a) => `${a.provider}: ${a.failure?.reason ?? a.outcome}`)
110
+ .join(" | ");
111
+ return (
112
+ `The web search for "${outcome.query}" COULD NOT BE PERFORMED (${reasons}). ` +
113
+ `Tell the user plainly that you could not search the web right now. ` +
114
+ `You must NOT say that nothing was found, that no such thing exists, or describe any web content — ` +
115
+ `you have not seen any.`
116
+ );
117
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * ai-kit/web — the agent's eyes on the open web.
3
+ *
4
+ * Why this belongs in this package rather than in each app. Every AI product
5
+ * in the fleet is about to need the same four things, and each one is a
6
+ * distinct way to be wrong:
7
+ *
8
+ * 1. WHICH backend. Self-hosted metasearch, an independent index, an
9
+ * agent-oriented API — with the same fallback-chain problem `chain.ts`
10
+ * already solved for models, for the same reason: a single pinned
11
+ * backend is a scheduled outage.
12
+ * 2. WHETHER it answered. "Found nothing" and "could not look" are different
13
+ * answers, and an app that collapses them ships confident negatives it
14
+ * never earned.
15
+ * 3. Fetching a page an AGENT chose, which is a genuinely different security
16
+ * problem from fetching one a user typed — the model can be steered to a
17
+ * URL by any page it reads, so the SSRF guard has to survive redirects and
18
+ * DNS rebinding rather than checking a string once.
19
+ * 4. CITATIONS. Search without citation binding makes hallucination worse,
20
+ * because now the invented sentence is surrounded by real ones.
21
+ *
22
+ * Four chances to get it wrong, times every app, is the duplication this
23
+ * package exists to end. The output type is deliberately `Fact` from
24
+ * `ai-kit/grounding` rather than a bespoke shape: retrieved web content and
25
+ * retrieved database rows then flow through ONE verifier, and "cite your
26
+ * sources" becomes a mechanical check instead of a line in a prompt.
27
+ *
28
+ * What it deliberately does not do: summarise, re-rank with an LLM, crawl,
29
+ * render JavaScript, or cache. The first two are the model's job and belong
30
+ * upstream where the app's own prompt lives; the last three are a different
31
+ * product with a different cost profile.
32
+ */
33
+ export type {
34
+ WebResult,
35
+ WebFailure,
36
+ SearchOutcome,
37
+ PageOutcome,
38
+ SearchProvider,
39
+ SearchOptions,
40
+ ReadOptions,
41
+ WebEnv,
42
+ } from "./types.js";
43
+
44
+ export {
45
+ webSearch,
46
+ describeAttempts,
47
+ type WebSearchResult,
48
+ type SearchAttempt,
49
+ type WebSearchDeps,
50
+ } from "./search.js";
51
+
52
+ export { searxngProvider, braveProvider, tavilyProvider, defaultProviders } from "./providers.js";
53
+
54
+ export { readPage, extractReadableText, type ReadDeps, type ExtractedPage } from "./read.js";
55
+
56
+ export {
57
+ validateFetchTarget,
58
+ isPrivateAddress,
59
+ defaultLookup,
60
+ type LookupFn,
61
+ type UrlVerdict,
62
+ } from "./ssrf.js";
63
+
64
+ export {
65
+ resultsToFacts,
66
+ pageToFact,
67
+ resultsEvidence,
68
+ pageEvidence,
69
+ describeEmptySearch,
70
+ } from "./facts.js";
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Search backends.
3
+ *
4
+ * Three, on purpose, and in this order of preference:
5
+ *
6
+ * searxng — self-hosted, no API key, no per-call cost, no third party told
7
+ * what your users are looking for. A metasearch front end over the
8
+ * engines you enable. The catch is that it is scraping on your
9
+ * behalf from your own IP, so a datacenter host will sometimes be
10
+ * refused by upstream engines; that failure is loud here, which is
11
+ * why the chain exists.
12
+ * brave — an independent index (not a Bing or Google reseller) with a free
13
+ * tier, so the fallback is not the same index the primary was
14
+ * already asking.
15
+ * tavily — built for agents: it returns cleaned content rather than SERP
16
+ * chrome. Last resort because it is the most expensive per call.
17
+ *
18
+ * The seam is one method wide deliberately. Every provider here also sells
19
+ * "AI answers", news verticals and summarisation; adopting any of those would
20
+ * make the backends non-interchangeable, and an interchangeable backend is the
21
+ * entire reason to have a seam. Summarising is the model's job and it happens
22
+ * upstream of this file.
23
+ *
24
+ * None of them throws. A provider that cannot answer returns a typed failure,
25
+ * because the chain has to tell "no results" apart from "no answer" to pick
26
+ * its next move — and so does the model reading the result.
27
+ */
28
+ import type { SearchOutcome, SearchProvider, WebEnv, WebFailure, WebResult } from "./types.js";
29
+
30
+ const DEFAULT_LIMIT = 8;
31
+ const DEFAULT_TIMEOUT_MS = 8_000;
32
+ const MAX_LIMIT = 20;
33
+
34
+ type Fetch = typeof globalThis.fetch;
35
+
36
+ function clampLimit(limit: number | undefined): number {
37
+ if (!limit || !Number.isFinite(limit)) return DEFAULT_LIMIT;
38
+ return Math.max(1, Math.min(MAX_LIMIT, Math.floor(limit)));
39
+ }
40
+
41
+ /** `site:` is spelled the same by every engine here, so it is applied once. */
42
+ function applySite(query: string, site: string | undefined): string {
43
+ const trimmed = site?.trim();
44
+ if (!trimmed) return query;
45
+ return `${query} site:${trimmed.replace(/^https?:\/\//, "").replace(/\/.*$/, "")}`;
46
+ }
47
+
48
+ function transportFailure(err: unknown): WebFailure {
49
+ const name = err instanceof Error ? err.name : "";
50
+ const message = err instanceof Error ? err.message : String(err);
51
+ if (name === "TimeoutError" || name === "AbortError") {
52
+ return { kind: "timeout", reason: "The search backend did not answer in time." };
53
+ }
54
+ return { kind: "unreachable", reason: `The search backend could not be reached (${message}).` };
55
+ }
56
+
57
+ /** HTTP status → failure kind. Shared because every provider gets this wrong the same way. */
58
+ function statusFailure(status: number, provider: string): WebFailure {
59
+ if (status === 429) {
60
+ return { kind: "rate_limited", reason: `${provider} is rate-limiting this key right now.` };
61
+ }
62
+ if (status === 401 || status === 403) {
63
+ return { kind: "auth", reason: `${provider} rejected the credentials (${status}).` };
64
+ }
65
+ return { kind: "bad_response", reason: `${provider} answered ${status}.` };
66
+ }
67
+
68
+ function trimResult(raw: {
69
+ title?: unknown;
70
+ url?: unknown;
71
+ snippet?: unknown;
72
+ published?: unknown;
73
+ engine?: unknown;
74
+ }): WebResult | null {
75
+ const url = typeof raw.url === "string" ? raw.url.trim() : "";
76
+ if (!url || !/^https?:\/\//i.test(url)) {
77
+ return null;
78
+ }
79
+ const title = typeof raw.title === "string" && raw.title.trim() ? raw.title.trim() : url;
80
+ const snippet = typeof raw.snippet === "string" ? raw.snippet.trim().slice(0, 600) : "";
81
+ const published =
82
+ typeof raw.published === "string" && raw.published.trim() ? raw.published.trim() : undefined;
83
+ const engine =
84
+ typeof raw.engine === "string" && raw.engine.trim() ? raw.engine.trim() : undefined;
85
+ return {
86
+ title: title.slice(0, 300),
87
+ url,
88
+ snippet,
89
+ ...(published ? { published } : {}),
90
+ ...(engine ? { engine } : {}),
91
+ };
92
+ }
93
+
94
+ // ── SearXNG ────────────────────────────────────────────────────────────────
95
+ // Needs `formats: [html, json]` in the instance's settings.yml — a stock
96
+ // instance serves HTML only and answers a JSON request with 403, which is
97
+ // reported here as an auth failure so the operator sees the real fix rather
98
+ // than "no results".
99
+
100
+ export function searxngProvider(env: WebEnv, doFetch: Fetch = globalThis.fetch): SearchProvider {
101
+ const base = env.SEARXNG_URL?.trim().replace(/\/+$/, "");
102
+ return {
103
+ name: "searxng",
104
+ configured: () => Boolean(base),
105
+ async search(query, opts): Promise<SearchOutcome> {
106
+ if (!base) {
107
+ return {
108
+ ok: false,
109
+ provider: "searxng",
110
+ query,
111
+ failure: { kind: "not_configured", reason: "SEARXNG_URL is not set." },
112
+ };
113
+ }
114
+ const url = new URL(`${base}/search`);
115
+ url.searchParams.set("q", applySite(query, opts.site));
116
+ url.searchParams.set("format", "json");
117
+ url.searchParams.set("safesearch", "0");
118
+ if (opts.lang) {
119
+ url.searchParams.set("language", opts.lang);
120
+ }
121
+ try {
122
+ const res = await doFetch(url.toString(), {
123
+ headers: {
124
+ Accept: "application/json",
125
+ ...(env.SEARXNG_TOKEN ? { Authorization: `Bearer ${env.SEARXNG_TOKEN}` } : {}),
126
+ },
127
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
128
+ });
129
+ if (!res.ok) {
130
+ return {
131
+ ok: false,
132
+ provider: "searxng",
133
+ query,
134
+ failure: statusFailure(res.status, "SearXNG"),
135
+ };
136
+ }
137
+ const body = (await res.json()) as { results?: unknown };
138
+ const rows = Array.isArray(body.results) ? body.results : [];
139
+ const results = rows
140
+ .map((r) => {
141
+ const row = r as Record<string, unknown>;
142
+ return trimResult({
143
+ title: row.title,
144
+ url: row.url,
145
+ snippet: row.content,
146
+ published: row.publishedDate,
147
+ engine: row.engine,
148
+ });
149
+ })
150
+ .filter((r): r is WebResult => r !== null)
151
+ .slice(0, clampLimit(opts.limit));
152
+ return { ok: true, provider: "searxng", query, results };
153
+ } catch (err) {
154
+ return { ok: false, provider: "searxng", query, failure: transportFailure(err) };
155
+ }
156
+ },
157
+ };
158
+ }
159
+
160
+ // ── Brave ──────────────────────────────────────────────────────────────────
161
+
162
+ export function braveProvider(env: WebEnv, doFetch: Fetch = globalThis.fetch): SearchProvider {
163
+ const key = env.BRAVE_SEARCH_API_KEY?.trim();
164
+ return {
165
+ name: "brave",
166
+ configured: () => Boolean(key),
167
+ async search(query, opts): Promise<SearchOutcome> {
168
+ if (!key) {
169
+ return {
170
+ ok: false,
171
+ provider: "brave",
172
+ query,
173
+ failure: { kind: "not_configured", reason: "BRAVE_SEARCH_API_KEY is not set." },
174
+ };
175
+ }
176
+ const url = new URL("https://api.search.brave.com/res/v1/web/search");
177
+ url.searchParams.set("q", applySite(query, opts.site));
178
+ url.searchParams.set("count", String(clampLimit(opts.limit)));
179
+ if (opts.lang) {
180
+ url.searchParams.set("search_lang", opts.lang.split("-")[0] ?? opts.lang);
181
+ }
182
+ try {
183
+ const res = await doFetch(url.toString(), {
184
+ headers: { Accept: "application/json", "X-Subscription-Token": key },
185
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
186
+ });
187
+ if (!res.ok) {
188
+ return {
189
+ ok: false,
190
+ provider: "brave",
191
+ query,
192
+ failure: statusFailure(res.status, "Brave Search"),
193
+ };
194
+ }
195
+ const body = (await res.json()) as { web?: { results?: unknown } };
196
+ const rows = Array.isArray(body.web?.results) ? body.web.results : [];
197
+ const results = rows
198
+ .map((r) => {
199
+ const row = r as Record<string, unknown>;
200
+ return trimResult({
201
+ title: row.title,
202
+ url: row.url,
203
+ snippet: row.description,
204
+ published: row.page_age,
205
+ engine: "brave",
206
+ });
207
+ })
208
+ .filter((r): r is WebResult => r !== null);
209
+ return { ok: true, provider: "brave", query, results };
210
+ } catch (err) {
211
+ return { ok: false, provider: "brave", query, failure: transportFailure(err) };
212
+ }
213
+ },
214
+ };
215
+ }
216
+
217
+ // ── Tavily ─────────────────────────────────────────────────────────────────
218
+
219
+ export function tavilyProvider(env: WebEnv, doFetch: Fetch = globalThis.fetch): SearchProvider {
220
+ const key = env.TAVILY_API_KEY?.trim();
221
+ return {
222
+ name: "tavily",
223
+ configured: () => Boolean(key),
224
+ async search(query, opts): Promise<SearchOutcome> {
225
+ if (!key) {
226
+ return {
227
+ ok: false,
228
+ provider: "tavily",
229
+ query,
230
+ failure: { kind: "not_configured", reason: "TAVILY_API_KEY is not set." },
231
+ };
232
+ }
233
+ try {
234
+ const res = await doFetch("https://api.tavily.com/search", {
235
+ method: "POST",
236
+ headers: {
237
+ "Content-Type": "application/json",
238
+ Authorization: `Bearer ${key}`,
239
+ },
240
+ body: JSON.stringify({
241
+ query: applySite(query, opts.site),
242
+ max_results: clampLimit(opts.limit),
243
+ search_depth: "basic",
244
+ }),
245
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
246
+ });
247
+ if (!res.ok) {
248
+ return {
249
+ ok: false,
250
+ provider: "tavily",
251
+ query,
252
+ failure: statusFailure(res.status, "Tavily"),
253
+ };
254
+ }
255
+ const body = (await res.json()) as { results?: unknown };
256
+ const rows = Array.isArray(body.results) ? body.results : [];
257
+ const results = rows
258
+ .map((r) => {
259
+ const row = r as Record<string, unknown>;
260
+ return trimResult({
261
+ title: row.title,
262
+ url: row.url,
263
+ snippet: row.content,
264
+ published: row.published_date,
265
+ engine: "tavily",
266
+ });
267
+ })
268
+ .filter((r): r is WebResult => r !== null);
269
+ return { ok: true, provider: "tavily", query, results };
270
+ } catch (err) {
271
+ return { ok: false, provider: "tavily", query, failure: transportFailure(err) };
272
+ }
273
+ },
274
+ };
275
+ }
276
+
277
+ /**
278
+ * The default chain, in preference order, filtered to what the env configures.
279
+ *
280
+ * A caller that wants a different order passes its own array — this function
281
+ * exists so that the common case ("use whatever we have") is one call and not
282
+ * a policy decision re-made in every app, which is how the fleet ended up with
283
+ * one model-fallback implementation per repo.
284
+ */
285
+ export function defaultProviders(env: WebEnv, doFetch: Fetch = globalThis.fetch): SearchProvider[] {
286
+ return [
287
+ searxngProvider(env, doFetch),
288
+ braveProvider(env, doFetch),
289
+ tavilyProvider(env, doFetch),
290
+ ].filter((p) => p.configured());
291
+ }
292
+
293
+ export { DEFAULT_LIMIT, DEFAULT_TIMEOUT_MS, MAX_LIMIT };