@bitbaum/ai-kit 0.15.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.
- package/README.md +46 -0
- package/dist/grounding/facts.js +7 -0
- package/dist/web/facts.d.ts +60 -0
- package/dist/web/facts.js +104 -0
- package/dist/web/index.d.ts +38 -0
- package/dist/web/index.js +5 -0
- package/dist/web/providers.d.ts +45 -0
- package/dist/web/providers.js +246 -0
- package/dist/web/read.d.ts +48 -0
- package/dist/web/read.js +227 -0
- package/dist/web/search.d.ts +61 -0
- package/dist/web/search.js +65 -0
- package/dist/web/ssrf.d.ts +32 -0
- package/dist/web/ssrf.js +171 -0
- package/dist/web/types.d.ts +105 -0
- package/dist/web/types.js +16 -0
- package/dist-cjs/grounding/facts.js +7 -0
- package/package.json +5 -1
- package/src/grounding/facts.ts +7 -0
- package/src/web/facts.ts +117 -0
- package/src/web/index.ts +70 -0
- package/src/web/providers.ts +293 -0
- package/src/web/read.ts +284 -0
- package/src/web/search.ts +129 -0
- package/src/web/ssrf.ts +175 -0
- package/src/web/types.ts +89 -0
|
@@ -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.
|
|
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",
|
package/src/grounding/facts.ts
CHANGED
|
@@ -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/web/facts.ts
ADDED
|
@@ -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
|
+
}
|
package/src/web/index.ts
ADDED
|
@@ -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 };
|