@nijaru/pi-search 0.0.1

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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -0
  3. package/THIRD_PARTY_NOTICES.md +31 -0
  4. package/package.json +69 -0
  5. package/src/anthropic.ts +350 -0
  6. package/src/anydoc-worker.mjs +35 -0
  7. package/src/anydoc.ts +161 -0
  8. package/src/brave-answers.ts +396 -0
  9. package/src/brave.ts +441 -0
  10. package/src/codex.ts +314 -0
  11. package/src/contracts.ts +644 -0
  12. package/src/direct-transport.ts +141 -0
  13. package/src/errors.ts +185 -0
  14. package/src/exa.ts +204 -0
  15. package/src/fetch-errors.ts +134 -0
  16. package/src/fetch-extractor-worker.mjs +85 -0
  17. package/src/fetch-tool.ts +166 -0
  18. package/src/fetcher.ts +451 -0
  19. package/src/fixtures/sample.csv +2 -0
  20. package/src/fixtures/sample.docx +0 -0
  21. package/src/fixtures/sample.epub +0 -0
  22. package/src/fixtures/sample.odt +0 -0
  23. package/src/fixtures/sample.pdf +32 -0
  24. package/src/fixtures/sample.pptx +0 -0
  25. package/src/fixtures/sample.rtf +1 -0
  26. package/src/fixtures/sample.xlsx +0 -0
  27. package/src/gemini.ts +298 -0
  28. package/src/grounding.ts +167 -0
  29. package/src/http.ts +78 -0
  30. package/src/index.ts +101 -0
  31. package/src/local-llama-compat.ts +134 -0
  32. package/src/meta.ts +137 -0
  33. package/src/model-selection.ts +134 -0
  34. package/src/openai.ts +850 -0
  35. package/src/parallel-responses.ts +260 -0
  36. package/src/parallel.ts +173 -0
  37. package/src/pdf.ts +153 -0
  38. package/src/provider-availability.ts +35 -0
  39. package/src/provider-http.ts +203 -0
  40. package/src/render-text.ts +8 -0
  41. package/src/research-tool.ts +487 -0
  42. package/src/router.ts +302 -0
  43. package/src/search-cleanup.ts +219 -0
  44. package/src/search-tool.ts +551 -0
  45. package/src/search.ts +420 -0
  46. package/src/ssrf.ts +302 -0
  47. package/src/url-rendering.ts +21 -0
  48. package/src/x.ts +188 -0
  49. package/src/xai.ts +254 -0
  50. package/src/youtube.ts +224 -0
