@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/search.ts ADDED
@@ -0,0 +1,420 @@
1
+ import type { Provider, ProviderContext, SearchProviderSelection, SearchRequest, SearchResponse } from "./contracts";
2
+ import { createProviderError, SearchToolError, toSearchToolError } from "./errors";
3
+ import { cleanupSearchResponse, MAX_SEARCH_DOMAIN_LENGTH } from "./search-cleanup";
4
+
5
+ export const DEFAULT_MAX_RESULTS = 10;
6
+ export const MAX_RESULTS = 20;
7
+ export const MAX_QUERY_LENGTH = 2_000;
8
+ export const MAX_SEARCH_DOMAIN_COUNT = 20;
9
+ export const MAX_SEARCH_DOMAIN_BYTES = 4_096;
10
+ export const MAX_EXECUTION_MODEL_LENGTH = 500;
11
+ export const MAX_SEARCH_HANDLE_COUNT = 20;
12
+ export const MAX_SEARCH_HANDLE_LENGTH = 15;
13
+ export const MAX_SEARCH_DATE_LENGTH = 64;
14
+ export const MAX_SEARCH_LOCATION_FIELD_LENGTH = 100;
15
+ export const MAX_SEARCH_CONTENT_TYPES = 2;
16
+ /** Native model-mediated search can spend tens of seconds grounding a query. */
17
+ export const DEFAULT_SEARCH_TIMEOUT_MS = 60_000;
18
+ export const DEFAULT_CONTENT_RESULTS = 2;
19
+ /** Explicit source enrichment is finite and follows the public search-result bound. */
20
+ export const MAX_CONTENT_RESULTS = MAX_RESULTS;
21
+ export const DEFAULT_CONTENT_MAX_LENGTH = 4_000;
22
+ /** Keep search enrichment aligned with the fetcher's hard 32-KB output bound. */
23
+ export const MAX_CONTENT_MAX_LENGTH = 32_000;
24
+
25
+ function invalidRequest(message: string): SearchToolError {
26
+ return new SearchToolError("WEB_SEARCH_INVALID_REQUEST", message);
27
+ }
28
+
29
+ export function normalizeSearchDomain(value: string, field = "domain"): string {
30
+ const domain = value.trim().toLowerCase();
31
+ if (
32
+ domain.length === 0 ||
33
+ domain.length > MAX_SEARCH_DOMAIN_LENGTH ||
34
+ !domain.includes(".") ||
35
+ domain.includes("..") ||
36
+ !domain.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label))
37
+ ) {
38
+ throw invalidRequest(`Search ${field} must be a hostname without a scheme, path, port, or operators`);
39
+ }
40
+ return domain;
41
+ }
42
+
43
+ function normalizeDomainList(value: readonly string[] | undefined, field: string): string[] | undefined {
44
+ if (value === undefined) {
45
+ return undefined;
46
+ }
47
+ if (!Array.isArray(value)) {
48
+ throw invalidRequest(`Search ${field} must be an array of domain strings`);
49
+ }
50
+ if (value.length > MAX_SEARCH_DOMAIN_COUNT) {
51
+ throw invalidRequest(`Search ${field} must contain at most ${MAX_SEARCH_DOMAIN_COUNT} domains`);
52
+ }
53
+ let totalBytes = 0;
54
+ const domains = value.map((domain, index) => {
55
+ if (typeof domain !== "string" || domain.trim().length === 0) {
56
+ throw invalidRequest(`Search ${field}[${index}] must be a non-empty string`);
57
+ }
58
+ const normalized = normalizeSearchDomain(domain, `${field}[${index}]`);
59
+ totalBytes += new TextEncoder().encode(normalized).byteLength;
60
+ if (totalBytes > MAX_SEARCH_DOMAIN_BYTES) {
61
+ throw invalidRequest(`Search ${field} exceeds the ${MAX_SEARCH_DOMAIN_BYTES}-byte aggregate limit`);
62
+ }
63
+ return normalized;
64
+ });
65
+ return [...new Set(domains)];
66
+ }
67
+
68
+ function normalizeDate(value: unknown, field: string): string | undefined {
69
+ if (value === undefined) return undefined;
70
+ if (typeof value !== "string" || value.trim().length === 0) throw invalidRequest(`Search ${field} must be an ISO-8601 date or date-time`);
71
+ const normalized = value.trim();
72
+ const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,6}))?)?(Z|[+-]\d{2}:?\d{2}))?$/.exec(normalized);
73
+ if (normalized.length > MAX_SEARCH_DATE_LENGTH || match === null) throw invalidRequest(`Search ${field} must be an ISO-8601 date or date-time`);
74
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText, , offsetText] = match;
75
+ const year = Number(yearText);
76
+ const month = Number(monthText);
77
+ const day = Number(dayText);
78
+ const hour = hourText === undefined ? 0 : Number(hourText);
79
+ const minute = minuteText === undefined ? 0 : Number(minuteText);
80
+ const second = secondText === undefined ? 0 : Number(secondText);
81
+ const offset = offsetText === undefined || offsetText === "Z" ? undefined : offsetText.replace(":", "");
82
+ const offsetHour = offset === undefined ? 0 : Number(offset.slice(1, 3));
83
+ const offsetMinute = offset === undefined ? 0 : Number(offset.slice(3, 5));
84
+ const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
85
+ if (
86
+ month < 1 || month > 12 || day < 1 || day > daysInMonth ||
87
+ hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 ||
88
+ offsetHour > 23 || offsetMinute > 59 || !Number.isFinite(Date.parse(normalized))
89
+ ) {
90
+ throw invalidRequest(`Search ${field} must be an ISO-8601 date or date-time`);
91
+ }
92
+ return normalized;
93
+ }
94
+
95
+ function normalizeDateRange(value: SearchRequest["dateRange"]): SearchRequest["dateRange"] | undefined {
96
+ if (value === undefined) return undefined;
97
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalidRequest("Search dateRange must be an object");
98
+ const from = normalizeDate(value.from, "dateRange.from");
99
+ const to = normalizeDate(value.to, "dateRange.to");
100
+ if (from !== undefined && to !== undefined && Date.parse(from) > Date.parse(to)) throw invalidRequest("Search dateRange.from must not be later than dateRange.to");
101
+ if (from === undefined && to === undefined) return undefined;
102
+ return { ...(from === undefined ? {} : { from }), ...(to === undefined ? {} : { to }) };
103
+ }
104
+
105
+ function normalizeHandles(value: readonly string[] | undefined, field: string): string[] | undefined {
106
+ if (value === undefined) return undefined;
107
+ if (!Array.isArray(value)) throw invalidRequest(`Search ${field} must be an array of X handles`);
108
+ if (value.length > MAX_SEARCH_HANDLE_COUNT) throw invalidRequest(`Search ${field} must contain at most ${MAX_SEARCH_HANDLE_COUNT} handles`);
109
+ const handles = value.map((handle, index) => {
110
+ if (typeof handle !== "string") throw invalidRequest(`Search ${field}[${index}] must be a string`);
111
+ const normalized = handle.trim().replace(/^@/, "").toLowerCase();
112
+ if (normalized.length === 0 || normalized.length > MAX_SEARCH_HANDLE_LENGTH || !/^[A-Za-z0-9_]+$/.test(normalized)) {
113
+ throw invalidRequest(`Search ${field}[${index}] must be an X handle without spaces or punctuation`);
114
+ }
115
+ return normalized;
116
+ });
117
+ return [...new Set(handles)];
118
+ }
119
+
120
+ function normalizeLocation(value: SearchRequest["userLocation"]): SearchRequest["userLocation"] | undefined {
121
+ if (value === undefined) return undefined;
122
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalidRequest("Search userLocation must be an object");
123
+ if (value.type !== "approximate") throw invalidRequest("Search userLocation.type must be approximate");
124
+ const field = (candidate: unknown, name: string): string | undefined => {
125
+ if (candidate === undefined) return undefined;
126
+ if (typeof candidate !== "string" || candidate.trim().length === 0 || candidate.length > MAX_SEARCH_LOCATION_FIELD_LENGTH) {
127
+ throw invalidRequest(`Search userLocation.${name} must be a non-empty string of at most ${MAX_SEARCH_LOCATION_FIELD_LENGTH} characters`);
128
+ }
129
+ return candidate.trim();
130
+ };
131
+ const country = field(value.country, "country");
132
+ if (country !== undefined && !/^[A-Za-z]{2}$/.test(country)) throw invalidRequest("Search userLocation.country must be a two-letter ISO country code");
133
+ const region = field(value.region, "region");
134
+ const city = field(value.city, "city");
135
+ const timezone = field(value.timezone, "timezone");
136
+ if (country === undefined && region === undefined && city === undefined && timezone === undefined) throw invalidRequest("Search userLocation must include a location field");
137
+ return {
138
+ type: "approximate",
139
+ ...(country === undefined ? {} : { country: country.toUpperCase() }),
140
+ ...(region === undefined ? {} : { region }),
141
+ ...(city === undefined ? {} : { city }),
142
+ ...(timezone === undefined ? {} : { timezone }),
143
+ };
144
+ }
145
+
146
+ function normalizeContentTypes(value: SearchRequest["searchContentTypes"]): SearchRequest["searchContentTypes"] | undefined {
147
+ if (value === undefined) return undefined;
148
+ if (!Array.isArray(value) || value.length < 1 || value.length > MAX_SEARCH_CONTENT_TYPES) throw invalidRequest(`Search searchContentTypes must contain 1-${MAX_SEARCH_CONTENT_TYPES} items`);
149
+ const types = value.map((item, index) => {
150
+ if (item !== "text" && item !== "image") throw invalidRequest(`Search searchContentTypes[${index}] must be text or image`);
151
+ return item;
152
+ });
153
+ return [...new Set(types)];
154
+ }
155
+
156
+ function normalizeImageSettings(value: SearchRequest["imageSettings"]): SearchRequest["imageSettings"] | undefined {
157
+ if (value === undefined) return undefined;
158
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalidRequest("Search imageSettings must be an object");
159
+ const maxResults = value.maxResults;
160
+ if (maxResults !== undefined && (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > MAX_RESULTS)) throw invalidRequest(`Search imageSettings.maxResults must be an integer between 1 and ${MAX_RESULTS}`);
161
+ if (value.caption !== undefined && typeof value.caption !== "boolean") throw invalidRequest("Search imageSettings.caption must be a boolean");
162
+ if (maxResults === undefined && value.caption === undefined) return undefined;
163
+ return {
164
+ ...(maxResults === undefined ? {} : { maxResults }),
165
+ ...(value.caption === undefined ? {} : { caption: value.caption }),
166
+ };
167
+ }
168
+
169
+ function normalizeSocial(value: SearchRequest["social"]): SearchRequest["social"] | undefined {
170
+ if (value === undefined) return undefined;
171
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalidRequest("Search social must be an object");
172
+ if (value.understandImages !== undefined && typeof value.understandImages !== "boolean") throw invalidRequest("Search social.understandImages must be a boolean");
173
+ if (value.understandVideos !== undefined && typeof value.understandVideos !== "boolean") throw invalidRequest("Search social.understandVideos must be a boolean");
174
+ const includeHandles = normalizeHandles(value.includeHandles, "social.includeHandles");
175
+ const excludeHandles = normalizeHandles(value.excludeHandles, "social.excludeHandles");
176
+ if (includeHandles !== undefined && excludeHandles !== undefined) {
177
+ const excluded = new Set(excludeHandles);
178
+ const overlap = includeHandles.find((handle) => excluded.has(handle));
179
+ if (overlap !== undefined) throw invalidRequest(`Search social handle cannot be both included and excluded: ${overlap}`);
180
+ }
181
+ if (includeHandles === undefined && excludeHandles === undefined && value.understandImages === undefined && value.understandVideos === undefined) return undefined;
182
+ return {
183
+ ...(includeHandles === undefined ? {} : { includeHandles }),
184
+ ...(excludeHandles === undefined ? {} : { excludeHandles }),
185
+ ...(value.understandImages === undefined ? {} : { understandImages: value.understandImages }),
186
+ ...(value.understandVideos === undefined ? {} : { understandVideos: value.understandVideos }),
187
+ };
188
+ }
189
+
190
+ /** Validate and normalize the public search request before any provider call. */
191
+ export function validateSearchRequest(request: SearchRequest): SearchRequest {
192
+ if (request === null || typeof request !== "object") {
193
+ throw invalidRequest("Search request must be an object");
194
+ }
195
+ if (typeof request.query !== "string") {
196
+ throw invalidRequest("Search query must be a string");
197
+ }
198
+ const query = request.query.trim();
199
+ if (query.length === 0) {
200
+ throw invalidRequest("Search query must not be empty");
201
+ }
202
+ if (query.length > MAX_QUERY_LENGTH) {
203
+ throw invalidRequest(`Search query must be at most ${MAX_QUERY_LENGTH} characters`);
204
+ }
205
+
206
+ const maxResults = request.maxResults ?? DEFAULT_MAX_RESULTS;
207
+ if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > MAX_RESULTS) {
208
+ throw invalidRequest(`Search maxResults must be an integer between 1 and ${MAX_RESULTS}`);
209
+ }
210
+ if (request.executionModel !== undefined && typeof request.executionModel !== "string") throw invalidRequest("Search executionModel must be a string");
211
+ const executionModel = request.executionModel === undefined ? undefined : request.executionModel.trim();
212
+ if (executionModel !== undefined && (executionModel.length === 0 || executionModel.length > MAX_EXECUTION_MODEL_LENGTH)) {
213
+ throw invalidRequest(`Search executionModel must be between 1 and ${MAX_EXECUTION_MODEL_LENGTH} characters`);
214
+ }
215
+ const mode = request.mode ?? "auto";
216
+ if (mode !== "auto" && mode !== "keyword" && mode !== "fresh") {
217
+ throw invalidRequest("Search mode must be auto, keyword, or fresh");
218
+ }
219
+ const dateRange = normalizeDateRange(request.dateRange);
220
+ const social = normalizeSocial(request.social);
221
+ const userLocation = normalizeLocation(request.userLocation);
222
+ const searchContentTypes = normalizeContentTypes(request.searchContentTypes);
223
+ const imageSettings = normalizeImageSettings(request.imageSettings);
224
+ if (imageSettings !== undefined && !searchContentTypes?.includes("image")) throw invalidRequest("Search imageSettings requires searchContentTypes to include image");
225
+ const searchContextSize = request.searchContextSize ?? undefined;
226
+ if (searchContextSize !== undefined && !["low", "medium", "high"].includes(searchContextSize)) throw invalidRequest("Search searchContextSize must be low, medium, or high");
227
+ const returnTokenBudget = request.returnTokenBudget ?? undefined;
228
+ if (returnTokenBudget !== undefined && returnTokenBudget !== "default" && returnTokenBudget !== "unlimited") throw invalidRequest("Search returnTokenBudget must be default or unlimited");
229
+ const externalWebAccess = request.externalWebAccess ?? undefined;
230
+ if (externalWebAccess !== undefined && typeof externalWebAccess !== "boolean") throw invalidRequest("Search externalWebAccess must be a boolean");
231
+ const answerMode = request.answerMode ?? "auto";
232
+ if (answerMode !== "auto" && answerMode !== "evidence") {
233
+ throw invalidRequest("Search answerMode must be auto or evidence");
234
+ }
235
+ const includeContent = request.includeContent ?? false;
236
+ if (typeof includeContent !== "boolean") throw invalidRequest("Search includeContent must be a boolean");
237
+ const contentResults = request.contentResults ?? DEFAULT_CONTENT_RESULTS;
238
+ if (!Number.isInteger(contentResults) || contentResults < 1 || contentResults > MAX_CONTENT_RESULTS) {
239
+ throw invalidRequest(`Search contentResults must be an integer between 1 and ${MAX_CONTENT_RESULTS}`);
240
+ }
241
+ const contentMaxLength = request.contentMaxLength ?? DEFAULT_CONTENT_MAX_LENGTH;
242
+ if (!Number.isInteger(contentMaxLength) || contentMaxLength < 1 || contentMaxLength > MAX_CONTENT_MAX_LENGTH) {
243
+ throw invalidRequest(`Search contentMaxLength must be an integer between 1 and ${MAX_CONTENT_MAX_LENGTH}`);
244
+ }
245
+
246
+ if (
247
+ request.domains !== undefined &&
248
+ (typeof request.domains !== "object" || request.domains === null || Array.isArray(request.domains))
249
+ ) {
250
+ throw invalidRequest("Search domains must be an object");
251
+ }
252
+ const include = normalizeDomainList(request.domains?.include, "domains.include");
253
+ const exclude = normalizeDomainList(request.domains?.exclude, "domains.exclude");
254
+ if (include !== undefined && exclude !== undefined) {
255
+ const excluded = new Set(exclude);
256
+ const overlap = include.find((domain) => excluded.has(domain));
257
+ if (overlap !== undefined) {
258
+ throw invalidRequest(`Search domain cannot be both included and excluded: ${overlap}`);
259
+ }
260
+ }
261
+ return {
262
+ ...request,
263
+ query,
264
+ mode,
265
+ maxResults,
266
+ ...(executionModel === undefined ? {} : { executionModel }),
267
+ ...(dateRange === undefined ? {} : { dateRange }),
268
+ ...(social === undefined ? {} : { social }),
269
+ ...(userLocation === undefined ? {} : { userLocation }),
270
+ ...(searchContentTypes === undefined ? {} : { searchContentTypes }),
271
+ ...(imageSettings === undefined ? {} : { imageSettings }),
272
+ ...(searchContextSize === undefined ? {} : { searchContextSize }),
273
+ ...(returnTokenBudget === undefined ? {} : { returnTokenBudget }),
274
+ ...(externalWebAccess === undefined ? {} : { externalWebAccess }),
275
+ answerMode,
276
+ includeContent,
277
+ contentResults,
278
+ contentMaxLength,
279
+ ...(include === undefined && exclude === undefined
280
+ ? { domains: request.domains }
281
+ : { domains: { ...(include === undefined ? {} : { include }), ...(exclude === undefined ? {} : { exclude }) } }),
282
+ };
283
+ }
284
+
285
+ export interface ExecuteSearchOptions {
286
+ readonly signal?: AbortSignal;
287
+ readonly timeoutMs?: number;
288
+ readonly context?: ProviderContext;
289
+ }
290
+
291
+ /** Execute one provider call with caller cancellation and a hard deadline. */
292
+ export async function executeSearch(
293
+ provider: Provider,
294
+ request: SearchRequest,
295
+ options: ExecuteSearchOptions = {},
296
+ ): Promise<SearchResponse> {
297
+ const normalized = validateSearchRequest(request);
298
+ const timeoutMs = options.timeoutMs ?? DEFAULT_SEARCH_TIMEOUT_MS;
299
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
300
+ throw invalidRequest("Search timeoutMs must be positive");
301
+ }
302
+ if (options.signal?.aborted) {
303
+ throw toSearchToolError(
304
+ createProviderError({
305
+ provider: provider.id,
306
+ kind: "canceled",
307
+ message: "Search canceled",
308
+ retryable: false,
309
+ }),
310
+ provider.id,
311
+ );
312
+ }
313
+
314
+ const controller = new AbortController();
315
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
316
+ let rejectCanceled: ((error: SearchToolError) => void) | undefined;
317
+ const canceled = new Promise<never>((_, reject) => {
318
+ rejectCanceled = reject;
319
+ });
320
+ const onAbort = () => {
321
+ const deadlineAbort = options.signal?.reason instanceof DOMException && options.signal.reason.name === "TimeoutError";
322
+ controller.abort(options.signal?.reason);
323
+ rejectCanceled?.(
324
+ toSearchToolError(
325
+ createProviderError({
326
+ provider: provider.id,
327
+ kind: deadlineAbort ? "timeout" : "canceled",
328
+ message: deadlineAbort ? "Search deadline exceeded" : "Search canceled",
329
+ retryable: deadlineAbort,
330
+ }),
331
+ provider.id,
332
+ ),
333
+ );
334
+ };
335
+ options.signal?.addEventListener("abort", onAbort, { once: true });
336
+
337
+ let rejectTimedOut: ((error: SearchToolError) => void) | undefined;
338
+ const timedOut = new Promise<never>((_, reject) => {
339
+ rejectTimedOut = reject;
340
+ timeoutId = setTimeout(() => {
341
+ controller.abort();
342
+ rejectTimedOut?.(
343
+ toSearchToolError(
344
+ createProviderError({
345
+ provider: provider.id,
346
+ kind: "timeout",
347
+ message: `Search timed out after ${timeoutMs}ms`,
348
+ retryable: true,
349
+ }),
350
+ provider.id,
351
+ ),
352
+ );
353
+ }, timeoutMs);
354
+ });
355
+
356
+ const providerCall = Promise.resolve().then(() => provider.search(normalized, controller.signal, options.context ?? {}));
357
+ try {
358
+ const response = await Promise.race([providerCall, timedOut, canceled]);
359
+ return cleanupSearchResponse(response, normalized, provider.id);
360
+ } catch (error) {
361
+ throw toSearchToolError(error, provider.id);
362
+ } finally {
363
+ if (timeoutId !== undefined) {
364
+ clearTimeout(timeoutId);
365
+ }
366
+ options.signal?.removeEventListener("abort", onAbort);
367
+ }
368
+ }
369
+
370
+ function canUseAutomaticFallback(error: unknown): boolean {
371
+ if (!(error instanceof SearchToolError)) return false;
372
+ // A successful provider request can be billed before a network, timeout, or
373
+ // 5xx error reaches us. Only failures that are known to have been rejected
374
+ // or prevented from dispatching may consume the bounded alternative.
375
+ return error.kind === "auth" || error.kind === "rateLimit" || error.kind === "unavailable";
376
+ }
377
+
378
+ /** Execute an automatic selection with at most one visible alternative. */
379
+ export async function executeSearchSelection(
380
+ selection: SearchProviderSelection,
381
+ request: SearchRequest,
382
+ options: ExecuteSearchOptions = {},
383
+ ): Promise<SearchResponse> {
384
+ const normalized = validateSearchRequest(request);
385
+ const candidates = [selection.provider, ...(selection.automatic ? selection.fallbacks.slice(0, 1) : [])];
386
+ let firstError: SearchToolError | undefined;
387
+ for (let index = 0; index < candidates.length; index += 1) {
388
+ const candidate = candidates[index]!;
389
+ try {
390
+ const response = await executeSearch(candidate, normalized, options);
391
+ const attemptedProviders = candidates.slice(0, index + 1).map((provider) => provider.id);
392
+ if (index === 0) return { ...response, attemptedProviders };
393
+ return {
394
+ ...response,
395
+ attemptedProviders,
396
+ warnings: [
397
+ ...response.warnings,
398
+ { code: "provider-fallback", message: `Provider ${candidates[0]!.id} failed; used ${candidate.id} as the bounded automatic fallback` },
399
+ ],
400
+ };
401
+ } catch (error) {
402
+ const toolError = error instanceof SearchToolError ? error : toSearchToolError(error, candidate.id);
403
+ if (index === 0) firstError = toolError;
404
+ if (index === 0 && selection.automatic && selection.fallbacks.length > 0 && canUseAutomaticFallback(toolError)) continue;
405
+ if (firstError !== undefined && index > 0) {
406
+ throw new SearchToolError(toolError.code, `${toolError.message}; primary provider ${selection.provider.id} also failed: ${firstError.message}`, {
407
+ provider: toolError.provider,
408
+ kind: toolError.kind,
409
+ retryable: toolError.retryable,
410
+ status: toolError.status,
411
+ requestId: toolError.requestId,
412
+ retryAfterMs: toolError.retryAfterMs,
413
+ rateLimits: toolError.rateLimits,
414
+ });
415
+ }
416
+ throw toolError;
417
+ }
418
+ }
419
+ throw firstError ?? new SearchToolError("WEB_SEARCH_UNKNOWN", "Search failed");
420
+ }