@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
@@ -0,0 +1,260 @@
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 { httpSource, objectValue, optionalString, postJson, requireApiKey, type SearchHttpFetch } from "./provider-http";
15
+ import { validateSearchRequest } from "./search";
16
+
17
+ /**
18
+ * Parallel Responses API — opt-in answer-synthesis provider.
19
+ *
20
+ * POST https://api.parallel.ai/v1/responses with model "parallel"; the
21
+ * reasoning tier (low/medium/high, default medium) is selected through
22
+ * `reasoning.effort` and grounds the answer in live web research
23
+ * automatically. Citations arrive as `url_citation` annotations carrying
24
+ * start/end indexes over the answer text. Registered only when
25
+ * `PI_SEARCH_ENABLE_PARALLEL_RESPONSES=1`; explicit provider hint only —
26
+ * never automatic routing, never the `native` alias.
27
+ */
28
+
29
+ export const PARALLEL_RESPONSES_ENDPOINT = "https://api.parallel.ai/v1/responses";
30
+ export const DEFAULT_PARALLEL_RESPONSES_BYTES = 4 * 1024 * 1024;
31
+ const MAX_ANSWER_LENGTH = 8_000;
32
+ const MAX_SOURCE_URL_LENGTH = 8_192;
33
+ const MAX_SOURCE_TITLE_LENGTH = 500;
34
+ const EFFORTS = ["low", "medium", "high"] as const;
35
+ export type ParallelResponsesEffort = (typeof EFFORTS)[number];
36
+
37
+ export interface ParallelResponsesAdapterOptions {
38
+ readonly apiKey?: string;
39
+ readonly endpoint?: string;
40
+ readonly fetchImpl?: SearchHttpFetch;
41
+ readonly maxResponseBytes?: number;
42
+ }
43
+
44
+ const capabilities: ProviderCapabilities = {
45
+ semantic: true,
46
+ freshness: true,
47
+ excerpts: false,
48
+ nativeGrounding: true,
49
+ searchContextSize: true,
50
+ };
51
+
52
+ const profile: ProviderProfile = {
53
+ auth: "environment",
54
+ costModel: "usage-based",
55
+ };
56
+
57
+ export interface ParallelResponsesPlan {
58
+ readonly body: Record<string, unknown>;
59
+ readonly appliedOptions: readonly SearchOption[];
60
+ readonly warnings: readonly SearchWarning[];
61
+ }
62
+
63
+ /** Map the contract's provider-native context size onto the reasoning tier. */
64
+ export function effortFor(request: SearchRequest): ParallelResponsesEffort {
65
+ return request.searchContextSize ?? "medium";
66
+ }
67
+
68
+ export function buildParallelResponsesRequest(request: SearchRequest): ParallelResponsesPlan {
69
+ const normalized = validateSearchRequest(request);
70
+ const hardConstraints: SearchOption[] = [];
71
+ if (normalized.domains?.include?.length || normalized.domains?.exclude?.length) hardConstraints.push("domains");
72
+ if (normalized.dateRange !== undefined) hardConstraints.push("dateRange");
73
+ if (normalized.social !== undefined) hardConstraints.push("social");
74
+ if (normalized.returnTokenBudget !== undefined) hardConstraints.push("returnTokenBudget");
75
+ if (normalized.externalWebAccess !== undefined) hardConstraints.push("externalWebAccess");
76
+ if (normalized.userLocation !== undefined) hardConstraints.push("userLocation");
77
+ if (normalized.searchContentTypes !== undefined) hardConstraints.push("searchContentTypes");
78
+ if (normalized.imageSettings !== undefined) hardConstraints.push("imageSettings");
79
+ if (hardConstraints.length > 0) {
80
+ throw createProviderError({
81
+ provider: "parallel-responses",
82
+ kind: "unsupported",
83
+ message: `Parallel Responses does not expose ${hardConstraints.join(", ")} controls`,
84
+ retryable: false,
85
+ });
86
+ }
87
+ const warnings: SearchWarning[] = [];
88
+ if (normalized.mode === "keyword") warnings.push({ code: "unsupported-option", option: "mode", message: "Parallel Responses researches the question semantically; keyword-only ranking is not guaranteed" });
89
+ if (normalized.mode === "fresh") warnings.push({ code: "unsupported-option", option: "mode", message: "Parallel Responses uses current sources but does not guarantee a freshness-only ranking" });
90
+ if (normalized.maxResults !== undefined) warnings.push({ code: "unsupported-option", option: "maxResults", message: "Parallel Responses returns the sources its research cited; the result count is not configurable" });
91
+ const appliedOptions: SearchOption[] = ["mode", ...(normalized.searchContextSize === undefined ? [] : ["searchContextSize" as const])];
92
+ return {
93
+ body: {
94
+ model: "parallel",
95
+ input: normalized.query,
96
+ reasoning: { effort: effortFor(normalized) },
97
+ },
98
+ appliedOptions,
99
+ warnings,
100
+ };
101
+ }
102
+
103
+ function malformed(message: string): never {
104
+ throw createProviderError({ provider: "parallel-responses", kind: "malformed", message: `Parallel Responses returned a malformed response (${message})`, retryable: false });
105
+ }
106
+
107
+ interface CitationCandidate {
108
+ readonly url: string;
109
+ readonly title?: string;
110
+ readonly startIndex?: number;
111
+ readonly endIndex?: number;
112
+ }
113
+
114
+ /** Collect message output text and its url_citation annotations. */
115
+ function answerFromPayload(payload: unknown): { text: string; citations: CitationCandidate[] } {
116
+ const root = objectValue(payload, "response", "parallel-responses");
117
+ const output = Array.isArray(root.output) ? root.output : malformed("output is not an array");
118
+ let text: string | undefined;
119
+ const citations: CitationCandidate[] = [];
120
+ for (const item of output) {
121
+ if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
122
+ const record = item as Record<string, unknown>;
123
+ if (record.type !== "message") continue;
124
+ const content = Array.isArray(record.content) ? record.content : [];
125
+ for (const part of content) {
126
+ if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
127
+ const contentPart = part as Record<string, unknown>;
128
+ if (contentPart.type !== "output_text" || typeof contentPart.text !== "string") continue;
129
+ text = text === undefined ? contentPart.text : `${text}\n${contentPart.text}`;
130
+ for (const annotation of Array.isArray(contentPart.annotations) ? contentPart.annotations : []) {
131
+ if (typeof annotation !== "object" || annotation === null || Array.isArray(annotation)) continue;
132
+ const record2 = annotation as Record<string, unknown>;
133
+ if (record2.type !== "url_citation") continue;
134
+ const parsed = httpSource(record2.url, "parallel-responses");
135
+ if (parsed === undefined || parsed.url.length > MAX_SOURCE_URL_LENGTH) continue;
136
+ citations.push({
137
+ url: parsed.url,
138
+ ...(optionalString(record2.title, MAX_SOURCE_TITLE_LENGTH) === undefined ? {} : { title: optionalString(record2.title, MAX_SOURCE_TITLE_LENGTH) }),
139
+ ...(typeof record2.start_index === "number" && Number.isFinite(record2.start_index) && record2.start_index >= 0 ? { startIndex: Math.round(record2.start_index) } : {}),
140
+ ...(typeof record2.end_index === "number" && Number.isFinite(record2.end_index) && record2.end_index >= 0 ? { endIndex: Math.round(record2.end_index) } : {}),
141
+ });
142
+ }
143
+ }
144
+ }
145
+ if (text === undefined) malformed("no message output text");
146
+ return { text, citations };
147
+ }
148
+
149
+ function usageFromPayload(root: Record<string, unknown>): ProviderUsage | undefined {
150
+ const value = root.usage;
151
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
152
+ const usage = value as Record<string, unknown>;
153
+ 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;
154
+ const inputTokens = number("input_tokens");
155
+ const outputTokens = number("output_tokens");
156
+ const totalTokens = number("total_tokens");
157
+ if (inputTokens === undefined && outputTokens === undefined && totalTokens === undefined) return undefined;
158
+ return {
159
+ ...(inputTokens === undefined ? {} : { inputTokens }),
160
+ ...(outputTokens === undefined ? {} : { outputTokens }),
161
+ ...(totalTokens === undefined ? {} : { totalTokens, billedUnits: totalTokens, billedUnit: "tokens" }),
162
+ };
163
+ }
164
+
165
+ export function normalizeParallelResponsesPayload(payload: unknown, request: SearchRequest): SearchResponse {
166
+ const normalized = validateSearchRequest(request);
167
+ const root = objectValue(payload, "response", "parallel-responses");
168
+ const { text, citations: rawCitations } = answerFromPayload(root);
169
+ if (text.trim().length === 0) malformed("answer text is empty");
170
+ const limitedText = text.slice(0, MAX_ANSWER_LENGTH);
171
+
172
+ // Deduplicate citations by URL, preserving first-seen order and indexes.
173
+ const byUrl = new Map<string, CitationCandidate>();
174
+ for (const citation of rawCitations) {
175
+ if (!byUrl.has(citation.url)) byUrl.set(citation.url, citation);
176
+ }
177
+ const results: SearchResult[] = [];
178
+ for (const citation of byUrl.values()) {
179
+ const parsed = httpSource(citation.url, "parallel-responses");
180
+ if (parsed === undefined) continue;
181
+ results.push({
182
+ url: parsed.url,
183
+ ...(citation.title === undefined ? {} : { title: citation.title }),
184
+ domain: parsed.domain,
185
+ provider: "parallel-responses",
186
+ searchQuery: normalized.query,
187
+ });
188
+ }
189
+ if (results.length === 0) malformed("answer cites no HTTP sources");
190
+ const answer = {
191
+ text: limitedText,
192
+ contentTrust: "untrusted" as const,
193
+ provider: "parallel-responses" as const,
194
+ citations: [...byUrl.values()].slice(0, normalized.maxResults ?? 10).map((citation) => ({
195
+ url: citation.url,
196
+ ...(citation.title === undefined ? {} : { title: citation.title }),
197
+ ...(citation.startIndex === undefined ? {} : { startIndex: citation.startIndex }),
198
+ ...(citation.endIndex === undefined ? {} : { endIndex: citation.endIndex }),
199
+ })),
200
+ };
201
+ return {
202
+ query: normalized.query,
203
+ results,
204
+ ...(normalized.answerMode === "evidence" ? {} : { answer }),
205
+ provider: "parallel-responses",
206
+ appliedOptions: [],
207
+ warnings: [],
208
+ ...(usageFromPayload(root) === undefined ? {} : { usage: usageFromPayload(root) }),
209
+ };
210
+ }
211
+
212
+ /** Response id from the payload, used when the transport supplied none. */
213
+ export function parallelResponsesRequestId(payload: unknown): string | undefined {
214
+ const root = objectValue(payload, "response", "parallel-responses");
215
+ return optionalString(root.id, 500);
216
+ }
217
+
218
+ export class ParallelResponsesProvider implements Provider {
219
+ readonly id = "parallel-responses" as const;
220
+ readonly capabilities = capabilities;
221
+ readonly profile = profile;
222
+ private readonly apiKey?: string;
223
+ private readonly endpoint: string;
224
+ private readonly fetchImpl: SearchHttpFetch;
225
+ private readonly maxResponseBytes: number;
226
+
227
+ constructor(options: ParallelResponsesAdapterOptions = {}) {
228
+ this.apiKey = options.apiKey;
229
+ this.endpoint = options.endpoint ?? PARALLEL_RESPONSES_ENDPOINT;
230
+ this.fetchImpl = options.fetchImpl ?? (fetch as SearchHttpFetch);
231
+ this.maxResponseBytes = options.maxResponseBytes ?? DEFAULT_PARALLEL_RESPONSES_BYTES;
232
+ if (!Number.isInteger(this.maxResponseBytes) || this.maxResponseBytes < 1) throw new Error("Parallel Responses maxResponseBytes must be a positive integer");
233
+ }
234
+
235
+ async search(request: SearchRequest, signal: AbortSignal, _context: ProviderContext): Promise<SearchResponse> {
236
+ const normalized = validateSearchRequest(request);
237
+ const plan = buildParallelResponsesRequest(normalized);
238
+ const result = await postJson({
239
+ provider: this.id,
240
+ url: this.endpoint,
241
+ headers: { authorization: `Bearer ${requireApiKey(this.id, this.apiKey)}` },
242
+ body: plan.body,
243
+ signal,
244
+ fetchImpl: this.fetchImpl,
245
+ maxResponseBytes: this.maxResponseBytes,
246
+ });
247
+ const response = normalizeParallelResponsesPayload(result.payload, normalized);
248
+ const requestId = result.requestId ?? parallelResponsesRequestId(result.payload);
249
+ return {
250
+ ...response,
251
+ ...(requestId === undefined ? {} : { requestId }),
252
+ appliedOptions: plan.appliedOptions,
253
+ warnings: [...plan.warnings, ...response.warnings],
254
+ };
255
+ }
256
+ }
257
+
258
+ export function createParallelResponsesProvider(options: ParallelResponsesAdapterOptions): ParallelResponsesProvider {
259
+ return new ParallelResponsesProvider(options);
260
+ }
@@ -0,0 +1,173 @@
1
+ import type {
2
+ Provider,
3
+ ProviderCapabilities,
4
+ ProviderContext,
5
+ ProviderProfile,
6
+ ProviderRateLimitInfo,
7
+ SearchOption,
8
+ SearchRequest,
9
+ SearchResponse,
10
+ SearchResult,
11
+ SearchWarning,
12
+ } from "./contracts";
13
+ import { createProviderError } from "./errors";
14
+ import { httpSource, optionalString, optionalTimestamp, objectValue, postJson, requireApiKey, type SearchHttpFetch } from "./provider-http";
15
+ import { validateSearchRequest } from "./search";
16
+
17
+ export const PARALLEL_SEARCH_ENDPOINT = "https://api.parallel.ai/v1/search";
18
+ export const DEFAULT_PARALLEL_RESPONSE_BYTES = 4 * 1024 * 1024;
19
+
20
+ export interface ParallelAdapterOptions {
21
+ readonly apiKey?: string;
22
+ readonly endpoint?: string;
23
+ readonly fetchImpl?: SearchHttpFetch;
24
+ readonly maxResponseBytes?: number;
25
+ }
26
+
27
+ const capabilities: ProviderCapabilities = {
28
+ keyword: true,
29
+ freshness: true,
30
+ semantic: true,
31
+ excerpts: true,
32
+ domainFilter: true,
33
+ dateFilter: true,
34
+ };
35
+
36
+ const profile: ProviderProfile = {
37
+ auth: "environment",
38
+ costModel: "usage-based",
39
+ };
40
+
41
+ export interface ParallelRequestPlan {
42
+ readonly body: Record<string, unknown>;
43
+ readonly appliedOptions: readonly SearchOption[];
44
+ readonly warnings: readonly SearchWarning[];
45
+ }
46
+
47
+ export function buildParallelRequest(request: SearchRequest): ParallelRequestPlan {
48
+ const normalized = validateSearchRequest(request);
49
+ if (normalized.social !== undefined) {
50
+ throw createProviderError({ provider: "parallel", kind: "unsupported", message: "Parallel Search does not expose dedicated social/X constraints", retryable: false });
51
+ }
52
+ if (normalized.dateRange?.to !== undefined) {
53
+ throw createProviderError({ provider: "parallel", kind: "unsupported", message: "Parallel Search supports a date-range start but not an upper date bound", retryable: false });
54
+ }
55
+ if (normalized.domains?.include?.length && normalized.domains?.exclude?.length) {
56
+ throw createProviderError({ provider: "parallel", kind: "unsupported", message: "Parallel Search accepts either included or excluded domains, not both", retryable: false });
57
+ }
58
+ const warnings: SearchWarning[] = [];
59
+ if (normalized.mode === "fresh") {
60
+ warnings.push({ code: "unsupported-option", option: "mode", message: "Parallel Search can prioritize current sources but this request does not impose a date cutoff" });
61
+ }
62
+ const maxResults = normalized.maxResults ?? 10;
63
+ const sourcePolicy = {
64
+ ...(normalized.domains?.include === undefined ? {} : { include_domains: [...normalized.domains.include] }),
65
+ ...(normalized.domains?.exclude === undefined ? {} : { exclude_domains: [...normalized.domains.exclude] }),
66
+ ...(normalized.dateRange?.from === undefined ? {} : { after_date: normalized.dateRange.from.slice(0, 10) }),
67
+ };
68
+ const appliedOptions: SearchOption[] = ["maxResults", "mode"];
69
+ if (normalized.domains !== undefined && (normalized.domains.include !== undefined || normalized.domains.exclude !== undefined)) appliedOptions.push("domains");
70
+ if (normalized.dateRange !== undefined && normalized.dateRange.from !== undefined) appliedOptions.push("dateRange");
71
+ // Mode mapping per docs.parallel.ai/search/modes (checked 2026-09-06):
72
+ // fast is the recommended high-quality default for agents ($1/1K, ~700ms);
73
+ // basic returns extended snippets for keyword/reference probes ($5/1K);
74
+ // advanced is the multi-hop depth pipeline for recency-sensitive work
75
+ // ($5/1K, ~3s). turbo is a latency tier with no quality advantage and
76
+ // partial language coverage, so it is deliberately not used.
77
+ const mode = normalized.mode === "keyword" ? "basic" : normalized.mode === "fresh" ? "advanced" : "fast";
78
+ return {
79
+ body: {
80
+ objective: normalized.query,
81
+ search_queries: [normalized.query],
82
+ mode,
83
+ advanced_settings: {
84
+ max_results: maxResults,
85
+ ...(Object.keys(sourcePolicy).length === 0 ? {} : { source_policy: sourcePolicy }),
86
+ },
87
+ max_chars_total: Math.min(24_000, Math.max(2_000, maxResults * 2_000)),
88
+ },
89
+ appliedOptions,
90
+ warnings,
91
+ };
92
+ }
93
+
94
+ function malformed(message: string): never {
95
+ throw createProviderError({ provider: "parallel", kind: "malformed", message: `Parallel returned a malformed response (${message})`, retryable: false });
96
+ }
97
+
98
+ function normalizeParallelResponse(payload: unknown, request: SearchRequest, metadata: { readonly requestId?: string; readonly rateLimits?: ProviderRateLimitInfo } = {}): SearchResponse {
99
+ const normalized = validateSearchRequest(request);
100
+ const root = objectValue(payload, "response", "parallel");
101
+ if (!Array.isArray(root.results)) return malformed("results is not an array");
102
+ const results: SearchResult[] = [];
103
+ let discarded = 0;
104
+ for (const value of root.results) {
105
+ const record = objectValue(value, "results[]", "parallel");
106
+ const parsed = httpSource(record.url, "parallel");
107
+ if (parsed === undefined) {
108
+ discarded += 1;
109
+ continue;
110
+ }
111
+ const excerpts = Array.isArray(record.excerpts)
112
+ ? record.excerpts.filter((item): item is string => typeof item === "string").slice(0, 3).join("\n")
113
+ : undefined;
114
+ results.push({
115
+ url: parsed.url,
116
+ title: optionalString(record.title, 500),
117
+ domain: parsed.domain,
118
+ publishedAt: optionalTimestamp(record.publish_date),
119
+ ...(excerpts === undefined ? {} : { excerpt: excerpts.slice(0, 4_000) }),
120
+ provider: "parallel",
121
+ searchQuery: normalized.query,
122
+ });
123
+ }
124
+ if (discarded > 0 && results.length === 0) return malformed("results contained no parseable HTTP URLs");
125
+ return {
126
+ query: normalized.query,
127
+ results: results.slice(0, normalized.maxResults ?? 10),
128
+ provider: "parallel",
129
+ appliedOptions: [],
130
+ warnings: discarded > 0 ? [{ code: "partial-results", message: `Parallel discarded ${discarded} malformed result entr${discarded === 1 ? "y" : "ies"}` }] : [],
131
+ ...(metadata.requestId === undefined ? {} : { requestId: metadata.requestId }),
132
+ ...(metadata.rateLimits === undefined ? {} : { usage: { rateLimits: metadata.rateLimits } }),
133
+ };
134
+ }
135
+
136
+ export class ParallelProvider implements Provider {
137
+ readonly id = "parallel" as const;
138
+ readonly capabilities = capabilities;
139
+ readonly profile = profile;
140
+ private readonly apiKey?: string;
141
+ private readonly endpoint: string;
142
+ private readonly fetchImpl: SearchHttpFetch;
143
+ private readonly maxResponseBytes: number;
144
+
145
+ constructor(options: ParallelAdapterOptions) {
146
+ this.apiKey = options.apiKey;
147
+ this.endpoint = options.endpoint ?? PARALLEL_SEARCH_ENDPOINT;
148
+ this.fetchImpl = options.fetchImpl ?? (fetch as SearchHttpFetch);
149
+ this.maxResponseBytes = options.maxResponseBytes ?? DEFAULT_PARALLEL_RESPONSE_BYTES;
150
+ }
151
+
152
+ async search(request: SearchRequest, signal: AbortSignal, _context: ProviderContext): Promise<SearchResponse> {
153
+ const normalized = validateSearchRequest(request);
154
+ const plan = buildParallelRequest(normalized);
155
+ const result = await postJson({
156
+ provider: this.id,
157
+ url: this.endpoint,
158
+ headers: { "x-api-key": requireApiKey(this.id, this.apiKey) },
159
+ body: plan.body,
160
+ signal,
161
+ fetchImpl: this.fetchImpl,
162
+ maxResponseBytes: this.maxResponseBytes,
163
+ });
164
+ const payload = result.payload !== null && typeof result.payload === "object" && !Array.isArray(result.payload) ? result.payload as Record<string, unknown> : undefined;
165
+ const searchId = typeof payload?.search_id === "string" ? payload.search_id : undefined;
166
+ const response = normalizeParallelResponse(result.payload, normalized, { requestId: result.requestId ?? searchId, ...(result.rateLimits === undefined ? {} : { rateLimits: result.rateLimits }) });
167
+ return { ...response, appliedOptions: plan.appliedOptions, warnings: [...plan.warnings, ...response.warnings] };
168
+ }
169
+ }
170
+
171
+ export function createParallelProvider(options: ParallelAdapterOptions): ParallelProvider {
172
+ return new ParallelProvider(options);
173
+ }
package/src/pdf.ts ADDED
@@ -0,0 +1,153 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { SafeFetchError } from "./fetch-errors";
6
+
7
+ // AnyDoc owns the default PDF path. This bounded Poppler path remains for
8
+ // explicit maxPages requests because AnyDoc has no page-range option.
9
+ export const DEFAULT_PDF_MAX_PAGES = 100;
10
+ export const MAX_PDF_PAGES = 500;
11
+ export const DEFAULT_PDF_TEXT_BYTES = 2 * 1024 * 1024;
12
+
13
+ export interface PdfExtractorOptions {
14
+ readonly command?: string;
15
+ readonly maxPages?: number;
16
+ readonly maxOutputBytes?: number;
17
+ readonly signal: AbortSignal;
18
+ readonly spawnImpl?: (command: string, args: readonly string[], options: Record<string, unknown>) => any;
19
+ }
20
+
21
+ export interface PdfTextResult {
22
+ readonly text: string;
23
+ readonly bytesRead: number;
24
+ }
25
+
26
+ function canceled(): SafeFetchError {
27
+ return new SafeFetchError({ kind: "canceled", message: "PDF extraction canceled" });
28
+ }
29
+
30
+ function extraction(message: string, cause?: unknown): SafeFetchError {
31
+ return new SafeFetchError({ kind: "extraction", message, cause });
32
+ }
33
+
34
+ function appendBytes(chunks: Uint8Array[], chunk: unknown, current: number, max: number): number {
35
+ const bytes = chunk instanceof Uint8Array ? chunk : new TextEncoder().encode(String(chunk));
36
+ const next = current + bytes.byteLength;
37
+ if (next > max) throw new SafeFetchError({ kind: "responseTooLarge", message: "PDF text exceeds the configured extraction limit" });
38
+ chunks.push(bytes);
39
+ return next;
40
+ }
41
+
42
+ async function runPdfToText(
43
+ command: string,
44
+ inputPath: string,
45
+ maxPages: number,
46
+ maxOutputBytes: number,
47
+ signal: AbortSignal,
48
+ spawnImpl: NonNullable<PdfExtractorOptions["spawnImpl"]>,
49
+ ): Promise<Uint8Array> {
50
+ if (signal.aborted) return Promise.reject(canceled());
51
+ return new Promise<Uint8Array>((resolve, reject) => {
52
+ let child: any;
53
+ let settled = false;
54
+ let terminationError: SafeFetchError | undefined;
55
+ let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
56
+ let stdoutBytes = 0;
57
+ const stdout: Uint8Array[] = [];
58
+ const stderr: Uint8Array[] = [];
59
+ const finish = (error?: unknown): void => {
60
+ if (settled) return;
61
+ settled = true;
62
+ signal.removeEventListener("abort", onAbort);
63
+ if (fallbackTimer !== undefined) clearTimeout(fallbackTimer);
64
+ if (error !== undefined) reject(error);
65
+ else {
66
+ const result = new Uint8Array(stdoutBytes);
67
+ let offset = 0;
68
+ for (const chunk of stdout) {
69
+ result.set(chunk, offset);
70
+ offset += chunk.byteLength;
71
+ }
72
+ resolve(result);
73
+ }
74
+ };
75
+ const kill = (): void => {
76
+ try {
77
+ if (child?.pid && process.platform !== "win32") {
78
+ try { process.kill(-child.pid, "SIGKILL"); } catch { /* group may already be gone */ }
79
+ }
80
+ child?.kill?.("SIGKILL");
81
+ } catch {
82
+ // Cleanup is best effort; the process result remains failed.
83
+ }
84
+ };
85
+ const terminate = (error: SafeFetchError): void => {
86
+ if (settled || terminationError !== undefined) return;
87
+ terminationError = error;
88
+ kill();
89
+ fallbackTimer = setTimeout(() => finish(error), 1_000);
90
+ };
91
+ const onAbort = (): void => terminate(canceled());
92
+ try {
93
+ child = spawnImpl(command, ["-q", "-enc", "UTF-8", "-f", "1", "-l", String(maxPages), inputPath, "-"], {
94
+ stdio: ["ignore", "pipe", "pipe"],
95
+ detached: process.platform !== "win32",
96
+ });
97
+ signal.addEventListener("abort", onAbort, { once: true });
98
+ child.stdout?.on("data", (chunk: unknown) => {
99
+ if (settled) return;
100
+ try {
101
+ stdoutBytes = appendBytes(stdout, chunk, stdoutBytes, maxOutputBytes);
102
+ } catch (error) {
103
+ terminate(error instanceof SafeFetchError ? error : extraction("PDF output exceeded the extraction limit", error));
104
+ }
105
+ });
106
+ child.stderr?.on("data", (chunk: unknown) => {
107
+ if (stderr.reduce((sum, item) => sum + item.byteLength, 0) < 16_384) {
108
+ try { appendBytes(stderr, chunk, stderr.reduce((sum, item) => sum + item.byteLength, 0), 16_384); } catch { /* diagnostics are bounded */ }
109
+ }
110
+ });
111
+ child.once("error", (error: unknown) => finish(terminationError ?? extraction(`PDF extraction command failed: ${error instanceof Error ? error.message : String(error)}`, error)));
112
+ if (signal.aborted) onAbort();
113
+ child.once("close", (code: number | null) => {
114
+ if (settled) return;
115
+ if (terminationError !== undefined) return finish(terminationError);
116
+ if (signal.aborted) return finish(canceled());
117
+ if (code !== 0) {
118
+ const diagnostic = new TextDecoder().decode(Uint8Array.from(stderr.flatMap((chunk) => [...chunk]))).trim().slice(0, 500);
119
+ return finish(extraction(diagnostic.length > 0 ? `PDF parser exited with ${code}: ${diagnostic}` : `PDF parser exited with ${code}`));
120
+ }
121
+ finish();
122
+ });
123
+ } catch (error) {
124
+ finish(extraction(`PDF extraction command could not start: ${error instanceof Error ? error.message : String(error)}`, error));
125
+ }
126
+ });
127
+ }
128
+
129
+ export async function extractPdfText(bytes: Uint8Array, options: PdfExtractorOptions): Promise<PdfTextResult> {
130
+ if (options.signal.aborted) throw canceled();
131
+ if (bytes.byteLength < 5 || new TextDecoder().decode(bytes.slice(0, 5)) !== "%PDF-") {
132
+ throw extraction("Response was identified as PDF but did not contain a valid PDF header");
133
+ }
134
+ const maxPages = options.maxPages ?? DEFAULT_PDF_MAX_PAGES;
135
+ if (!Number.isInteger(maxPages) || maxPages < 1 || maxPages > MAX_PDF_PAGES) {
136
+ throw new SafeFetchError({ kind: "invalidRequest", message: `maxPages must be between 1 and ${MAX_PDF_PAGES}` });
137
+ }
138
+ const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_PDF_TEXT_BYTES;
139
+ if (!Number.isInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > DEFAULT_PDF_TEXT_BYTES) {
140
+ throw new SafeFetchError({ kind: "invalidRequest", message: "max PDF text bytes are outside the supported bound" });
141
+ }
142
+ const directory = await mkdtemp(join(tmpdir(), "pi-search-pdf-"));
143
+ const inputPath = join(directory, "document.pdf");
144
+ try {
145
+ await writeFile(inputPath, bytes);
146
+ const output = await runPdfToText(options.command ?? "pdftotext", inputPath, maxPages, maxOutputBytes, options.signal, options.spawnImpl ?? (spawn as unknown as NonNullable<PdfExtractorOptions["spawnImpl"]>));
147
+ const text = new TextDecoder("utf-8", { fatal: false }).decode(output).trim();
148
+ if (text.length === 0) throw extraction("PDF contains no extractable text; scanned PDFs require OCR, which is not enabled");
149
+ return { text, bytesRead: output.byteLength };
150
+ } finally {
151
+ await rm(directory, { recursive: true, force: true }).catch(() => undefined);
152
+ }
153
+ }
@@ -0,0 +1,35 @@
1
+ import { SEARCH_PROVIDER_HINT_IDS, type SearchProviderHintId } from "./contracts";
2
+
3
+ /**
4
+ * Provider hints that can actually be dispatched by the current installation.
5
+ *
6
+ * The tool schemas expose only these ids (plus `native`, which any supported
7
+ * grounded active model can serve), so a calling model never sees options
8
+ * whose keys or enable gates are absent. Native grounding adapters are
9
+ * always listed: any of them can serve through an active or registry model
10
+ * without extra configuration.
11
+ */
12
+ export interface ProviderHintAvailability {
13
+ readonly brave?: boolean;
14
+ readonly braveAnswers?: boolean;
15
+ readonly exa?: boolean;
16
+ readonly parallel?: boolean;
17
+ readonly parallelResponses?: boolean;
18
+ readonly x?: boolean;
19
+ }
20
+
21
+ /** Native grounding adapters always stay exposed: any can serve through an
22
+ * active or registry model without extra configuration. */
23
+ const NATIVE_HINT_IDS = ["openai", "openai-codex", "gemini", "xai", "xai-x", "anthropic", "meta"] as const satisfies readonly SearchProviderHintId[];
24
+
25
+ /** Compute the dispatchable explicit-hint ids for this installation. */
26
+ export function availableProviderHints(availability: ProviderHintAvailability): readonly SearchProviderHintId[] {
27
+ return SEARCH_PROVIDER_HINT_IDS.filter((id) =>
28
+ NATIVE_HINT_IDS.includes(id as (typeof NATIVE_HINT_IDS)[number])
29
+ || (id === "brave" && availability.brave === true)
30
+ || (id === "brave-answers" && availability.braveAnswers === true)
31
+ || (id === "exa" && availability.exa === true)
32
+ || (id === "parallel" && availability.parallel === true)
33
+ || (id === "parallel-responses" && availability.parallelResponses === true)
34
+ || (id === "x" && availability.x === true));
35
+ }