package/src/anydoc.ts ADDED
@@ -0,0 +1,161 @@
1
+ import { Worker } from "node:worker_threads";
2
+ import { SafeFetchError } from "./fetch-errors";
3
+
4
+ export const ANYDOC_FORMATS = ["doc", "docx", "odt", "ppt", "pptx", "rtf", "epub", "pdf", "xlsx", "ods", "odp", "csv"] as const;
5
+ export type AnyDocFormat = (typeof ANYDOC_FORMATS)[number];
6
+ export type AnyDocErrorCode = "unsupported" | "needsOcr" | "malformed" | "encrypted" | "resourceLimit" | "missingPart" | "io";
7
+
8
+ export interface AnyDocResult {
9
+ readonly content: string;
10
+ readonly documentFormat: AnyDocFormat;
11
+ }
12
+
13
+ export class AnyDocConversionError extends Error {
14
+ readonly code: AnyDocErrorCode;
15
+
16
+ constructor(code: AnyDocErrorCode, message: string, cause?: unknown) {
17
+ super(message, { cause });
18
+ this.name = "AnyDocConversionError";
19
+ this.code = code;
20
+ }
21
+ }
22
+
23
+ export type DocumentConverter = (
24
+ bytes: Uint8Array,
25
+ formatHint: AnyDocFormat | undefined,
26
+ signal: AbortSignal,
27
+ ) => Promise<AnyDocResult>;
28
+
29
+ const formatByExtension: Readonly<Record<string, AnyDocFormat>> = {
30
+ doc: "doc",
31
+ docm: "docx",
32
+ docx: "docx",
33
+ epub: "epub",
34
+ pdf: "pdf",
35
+ ods: "ods",
36
+ odp: "odp",
37
+ odt: "odt",
38
+ pot: "ppt",
39
+ ppt: "ppt",
40
+ pptm: "pptx",
41
+ pptx: "pptx",
42
+ pps: "ppt",
43
+ ppsm: "pptx",
44
+ ppsx: "pptx",
45
+ rtf: "rtf",
46
+ csv: "csv",
47
+ xls: "xlsx",
48
+ xlsb: "xlsx",
49
+ xlsm: "xlsx",
50
+ xlsx: "xlsx",
51
+ };
52
+
53
+ const formatByMimeType: Readonly<Record<string, AnyDocFormat>> = {
54
+ "application/msword": "doc",
55
+ "application/rtf": "rtf",
56
+ "application/vnd.ms-excel": "xlsx",
57
+ "application/vnd.ms-powerpoint": "ppt",
58
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
59
+ "application/vnd.openxmlformats-officedocument.presentationml.slideshow": "pptx",
60
+ "application/vnd.openxmlformats-officedocument.presentationml.template": "pptx",
61
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
62
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
63
+ "application/vnd.oasis.opendocument.presentation": "odp",
64
+ "application/vnd.oasis.opendocument.spreadsheet": "ods",
65
+ "application/vnd.oasis.opendocument.text": "odt",
66
+ "application/epub+zip": "epub",
67
+ "application/pdf": "pdf",
68
+ "application/csv": "csv",
69
+ "text/csv": "csv",
70
+ "text/rtf": "rtf",
71
+ };
72
+
73
+ export function anyDocFormatHint(mimeType: string, url: string): AnyDocFormat | undefined {
74
+ const mimeHint = Object.prototype.hasOwnProperty.call(formatByMimeType, mimeType) ? formatByMimeType[mimeType] : undefined;
75
+ if (mimeHint !== undefined) return mimeHint;
76
+ try {
77
+ const pathname = new URL(url).pathname;
78
+ const extension = pathname.slice(pathname.lastIndexOf(".") + 1).toLowerCase();
79
+ return Object.prototype.hasOwnProperty.call(formatByExtension, extension) ? formatByExtension[extension] : undefined;
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ }
84
+
85
+ export function isAnyDocMimeType(mimeType: string): boolean {
86
+ return Object.prototype.hasOwnProperty.call(formatByMimeType, mimeType) || mimeType === "application/zip" || mimeType === "application/octet-stream";
87
+ }
88
+
89
+ export function isAnyDocCandidate(bytes: Uint8Array, mimeType: string, url: string): boolean {
90
+ if (isPreservedTextMimeType(mimeType)) return false;
91
+ if (mimeType.startsWith("text/") && mimeType !== "text/csv" && mimeType !== "text/rtf") {
92
+ return anyDocFormatHint(mimeType, url) === "csv" || hasAnyDocSignature(bytes);
93
+ }
94
+ if (anyDocFormatHint(mimeType, url) !== undefined || isAnyDocMimeType(mimeType)) return true;
95
+ return hasAnyDocSignature(bytes);
96
+ }
97
+
98
+ function isPreservedTextMimeType(mimeType: string): boolean {
99
+ return mimeType === "text/html" || mimeType === "application/xhtml+xml" || mimeType === "text/markdown" || mimeType === "text/x-markdown" || mimeType === "application/json" || mimeType.endsWith("+json") || mimeType === "application/xml" || mimeType.endsWith("+xml") || mimeType === "application/javascript" || mimeType === "application/x-javascript" || mimeType === "application/graphql";
100
+ }
101
+
102
+ function hasAnyDocSignature(bytes: Uint8Array): boolean {
103
+ if (bytes.length >= 4 && bytes[0] === 0x7b && bytes[1] === 0x5c && bytes[2] === 0x72 && bytes[3] === 0x74) return true;
104
+ if (bytes.length >= 8 && bytes[0] === 0xd0 && bytes[1] === 0xcf && bytes[2] === 0x11 && bytes[3] === 0xe0 && bytes[4] === 0xa1 && bytes[5] === 0xb1 && bytes[6] === 0x1a && bytes[7] === 0xe1) return true;
105
+ return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && (bytes[2] === 0x03 || bytes[2] === 0x05 || bytes[2] === 0x07) && (bytes[3] === 0x04 || bytes[3] === 0x06 || bytes[3] === 0x08);
106
+ }
107
+
108
+ export function convertDocumentWithWorker(
109
+ bytes: Uint8Array,
110
+ formatHint: AnyDocFormat | undefined,
111
+ signal: AbortSignal,
112
+ ): Promise<AnyDocResult> {
113
+ if (signal.aborted) return Promise.reject(new SafeFetchError({ kind: "canceled", message: "Fetch canceled" }));
114
+ return new Promise<AnyDocResult>((resolve, reject) => {
115
+ const worker = new Worker(new URL("./anydoc-worker.mjs", import.meta.url));
116
+ let settled = false;
117
+ const cleanup = (): void => {
118
+ signal.removeEventListener("abort", onAbort);
119
+ void worker.terminate();
120
+ };
121
+ const finish = (error?: unknown, result?: AnyDocResult): void => {
122
+ if (settled) return;
123
+ settled = true;
124
+ cleanup();
125
+ if (error === undefined && result !== undefined) resolve(result);
126
+ else reject(error ?? new AnyDocConversionError("malformed", "Local document conversion failed"));
127
+ };
128
+ const onAbort = (): void => finish(new SafeFetchError({ kind: "canceled", message: "Fetch canceled" }));
129
+ signal.addEventListener("abort", onAbort, { once: true });
130
+ worker.once("error", (error) => finish(new AnyDocConversionError("malformed", "Local document converter failed to start", error)));
131
+ worker.once("exit", (code) => {
132
+ if (!settled && code !== 0) finish(new AnyDocConversionError("malformed", `Local document converter stopped with exit code ${code}`));
133
+ });
134
+ worker.once("message", (message: { ok: boolean; result?: AnyDocResult; error?: { code?: string; message?: string } }) => {
135
+ if (!message.ok || message.result === undefined) {
136
+ const code = isAnyDocErrorCode(message.error?.code) ? message.error.code : "malformed";
137
+ finish(new AnyDocConversionError(code, message.error?.message ?? "Local document conversion failed"));
138
+ return;
139
+ }
140
+ if (!isAnyDocFormat(message.result.documentFormat) || typeof message.result.content !== "string") {
141
+ finish(new AnyDocConversionError("malformed", "Local document converter returned an invalid result"));
142
+ return;
143
+ }
144
+ finish(undefined, message.result);
145
+ });
146
+ try {
147
+ const transferable = bytes.slice();
148
+ worker.postMessage({ bytes: transferable, formatHint }, [transferable.buffer]);
149
+ } catch (error) {
150
+ finish(new AnyDocConversionError("malformed", "Local document conversion could not start", error));
151
+ }
152
+ });
153
+ }
154
+
155
+ function isAnyDocFormat(value: unknown): value is AnyDocFormat {
156
+ return typeof value === "string" && (ANYDOC_FORMATS as readonly string[]).includes(value);
157
+ }
158
+
159
+ function isAnyDocErrorCode(value: unknown): value is AnyDocErrorCode {
160
+ return value === "unsupported" || value === "needsOcr" || value === "malformed" || value === "encrypted" || value === "resourceLimit" || value === "missingPart" || value === "io";
161
+ }
@@ -0,0 +1,396 @@
1
+ import type {
2
+ Provider,
3
+ ProviderCapabilities,
4
+ ProviderContext,
5
+ ProviderProfile,
6
+ ProviderUsage,
7
+ SearchOption,
8
+ SearchRequest,
9
+ SearchResponse,
10
+ SearchResult,
11
+ SearchWarning,
12
+ } from "./contracts";
13
+ import { createProviderError } from "./errors";
14
+ import { readBoundedResponseText } from "./http";
15
+ import { httpSource, objectValue, optionalString, requireApiKey, retryAfterMsFromHeaders, type SearchHttpFetch } from "./provider-http";
16
+ import { validateSearchRequest } from "./search";
17
+
18
+ /**
19
+ * Brave AI Answers — opt-in answer-synthesis provider.
20
+ *
21
+ * POST https://api.search.brave.com/res/v1/chat/completions with model
22
+ * "brave". Citations and usage require streaming, so the request runs with
23
+ * `stream: true` and the adapter accumulates the answer text plus inline
24
+ * `<citation>{…}</citation>` tags and a trailing `<usage>{…}</usage>` tag
25
+ * from the SSE delta stream. Billing is metered separately from Brave search
26
+ * ($4 per 1K requests plus $5 per 1M tokens), so this provider registers
27
+ * only under `PI_SEARCH_ENABLE_BRAVE_ANSWERS=1` and is never covered by the
28
+ * free-mode admission gates that govern Brave search. Explicit provider hint
29
+ * only — never automatic routing, never the `native` alias.
30
+ */
31
+
32
+ export const BRAVE_ANSWERS_ENDPOINT = "https://api.search.brave.com/res/v1/chat/completions";
33
+ export const DEFAULT_BRAVE_ANSWERS_BYTES = 4 * 1024 * 1024;
34
+ const MAX_ANSWER_LENGTH = 8_000;
35
+ const MAX_SOURCE_URL_LENGTH = 8_192;
36
+ const MAX_SOURCE_TITLE_LENGTH = 500;
37
+ const MAX_SSE_EVENTS = 5_000;
38
+
39
+ export interface BraveAnswersAdapterOptions {
40
+ readonly apiKey?: string;
41
+ readonly endpoint?: string;
42
+ readonly fetchImpl?: SearchHttpFetch;
43
+ readonly maxResponseBytes?: number;
44
+ }
45
+
46
+ const capabilities: ProviderCapabilities = {
47
+ semantic: true,
48
+ freshness: true,
49
+ excerpts: false,
50
+ nativeGrounding: true,
51
+ searchContextSize: true,
52
+ userLocation: true,
53
+ };
54
+
55
+ const profile: ProviderProfile = {
56
+ auth: "environment",
57
+ costModel: "usage-based",
58
+ };
59
+
60
+ export interface BraveAnswersPlan {
61
+ readonly body: Record<string, unknown>;
62
+ readonly appliedOptions: readonly SearchOption[];
63
+ readonly warnings: readonly SearchWarning[];
64
+ }
65
+
66
+ export function buildBraveAnswersRequest(request: SearchRequest): BraveAnswersPlan {
67
+ const normalized = validateSearchRequest(request);
68
+ const hardConstraints: SearchOption[] = [];
69
+ if (normalized.domains?.include?.length || normalized.domains?.exclude?.length) hardConstraints.push("domains");
70
+ if (normalized.dateRange !== undefined) hardConstraints.push("dateRange");
71
+ if (normalized.social !== undefined) hardConstraints.push("social");
72
+ if (normalized.returnTokenBudget !== undefined) hardConstraints.push("returnTokenBudget");
73
+ if (normalized.externalWebAccess !== undefined) hardConstraints.push("externalWebAccess");
74
+ if (normalized.searchContentTypes !== undefined) hardConstraints.push("searchContentTypes");
75
+ if (normalized.imageSettings !== undefined) hardConstraints.push("imageSettings");
76
+ if (hardConstraints.length > 0) {
77
+ throw createProviderError({
78
+ provider: "brave-answers",
79
+ kind: "unsupported",
80
+ message: `Brave Answers does not expose ${hardConstraints.join(", ")} controls`,
81
+ retryable: false,
82
+ });
83
+ }
84
+ const warnings: SearchWarning[] = [];
85
+ if (normalized.mode === "keyword") warnings.push({ code: "unsupported-option", option: "mode", message: "Brave Answers researches the question semantically; keyword-only ranking is not guaranteed" });
86
+ if (normalized.mode === "fresh") warnings.push({ code: "unsupported-option", option: "mode", message: "Brave Answers uses current sources but does not guarantee a freshness-only ranking" });
87
+ if (normalized.maxResults !== undefined) warnings.push({ code: "unsupported-option", option: "maxResults", message: "Brave Answers returns the sources its answer cited; the result count is not configurable" });
88
+ const webSearchOptions = {
89
+ search_context_size: normalized.searchContextSize ?? "medium",
90
+ ...(normalized.userLocation === undefined ? {} : {
91
+ user_location: {
92
+ type: normalized.userLocation.type,
93
+ ...(normalized.userLocation.country === undefined ? {} : { country: normalized.userLocation.country }),
94
+ ...(normalized.userLocation.region === undefined ? {} : { region: normalized.userLocation.region }),
95
+ ...(normalized.userLocation.city === undefined ? {} : { city: normalized.userLocation.city }),
96
+ ...(normalized.userLocation.timezone === undefined ? {} : { timezone: normalized.userLocation.timezone }),
97
+ },
98
+ }),
99
+ };
100
+ const appliedOptions: SearchOption[] = ["mode", ...(normalized.searchContextSize === undefined ? [] : ["searchContextSize" as const]), ...(normalized.userLocation === undefined ? [] : ["userLocation" as const])];
101
+ return {
102
+ body: {
103
+ model: "brave",
104
+ stream: true,
105
+ enable_citations: true,
106
+ messages: [{ role: "user", content: normalized.query }],
107
+ web_search_options: webSearchOptions,
108
+ },
109
+ appliedOptions,
110
+ warnings,
111
+ };
112
+ }
113
+
114
+ function malformed(message: string): never {
115
+ throw createProviderError({ provider: "brave-answers", kind: "malformed", message: `Brave Answers returned a malformed response (${message})`, retryable: false });
116
+ }
117
+
118
+ /**
119
+ * Parse a Brave `ErrorResponse` envelope and fold its `detail` and `code`
120
+ * into the provider-error message. A plan-entitlement failure (HTTP 400,
121
+ * code `OPTION_NOT_IN_PLAN`) reads as "Brave plan does not include this
122
+ * option (…)" instead of an opaque status code.
123
+ */
124
+ async function braveErrorDetail(response: Response, provider: "brave-answers", status: number, signal: AbortSignal, maxBytes: number, fetchImpl: SearchHttpFetch): Promise<string> {
125
+ let envelopeText = "";
126
+ try {
127
+ envelopeText = await readBoundedResponseText(response, Math.min(maxBytes, 16_384), signal);
128
+ } catch {
129
+ return "";
130
+ }
131
+ try {
132
+ const parsed = JSON.parse(envelopeText) as unknown;
133
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return "";
134
+ const error = (parsed as Record<string, unknown>).error;
135
+ if (error === null || typeof error !== "object" || Array.isArray(error)) return "";
136
+ const record = error as Record<string, unknown>;
137
+ const detail = typeof record.detail === "string" ? record.detail.trim() : "";
138
+ const code = typeof record.code === "string" ? record.code.trim() : "";
139
+ if (detail.length === 0 && code.length === 0) return "";
140
+ return code.length > 0 && detail.length > 0 ? `${code}: ${detail}` : (code.length > 0 ? code : detail);
141
+ } catch {
142
+ return "";
143
+ }
144
+ }
145
+
146
+ interface CitationCandidate {
147
+ readonly url: string;
148
+ readonly title?: string;
149
+ readonly startIndex?: number;
150
+ readonly endIndex?: number;
151
+ readonly snippet?: string;
152
+ }
153
+
154
+ /** Parse one SSE chat-completions chunk body for delta text. */
155
+ function deltaTextFromEvent(payload: unknown): string {
156
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return "";
157
+ const record = payload as Record<string, unknown>;
158
+ const choices = Array.isArray(record.choices) ? record.choices : [];
159
+ let text = "";
160
+ for (const choice of choices) {
161
+ if (typeof choice !== "object" || choice === null || Array.isArray(choice)) continue;
162
+ const choiceRecord = choice as Record<string, unknown>;
163
+ const delta = choiceRecord.delta;
164
+ if (typeof delta !== "object" || delta === null || Array.isArray(delta)) continue;
165
+ const content = (delta as Record<string, unknown>).content;
166
+ if (typeof content === "string") text += content;
167
+ }
168
+ return text;
169
+ }
170
+
171
+ /**
172
+ * Accumulate answer text, citation tags, and usage from the streamed tags.
173
+ * Citation and usage tags may split across SSE deltas, so tag fragments are
174
+ * buffered until their closing marker arrives.
175
+ */
176
+ export function extractTaggedAnswer(streamedText: string): {
177
+ readonly text: string;
178
+ readonly citations: readonly CitationCandidate[];
179
+ readonly usage?: Record<string, unknown>;
180
+ } {
181
+ const citations: CitationCandidate[] = [];
182
+ let usage: Record<string, unknown> | undefined;
183
+ let text = "";
184
+ let remainder = streamedText;
185
+ while (remainder.length > 0) {
186
+ const citationStart = remainder.indexOf("<citation>");
187
+ const usageStart = remainder.indexOf("<usage>");
188
+ if (citationStart === -1 && usageStart === -1) {
189
+ text += remainder;
190
+ break;
191
+ }
192
+ const useCitation = citationStart !== -1 && (usageStart === -1 || citationStart < usageStart);
193
+ const tagStart = useCitation ? citationStart : usageStart;
194
+ const tagName = useCitation ? "citation" : "usage";
195
+ const closer = `</${tagName}>`;
196
+ const closeIndex = remainder.indexOf(closer, tagStart);
197
+ if (closeIndex === -1) {
198
+ // Unterminated tag: keep as text; a valid stream always closes tags.
199
+ text += remainder;
200
+ break;
201
+ }
202
+ text += remainder.slice(0, tagStart);
203
+ const inner = remainder.slice(tagStart + tagName.length + 2, closeIndex).trim();
204
+ try {
205
+ const parsed = JSON.parse(inner) as unknown;
206
+ if (useCitation) {
207
+ const record = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
208
+ const parsedUrl = httpSource(record.url, "brave-answers");
209
+ if (parsedUrl !== undefined && parsedUrl.url.length <= MAX_SOURCE_URL_LENGTH) {
210
+ const title = optionalString(record.favicon, MAX_SOURCE_TITLE_LENGTH) ?? optionalString(record.title, MAX_SOURCE_TITLE_LENGTH);
211
+ citations.push({
212
+ url: parsedUrl.url,
213
+ ...(title === undefined ? {} : { title }),
214
+ ...(typeof record.start_index === "number" && Number.isFinite(record.start_index) && record.start_index >= 0 ? { startIndex: Math.round(record.start_index) } : {}),
215
+ ...(typeof record.end_index === "number" && Number.isFinite(record.end_index) && record.end_index >= 0 ? { endIndex: Math.round(record.end_index) } : {}),
216
+ ...(optionalString(record.snippet, 4_000) === undefined ? {} : { snippet: optionalString(record.snippet, 4_000) }),
217
+ });
218
+ }
219
+ } else if (usage === undefined) {
220
+ usage = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : undefined;
221
+ }
222
+ } catch {
223
+ // Malformed tag JSON: drop the tag, keep the surrounding text.
224
+ }
225
+ remainder = remainder.slice(closeIndex + closer.length);
226
+ }
227
+ return { text: text.trim(), citations, usage };
228
+ }
229
+
230
+ function usageFromTagged(usage: Record<string, unknown> | undefined): ProviderUsage | undefined {
231
+ if (usage === undefined) return undefined;
232
+ const number = (key: string): number | undefined => typeof usage[key] === "number" && Number.isFinite(usage[key] as number) && (usage[key] as number) >= 0 ? usage[key] as number : undefined;
233
+ const inputTokens = number("X-Request-Tokens-In");
234
+ const outputTokens = number("X-Request-Tokens-Out");
235
+ const totalCost = number("X-Request-Total-Cost");
236
+ const searchQueries = number("X-Request-Queries");
237
+ const parts: { inputTokens?: number; outputTokens?: number; searchQueries?: number; costUsd?: number } = {};
238
+ if (inputTokens !== undefined) parts.inputTokens = inputTokens;
239
+ if (outputTokens !== undefined) parts.outputTokens = outputTokens;
240
+ if (searchQueries !== undefined) parts.searchQueries = searchQueries;
241
+ if (totalCost !== undefined) parts.costUsd = totalCost;
242
+ if (Object.keys(parts).length === 0) return undefined;
243
+ return parts;
244
+ }
245
+
246
+ /** Read a bounded SSE stream and collect every `data:` JSON chunk. */
247
+ async function readSseChunks(response: Response, provider: "brave-answers", signal: AbortSignal, maxBytes: number): Promise<readonly Record<string, unknown>[]> {
248
+ const text = await readBoundedResponseText(response, maxBytes, signal);
249
+ const events: Record<string, unknown>[] = [];
250
+ let dataLines: string[] = [];
251
+ const flush = (): void => {
252
+ if (dataLines.length === 0) return;
253
+ const data = dataLines.join("\n").trim();
254
+ dataLines = [];
255
+ if (data.length === 0 || data === "[DONE]") return;
256
+ try {
257
+ const parsed = JSON.parse(data) as unknown;
258
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) events.push(parsed as Record<string, unknown>);
259
+ } catch {
260
+ // Ignore non-JSON keepalive lines.
261
+ }
262
+ };
263
+ for (const rawLine of text.split(/\r?\n/)) {
264
+ if (events.length >= MAX_SSE_EVENTS) break;
265
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
266
+ if (line.length === 0) {
267
+ flush();
268
+ continue;
269
+ }
270
+ if (line.startsWith(":")) continue;
271
+ if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
272
+ }
273
+ flush();
274
+ return events;
275
+ }
276
+
277
+ export class BraveAnswersProvider implements Provider {
278
+ readonly id = "brave-answers" as const;
279
+ readonly capabilities = capabilities;
280
+ readonly profile = profile;
281
+ private readonly apiKey?: string;
282
+ private readonly endpoint: string;
283
+ private readonly fetchImpl: SearchHttpFetch;
284
+ private readonly maxResponseBytes: number;
285
+
286
+ constructor(options: BraveAnswersAdapterOptions = {}) {
287
+ this.apiKey = options.apiKey;
288
+ this.endpoint = options.endpoint ?? BRAVE_ANSWERS_ENDPOINT;
289
+ this.fetchImpl = options.fetchImpl ?? (fetch as SearchHttpFetch);
290
+ this.maxResponseBytes = options.maxResponseBytes ?? DEFAULT_BRAVE_ANSWERS_BYTES;
291
+ if (!Number.isInteger(this.maxResponseBytes) || this.maxResponseBytes < 1) throw new Error("Brave Answers maxResponseBytes must be a positive integer");
292
+ }
293
+
294
+ async search(request: SearchRequest, signal: AbortSignal, _context: ProviderContext): Promise<SearchResponse> {
295
+ const normalized = validateSearchRequest(request);
296
+ const plan = buildBraveAnswersRequest(normalized);
297
+ // Validate before dispatch so a missing key is an auth error, not network noise.
298
+ requireApiKey(this.id, this.apiKey);
299
+ if (signal.aborted) {
300
+ throw createProviderError({ provider: this.id, kind: "canceled", message: "Search canceled", retryable: false });
301
+ }
302
+ let response: Response;
303
+ try {
304
+ response = await this.fetchImpl(this.endpoint, {
305
+ method: "POST",
306
+ headers: {
307
+ "x-subscription-token": requireApiKey(this.id, this.apiKey),
308
+ accept: "text/event-stream",
309
+ "content-type": "application/json",
310
+ },
311
+ body: JSON.stringify(plan.body),
312
+ signal,
313
+ });
314
+ } catch (error) {
315
+ if (signal.aborted) {
316
+ throw createProviderError({ provider: this.id, kind: "canceled", message: "Search canceled", retryable: false, cause: error });
317
+ }
318
+ throw createProviderError({ provider: this.id, kind: "network", message: `${this.id} network request failed`, retryable: true, cause: error });
319
+ }
320
+ const requestId = response.headers.get("x-request-id") ?? response.headers.get("request-id") ?? undefined;
321
+ const statusMessage = async (): Promise<string> => {
322
+ const detail = await braveErrorDetail(response, this.id, response.status, signal, this.maxResponseBytes, this.fetchImpl);
323
+ return detail.length > 0 ? `${this.id} failed with HTTP ${response.status} (${detail})` : `${this.id} failed with HTTP ${response.status}`;
324
+ };
325
+ if (response.status === 401 || response.status === 403) {
326
+ const message = await statusMessage();
327
+ throw createProviderError({ provider: this.id, kind: "auth", message, status: response.status, retryable: false, ...(requestId === undefined ? {} : { requestId }) });
328
+ }
329
+ if (response.status === 429) {
330
+ const message = await statusMessage();
331
+ const retryAfterMs = retryAfterMsFromHeaders(response.headers);
332
+ throw createProviderError({ provider: this.id, kind: "rateLimit", message, status: response.status, retryable: true, ...(requestId === undefined ? {} : { requestId }), ...(retryAfterMs === undefined ? {} : { retryAfterMs }) });
333
+ }
334
+ if (response.status < 200 || response.status >= 300) {
335
+ const message = await statusMessage();
336
+ throw createProviderError({
337
+ provider: this.id,
338
+ kind: response.status === 400 || response.status === 422 ? "badRequest" : "http",
339
+ message,
340
+ status: response.status,
341
+ retryable: response.status === 408 || response.status === 425 || response.status >= 500,
342
+ ...(requestId === undefined ? {} : { requestId }),
343
+ });
344
+ }
345
+ const chunks = await readSseChunks(response, this.id, signal, this.maxResponseBytes);
346
+ const streamedText = chunks.map((chunk) => deltaTextFromEvent(chunk)).join("");
347
+ const { text, citations, usage: usageTag } = extractTaggedAnswer(streamedText);
348
+ if (text.length === 0) malformed("stream contained no answer text");
349
+ const limitedText = text.slice(0, MAX_ANSWER_LENGTH);
350
+
351
+ const byUrl = new Map<string, CitationCandidate>();
352
+ for (const citation of citations) {
353
+ if (!byUrl.has(citation.url)) byUrl.set(citation.url, citation);
354
+ }
355
+ const results: SearchResult[] = [];
356
+ for (const citation of byUrl.values()) {
357
+ const parsed = httpSource(citation.url, "brave-answers");
358
+ if (parsed === undefined) continue;
359
+ results.push({
360
+ url: parsed.url,
361
+ ...(citation.title === undefined ? {} : { title: citation.title }),
362
+ domain: parsed.domain,
363
+ ...(citation.snippet === undefined ? {} : { excerpt: citation.snippet }),
364
+ provider: "brave-answers",
365
+ searchQuery: normalized.query,
366
+ });
367
+ }
368
+ if (results.length === 0) malformed("answer cites no HTTP sources");
369
+ const answer = {
370
+ text: limitedText,
371
+ contentTrust: "untrusted" as const,
372
+ provider: "brave-answers" as const,
373
+ citations: [...byUrl.values()].slice(0, normalized.maxResults ?? 10).map((citation) => ({
374
+ url: citation.url,
375
+ ...(citation.title === undefined ? {} : { title: citation.title }),
376
+ ...(citation.startIndex === undefined ? {} : { startIndex: citation.startIndex }),
377
+ ...(citation.endIndex === undefined ? {} : { endIndex: citation.endIndex }),
378
+ })),
379
+ };
380
+ const usage = usageFromTagged(usageTag);
381
+ return {
382
+ query: normalized.query,
383
+ results,
384
+ ...(normalized.answerMode === "evidence" ? {} : { answer }),
385
+ provider: "brave-answers",
386
+ appliedOptions: plan.appliedOptions,
387
+ warnings: plan.warnings,
388
+ ...(requestId === undefined ? {} : { requestId }),
389
+ ...(usage === undefined ? {} : { usage }),
390
+ };
391
+ }
392
+ }
393
+
394
+ export function createBraveAnswersProvider(options: BraveAnswersAdapterOptions): BraveAnswersProvider {
395
+ return new BraveAnswersProvider(options);
396
+ }