@mini-z/dsh-search-providers 0.1.2

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/dsh/index.js ADDED
@@ -0,0 +1,1211 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { statSync, readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ const DEFAULT_TIMEOUT_MS = 55e3;
5
+ function resolveProviderConfig(pluginConfig, providerId, envPrefix) {
6
+ const pc = pluginConfig.providers?.[providerId] ?? {};
7
+ const apiKey = process.env[`${envPrefix}_API_KEY`] ?? pc.apiKey;
8
+ const baseUrl = process.env[`${envPrefix}_BASE_URL`] ?? pc.baseUrl;
9
+ const timeoutMs = Number(process.env[`${envPrefix}_TIMEOUT_MS`]) || pc.timeoutMs || DEFAULT_TIMEOUT_MS;
10
+ return {
11
+ apiKey,
12
+ baseUrl,
13
+ timeoutMs,
14
+ options: pc.options ?? {}
15
+ };
16
+ }
17
+ class ProviderAuthenticationError extends Error {
18
+ kind = "provider-auth";
19
+ constructor(message, options) {
20
+ super(message, options);
21
+ this.name = "ProviderAuthenticationError";
22
+ }
23
+ }
24
+ class ProviderHttpError extends Error {
25
+ constructor(message, status, options) {
26
+ super(message, options);
27
+ this.status = status;
28
+ this.name = "ProviderHttpError";
29
+ }
30
+ kind = "provider-http";
31
+ }
32
+ async function fetchWithTimeout(url, init, timeoutMs) {
33
+ const controller = new AbortController();
34
+ const timer = setTimeout(
35
+ () => controller.abort(new Error(`request timed out after ${timeoutMs}ms`)),
36
+ timeoutMs
37
+ );
38
+ try {
39
+ const signal = init.signal ? combineSignals(init.signal, controller.signal) : controller.signal;
40
+ return await fetch(url, { ...init, signal });
41
+ } finally {
42
+ clearTimeout(timer);
43
+ }
44
+ }
45
+ function combineSignals(a, b) {
46
+ return AbortSignal.any([a, b]);
47
+ }
48
+ async function throwOnError(response, label) {
49
+ if (response.ok) return;
50
+ const text = await response.text().catch(() => "");
51
+ let detail = text.slice(0, 500);
52
+ if (response.status === 401) {
53
+ detail = "Invalid or missing API key";
54
+ } else if (response.status === 429) {
55
+ detail = "Rate limit exceeded";
56
+ } else if (response.status === 432) {
57
+ detail = "Usage limit exceeded";
58
+ }
59
+ const message = `${label} returned ${response.status}: ${detail}`;
60
+ if (response.status === 401) {
61
+ throw new ProviderAuthenticationError(message);
62
+ }
63
+ throw new ProviderHttpError(message, response.status);
64
+ }
65
+ class CooldownController {
66
+ disabled = /* @__PURE__ */ new Map();
67
+ cooling = /* @__PURE__ */ new Map();
68
+ /** Record one capability failure. Confirmed authentication failures are provider-global. */
69
+ record(providerId, capability, error) {
70
+ if (error instanceof ProviderAuthenticationError) {
71
+ this.disabled.set(providerId, {
72
+ until: Number.POSITIVE_INFINITY,
73
+ reason: "invalid credentials"
74
+ });
75
+ return true;
76
+ }
77
+ this.cooling.set(this.capabilityKey(providerId, capability), {
78
+ until: Date.now() + this.cooldownDuration(error),
79
+ reason: this.classifyError(error)
80
+ });
81
+ return true;
82
+ }
83
+ /** Check whether one provider capability is temporarily cooling. */
84
+ isCooling(providerId, capability) {
85
+ return this.getEntry(this.cooling, this.capabilityKey(providerId, capability)) !== void 0;
86
+ }
87
+ /** Check whether confirmed authentication failure disabled the provider until restart. */
88
+ isDisabled(providerId) {
89
+ return this.getEntry(this.disabled, providerId) !== void 0;
90
+ }
91
+ /** Get the current failure reason, if any. */
92
+ getReason(providerId, capability) {
93
+ return this.getEntry(this.disabled, providerId)?.reason ?? this.getEntry(this.cooling, this.capabilityKey(providerId, capability))?.reason;
94
+ }
95
+ /** Build candidates while separating global disablement from capability cooldown. */
96
+ filter(chain, capability) {
97
+ const ready = [];
98
+ const cooling = [];
99
+ const disabled = [];
100
+ for (const id of chain) {
101
+ if (this.isDisabled(id)) {
102
+ disabled.push(id);
103
+ } else if (this.isCooling(id, capability)) {
104
+ cooling.push(id);
105
+ } else {
106
+ ready.push(id);
107
+ }
108
+ }
109
+ return { ready, cooling, disabled };
110
+ }
111
+ capabilityKey(providerId, capability) {
112
+ return `${providerId}:${capability}`;
113
+ }
114
+ getEntry(map, key) {
115
+ const entry = map.get(key);
116
+ if (!entry) return void 0;
117
+ if (Date.now() >= entry.until) {
118
+ map.delete(key);
119
+ return void 0;
120
+ }
121
+ return entry;
122
+ }
123
+ classifyError(error) {
124
+ const message = error instanceof Error ? error.message : String(error);
125
+ if (this.isRateLimitError(error, message)) return "rate limit or quota exceeded";
126
+ if (/timeout|timed out/i.test(message)) return "timeout";
127
+ if (/network|ENOTFOUND|ECONNREFUSED|fetch failed/i.test(message)) return "network error";
128
+ return "request failed";
129
+ }
130
+ cooldownDuration(error) {
131
+ const message = error instanceof Error ? error.message : String(error);
132
+ if (this.isRateLimitError(error, message)) return 5 * 60 * 1e3;
133
+ if (/timeout|timed out|network|ENOTFOUND|ECONNREFUSED|fetch failed/i.test(message)) {
134
+ return 30 * 1e3;
135
+ }
136
+ return 10 * 1e3;
137
+ }
138
+ isRateLimitError(error, message) {
139
+ return error instanceof ProviderHttpError && (error.status === 429 || error.status === 432) || /429|432|rate.?limit|quota|usage limit|out of credits|monthly quota/i.test(message);
140
+ }
141
+ }
142
+ class ProviderRegistry {
143
+ providers = /* @__PURE__ */ new Map();
144
+ activeId;
145
+ register(provider) {
146
+ if (this.providers.has(provider.id)) {
147
+ console.warn(`[search-providers] provider '${provider.id}' already registered; overwriting`);
148
+ }
149
+ this.providers.set(provider.id, provider);
150
+ if (this.activeId === void 0) {
151
+ this.activeId = provider.id;
152
+ }
153
+ }
154
+ setActive(id) {
155
+ if (!this.providers.has(id)) {
156
+ throw new Error(`Provider '${id}' is not registered`);
157
+ }
158
+ this.activeId = id;
159
+ }
160
+ getActive() {
161
+ if (this.activeId === void 0) {
162
+ throw new Error("No search provider registered");
163
+ }
164
+ const provider = this.providers.get(this.activeId);
165
+ if (!provider) {
166
+ throw new Error(`Active provider '${this.activeId}' disappeared`);
167
+ }
168
+ return provider;
169
+ }
170
+ get(id) {
171
+ return this.providers.get(id);
172
+ }
173
+ list() {
174
+ return Array.from(this.providers.keys());
175
+ }
176
+ }
177
+ class TinyfishProvider {
178
+ id = "tinyfish";
179
+ name = "TinyFish";
180
+ description = "TinyFish Search & Fetch (free tier, live web results)";
181
+ searchable = true;
182
+ fetchable = true;
183
+ apiKey;
184
+ searchBaseUrl;
185
+ fetchBaseUrl;
186
+ timeoutMs;
187
+ constructor(config) {
188
+ this.apiKey = config.apiKey;
189
+ this.searchBaseUrl = config.baseUrl ?? "https://api.search.tinyfish.ai";
190
+ this.fetchBaseUrl = config.options?.fetchBaseUrl ?? "https://api.fetch.tinyfish.ai";
191
+ this.timeoutMs = config.timeoutMs;
192
+ }
193
+ available() {
194
+ return typeof this.apiKey === "string" && this.apiKey !== "";
195
+ }
196
+ async search(request, signal) {
197
+ this.ensureKey();
198
+ const url = new URL(this.searchBaseUrl);
199
+ url.searchParams.set("query", request.query);
200
+ if (request.maxResults && request.maxResults > 0) {
201
+ url.searchParams.set("max_results", String(Math.floor(request.maxResults)));
202
+ }
203
+ if (typeof request.recencyDays === "number" && request.recencyDays > 0) {
204
+ url.searchParams.set("recency_minutes", String(Math.floor(request.recencyDays * 24 * 60)));
205
+ }
206
+ if (request.purpose) {
207
+ url.searchParams.set("purpose", request.purpose);
208
+ }
209
+ if (request.location) {
210
+ url.searchParams.set("location", request.location);
211
+ }
212
+ if (request.language) {
213
+ url.searchParams.set("language", request.language);
214
+ }
215
+ if (request.domainType) {
216
+ url.searchParams.set("domain_type", request.domainType);
217
+ }
218
+ if (request.includeDomains && request.includeDomains.length > 0) {
219
+ url.searchParams.set("include_domains", request.includeDomains.join(","));
220
+ }
221
+ if (request.excludeDomains && request.excludeDomains.length > 0) {
222
+ url.searchParams.set("exclude_domains", request.excludeDomains.join(","));
223
+ }
224
+ const response = await fetchWithTimeout(url.toString(), {
225
+ method: "GET",
226
+ headers: {
227
+ "X-API-Key": this.apiKey,
228
+ Accept: "application/json"
229
+ },
230
+ signal
231
+ }, this.timeoutMs);
232
+ await throwOnError(response, "TinyFish Search");
233
+ const body = await response.json();
234
+ const items = (body.results ?? []).map((r) => ({
235
+ title: r.title,
236
+ url: r.url,
237
+ snippet: r.snippet,
238
+ source: r.site_name
239
+ }));
240
+ return {
241
+ status: "ok",
242
+ source: this.name,
243
+ summary: items.length > 0 ? `Found ${body.total_results ?? items.length} result(s) for "${request.query}"` : `No results found for "${request.query}"`,
244
+ items,
245
+ uncertainty: items.length === 0 ? ["No search results returned"] : []
246
+ };
247
+ }
248
+ async fetch(request, signal) {
249
+ this.ensureKey();
250
+ const body = {
251
+ urls: [request.url],
252
+ format: request.format ?? "markdown"
253
+ };
254
+ if (request.query) {
255
+ body.purpose = request.query;
256
+ }
257
+ if (typeof request.ttl === "number") {
258
+ body.ttl = request.ttl;
259
+ }
260
+ const response = await fetchWithTimeout(this.fetchBaseUrl, {
261
+ method: "POST",
262
+ headers: {
263
+ "X-API-Key": this.apiKey,
264
+ "Content-Type": "application/json",
265
+ Accept: "application/json"
266
+ },
267
+ body: JSON.stringify(body),
268
+ signal
269
+ }, this.timeoutMs);
270
+ await throwOnError(response, "TinyFish Fetch");
271
+ const data = await response.json();
272
+ const page = data.results?.[0];
273
+ const error = data.errors?.[0];
274
+ if (!page && error) {
275
+ throw new Error(`TinyFish Fetch failed for ${error.url}: ${error.error}`);
276
+ }
277
+ if (!page) {
278
+ throw new Error("TinyFish Fetch returned no content");
279
+ }
280
+ return {
281
+ summary: page.title ?? page.description ?? "Fetched page content",
282
+ content: page.text ?? "",
283
+ uncertainty: [],
284
+ warnings: []
285
+ };
286
+ }
287
+ ensureKey() {
288
+ if (!this.apiKey) {
289
+ throw new Error(
290
+ "TinyFish API key is missing. Set TINYFISH_API_KEY or configure providers.tinyfish.apiKey."
291
+ );
292
+ }
293
+ }
294
+ }
295
+ class TavilyProvider {
296
+ id = "tavily";
297
+ name = "Tavily";
298
+ description = "Tavily web search";
299
+ searchable = true;
300
+ fetchable = false;
301
+ apiKey;
302
+ baseUrl;
303
+ timeoutMs;
304
+ constructor(config) {
305
+ this.apiKey = config.apiKey;
306
+ this.baseUrl = config.baseUrl ?? "https://api.tavily.com";
307
+ this.timeoutMs = config.timeoutMs;
308
+ }
309
+ available() {
310
+ return typeof this.apiKey === "string" && this.apiKey !== "";
311
+ }
312
+ async search(request, signal) {
313
+ this.ensureKey();
314
+ const body = {
315
+ query: request.query,
316
+ search_depth: "basic",
317
+ max_results: request.maxResults ?? 5
318
+ };
319
+ if (request.includeDomains && request.includeDomains.length > 0) {
320
+ body.include_domains = request.includeDomains;
321
+ }
322
+ if (request.excludeDomains && request.excludeDomains.length > 0) {
323
+ body.exclude_domains = request.excludeDomains;
324
+ }
325
+ const response = await fetchWithTimeout(`${this.baseUrl}/search`, {
326
+ method: "POST",
327
+ headers: {
328
+ Authorization: `Bearer ${this.apiKey}`,
329
+ "Content-Type": "application/json",
330
+ Accept: "application/json"
331
+ },
332
+ body: JSON.stringify(body),
333
+ signal
334
+ }, this.timeoutMs);
335
+ await throwOnError(response, "Tavily Search");
336
+ const data = await response.json();
337
+ const items = (data.results ?? []).map((r) => ({
338
+ title: r.title,
339
+ url: r.url,
340
+ snippet: r.content ?? r.raw_content ?? ""
341
+ }));
342
+ return {
343
+ status: "ok",
344
+ source: this.name,
345
+ summary: items.length > 0 ? `Found ${items.length} result(s) for "${request.query}"` : `No results found for "${request.query}"`,
346
+ items,
347
+ uncertainty: items.length === 0 ? ["No search results returned"] : []
348
+ };
349
+ }
350
+ ensureKey() {
351
+ if (!this.apiKey) {
352
+ throw new Error("Tavily API key is missing. Set TAVILY_API_KEY or configure providers.tavily.apiKey.");
353
+ }
354
+ }
355
+ }
356
+ class ExaProvider {
357
+ id = "exa";
358
+ name = "Exa";
359
+ description = "Exa neural web search and contents fetch";
360
+ searchable = true;
361
+ fetchable = true;
362
+ apiKey;
363
+ baseUrl;
364
+ timeoutMs;
365
+ constructor(config) {
366
+ this.apiKey = config.apiKey;
367
+ this.baseUrl = config.baseUrl ?? "https://api.exa.ai";
368
+ this.timeoutMs = config.timeoutMs;
369
+ }
370
+ available() {
371
+ return typeof this.apiKey === "string" && this.apiKey !== "";
372
+ }
373
+ async search(request, signal) {
374
+ this.ensureKey();
375
+ const body = {
376
+ query: request.query,
377
+ num_results: request.maxResults ?? 5,
378
+ type: "auto"
379
+ };
380
+ if (request.includeDomains && request.includeDomains.length > 0) {
381
+ body.include_domains = request.includeDomains;
382
+ }
383
+ if (request.excludeDomains && request.excludeDomains.length > 0) {
384
+ body.exclude_domains = request.excludeDomains;
385
+ }
386
+ const response = await fetchWithTimeout(`${this.baseUrl}/search`, {
387
+ method: "POST",
388
+ headers: {
389
+ "x-api-key": this.apiKey,
390
+ "Content-Type": "application/json",
391
+ Accept: "application/json"
392
+ },
393
+ body: JSON.stringify(body),
394
+ signal
395
+ }, this.timeoutMs);
396
+ await throwOnError(response, "Exa Search");
397
+ const data = await response.json();
398
+ const items = (data.results ?? []).map((r) => ({
399
+ title: r.title ?? r.url,
400
+ url: r.url,
401
+ snippet: r.text ?? "",
402
+ publishedAt: r.publishedDate
403
+ }));
404
+ return {
405
+ status: "ok",
406
+ source: this.name,
407
+ summary: items.length > 0 ? `Found ${items.length} result(s) for "${request.query}"` : `No results found for "${request.query}"`,
408
+ items,
409
+ uncertainty: items.length === 0 ? ["No search results returned"] : []
410
+ };
411
+ }
412
+ async fetch(request, signal) {
413
+ this.ensureKey();
414
+ const body = {
415
+ urls: [request.url],
416
+ text: true
417
+ };
418
+ const response = await fetchWithTimeout(`${this.baseUrl}/contents`, {
419
+ method: "POST",
420
+ headers: {
421
+ "x-api-key": this.apiKey,
422
+ "Content-Type": "application/json",
423
+ Accept: "application/json"
424
+ },
425
+ body: JSON.stringify(body),
426
+ signal
427
+ }, this.timeoutMs);
428
+ await throwOnError(response, "Exa Contents");
429
+ const data = await response.json();
430
+ const page = data.results?.[0];
431
+ if (!page) {
432
+ throw new Error("Exa Contents returned no content");
433
+ }
434
+ return {
435
+ summary: page.title ?? "Fetched page content",
436
+ content: page.text ?? "",
437
+ uncertainty: [],
438
+ warnings: []
439
+ };
440
+ }
441
+ ensureKey() {
442
+ if (!this.apiKey) {
443
+ throw new Error("Exa API key is missing. Set EXA_API_KEY or configure providers.exa.apiKey.");
444
+ }
445
+ }
446
+ }
447
+ class FirecrawlProvider {
448
+ id = "firecrawl";
449
+ name = "Firecrawl";
450
+ description = "Firecrawl web search and page scrape";
451
+ searchable = true;
452
+ fetchable = true;
453
+ apiKey;
454
+ baseUrl;
455
+ timeoutMs;
456
+ constructor(config) {
457
+ this.apiKey = config.apiKey;
458
+ this.baseUrl = config.baseUrl ?? "https://api.firecrawl.dev";
459
+ this.timeoutMs = config.timeoutMs;
460
+ }
461
+ available() {
462
+ return typeof this.apiKey === "string" && this.apiKey !== "";
463
+ }
464
+ async search(request, signal) {
465
+ this.ensureKey();
466
+ const body = {
467
+ query: request.query,
468
+ limit: request.maxResults ?? 5,
469
+ sources: ["web"]
470
+ };
471
+ if (request.includeDomains && request.includeDomains.length > 0) {
472
+ body.includeDomains = request.includeDomains;
473
+ }
474
+ if (request.excludeDomains && request.excludeDomains.length > 0) {
475
+ body.excludeDomains = request.excludeDomains;
476
+ }
477
+ const response = await fetchWithTimeout(`${this.baseUrl}/v2/search`, {
478
+ method: "POST",
479
+ headers: {
480
+ Authorization: `Bearer ${this.apiKey}`,
481
+ "Content-Type": "application/json",
482
+ Accept: "application/json"
483
+ },
484
+ body: JSON.stringify(body),
485
+ signal
486
+ }, this.timeoutMs);
487
+ await throwOnError(response, "Firecrawl Search");
488
+ const data = await response.json();
489
+ if (data.success === false) {
490
+ throw new Error("Firecrawl Search returned success: false");
491
+ }
492
+ const rawItems = data.data?.web ?? [];
493
+ const items = rawItems.map((r) => ({
494
+ title: r.title ?? r.url,
495
+ url: r.url,
496
+ snippet: r.description ?? r.markdown ?? ""
497
+ }));
498
+ return {
499
+ status: "ok",
500
+ source: this.name,
501
+ summary: items.length > 0 ? `Found ${items.length} result(s) for "${request.query}"` : `No results found for "${request.query}"`,
502
+ items,
503
+ uncertainty: items.length === 0 ? ["No search results returned"] : []
504
+ };
505
+ }
506
+ async fetch(request, signal) {
507
+ this.ensureKey();
508
+ const body = {
509
+ url: request.url,
510
+ formats: ["markdown"]
511
+ };
512
+ const response = await fetchWithTimeout(`${this.baseUrl}/v2/scrape`, {
513
+ method: "POST",
514
+ headers: {
515
+ Authorization: `Bearer ${this.apiKey}`,
516
+ "Content-Type": "application/json",
517
+ Accept: "application/json"
518
+ },
519
+ body: JSON.stringify(body),
520
+ signal
521
+ }, this.timeoutMs);
522
+ await throwOnError(response, "Firecrawl Scrape");
523
+ const data = await response.json();
524
+ if (data.success === false) {
525
+ throw new Error("Firecrawl Scrape returned success: false");
526
+ }
527
+ return {
528
+ summary: data.data?.metadata?.title ?? "Fetched page content",
529
+ content: data.data?.markdown ?? "",
530
+ uncertainty: [],
531
+ warnings: []
532
+ };
533
+ }
534
+ ensureKey() {
535
+ if (!this.apiKey) {
536
+ throw new Error("Firecrawl API key is missing. Set FIRECRAWL_API_KEY or configure providers.firecrawl.apiKey.");
537
+ }
538
+ }
539
+ }
540
+ const CODEX_SEARCH_URL = "https://chatgpt.com/backend-api/codex/alpha/search";
541
+ const MAX_CREDENTIAL_BYTES = 128 * 1024;
542
+ const MAX_SUCCESS_RESPONSE_BYTES = 4 * 1024 * 1024;
543
+ const MAX_ERROR_RESPONSE_BYTES = 64 * 1024;
544
+ class CodexProvider {
545
+ id = "codex";
546
+ name = "OpenAI Codex";
547
+ description = "ChatGPT Codex standalone web search";
548
+ searchable = true;
549
+ fetchable = false;
550
+ credentialFile;
551
+ timeoutMs;
552
+ constructor(config) {
553
+ this.credentialFile = configuredCredentialFile();
554
+ this.timeoutMs = Number.isFinite(config.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : 55e3;
555
+ }
556
+ available() {
557
+ return this.credentialFile !== void 0 && loadStaticCredential(this.credentialFile) !== void 0;
558
+ }
559
+ async search(request, signal) {
560
+ throwIfAborted$1(signal);
561
+ const credential = this.credentialFile === void 0 ? void 0 : loadStaticCredential(this.credentialFile);
562
+ if (!credential) {
563
+ throw new Error("OpenAI Codex credential file is not configured, missing or invalid");
564
+ }
565
+ const body = {
566
+ id: randomUUID(),
567
+ model: "gpt-5.6-sol",
568
+ input: [{
569
+ type: "message",
570
+ role: "user",
571
+ content: [{ type: "input_text", text: request.query }]
572
+ }],
573
+ commands: { search_query: [{ q: request.query }] },
574
+ settings: {
575
+ search_context_size: "medium",
576
+ allowed_callers: ["direct"],
577
+ external_web_access: false
578
+ },
579
+ max_output_tokens: 1e4
580
+ };
581
+ const requestTimeout = AbortSignal.timeout(this.timeoutMs);
582
+ const requestSignal = signal ? AbortSignal.any([signal, requestTimeout]) : requestTimeout;
583
+ let response;
584
+ try {
585
+ response = await fetch(CODEX_SEARCH_URL, {
586
+ method: "POST",
587
+ redirect: "error",
588
+ headers: {
589
+ Authorization: `Bearer ${credential.accessToken}`,
590
+ "chatgpt-account-id": credential.accountId,
591
+ "Content-Type": "application/json",
592
+ Accept: "application/json",
593
+ originator: "deepseek-harness"
594
+ },
595
+ body: JSON.stringify(body),
596
+ signal: requestSignal
597
+ });
598
+ } catch (error) {
599
+ throwIfAborted$1(signal);
600
+ if (requestTimeout.aborted) {
601
+ throw new Error(`OpenAI Codex search timed out after ${this.timeoutMs}ms`, { cause: error });
602
+ }
603
+ throw new Error("OpenAI Codex search request failed", { cause: error });
604
+ }
605
+ let payload;
606
+ try {
607
+ payload = await readBoundedJson(
608
+ response,
609
+ response.ok ? MAX_SUCCESS_RESPONSE_BYTES : MAX_ERROR_RESPONSE_BYTES,
610
+ requestSignal
611
+ );
612
+ } catch (error) {
613
+ throwIfAborted$1(signal);
614
+ if (requestTimeout.aborted) {
615
+ throw new Error(`OpenAI Codex search timed out after ${this.timeoutMs}ms`, { cause: error });
616
+ }
617
+ if (error instanceof ResponseTooLargeError) throw error;
618
+ throw new Error(`OpenAI Codex returned invalid JSON (HTTP ${response.status})`, { cause: error });
619
+ }
620
+ if (!response.ok) {
621
+ const detail = providerMessage(payload);
622
+ const suffix = detail ? `: ${detail}` : "";
623
+ if (response.status === 401 || response.status === 403) {
624
+ throw new ProviderAuthenticationError(
625
+ `OpenAI Codex credential is invalid or expired (HTTP ${response.status})${suffix}`
626
+ );
627
+ }
628
+ throw new Error(`OpenAI Codex search failed (HTTP ${response.status})${suffix}`);
629
+ }
630
+ return mapCodexSearchResponse(payload);
631
+ }
632
+ }
633
+ function configuredCredentialFile() {
634
+ const filename = readNonEmptyString(process.env.CODEX_CREDENTIAL_FILE);
635
+ return filename === void 0 ? void 0 : resolve(filename);
636
+ }
637
+ function loadStaticCredential(filename) {
638
+ try {
639
+ const stats = statSync(filename);
640
+ if (!stats.isFile() || stats.size <= 0 || stats.size > MAX_CREDENTIAL_BYTES) return void 0;
641
+ const value = JSON.parse(readFileSync(filename, "utf8"));
642
+ return parseStaticCredential(value);
643
+ } catch {
644
+ return void 0;
645
+ }
646
+ }
647
+ function mapCodexSearchResponse(value) {
648
+ if (!isRecord(value) || typeof value.output !== "string") {
649
+ throw new Error("OpenAI Codex returned a search response without string output");
650
+ }
651
+ if (value.results !== void 0 && !Array.isArray(value.results)) {
652
+ throw new Error("OpenAI Codex returned a search response with non-array results");
653
+ }
654
+ const items = [];
655
+ const seen = /* @__PURE__ */ new Set();
656
+ for (const item of value.results ?? []) {
657
+ if (!isRecord(item) || item.type !== "text_result") continue;
658
+ const url = citeableUrl(item.url);
659
+ if (!url || seen.has(url)) continue;
660
+ seen.add(url);
661
+ items.push({
662
+ title: readNonEmptyString(item.title) ?? url,
663
+ url,
664
+ snippet: readNonEmptyString(item.snippet) ?? ""
665
+ });
666
+ }
667
+ const summary = value.output;
668
+ return {
669
+ status: summary.length > 0 ? "ok" : "degraded",
670
+ source: "OpenAI Codex",
671
+ summary: summary || `Codex returned ${items.length} source(s) without summary text`,
672
+ items,
673
+ uncertainty: summary.length > 0 ? [] : ["Codex returned no summary text"]
674
+ };
675
+ }
676
+ function parseStaticCredential(value) {
677
+ if (!isRecord(value)) return void 0;
678
+ const candidates = [
679
+ value,
680
+ isRecord(value.credential) ? value.credential : void 0,
681
+ isRecord(value.tokens) ? value.tokens : void 0
682
+ ];
683
+ for (const candidate of candidates) {
684
+ if (!candidate) continue;
685
+ const accessToken = readNonEmptyString(candidate.access_token) ?? readNonEmptyString(candidate.access);
686
+ if (!accessToken) continue;
687
+ const accountId = readNonEmptyString(candidate.account_id) ?? readNonEmptyString(candidate.accountId) ?? accountIdFromToken(accessToken);
688
+ if (!accountId) continue;
689
+ return { accessToken, accountId };
690
+ }
691
+ return void 0;
692
+ }
693
+ function decodeJwtPayload(accessToken) {
694
+ try {
695
+ const parts = accessToken.split(".");
696
+ if (parts.length !== 3 || !parts[1]) return void 0;
697
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
698
+ return isRecord(payload) ? payload : void 0;
699
+ } catch {
700
+ return void 0;
701
+ }
702
+ }
703
+ function accountIdFromToken(accessToken) {
704
+ const auth = decodeJwtPayload(accessToken)?.["https://api.openai.com/auth"];
705
+ return isRecord(auth) ? readNonEmptyString(auth.chatgpt_account_id) : void 0;
706
+ }
707
+ class ResponseTooLargeError extends Error {
708
+ constructor(limit) {
709
+ super(`OpenAI Codex search response exceeded ${limit} bytes`);
710
+ this.name = "ResponseTooLargeError";
711
+ }
712
+ }
713
+ async function readBoundedJson(response, maxBytes, signal) {
714
+ const declaredLength = Number(response.headers.get("content-length"));
715
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
716
+ await response.body?.cancel().catch(() => {
717
+ });
718
+ throw new ResponseTooLargeError(maxBytes);
719
+ }
720
+ if (!response.body) throw new SyntaxError("empty response body");
721
+ const reader = response.body.getReader();
722
+ const chunks = [];
723
+ let total = 0;
724
+ try {
725
+ while (true) {
726
+ const { done, value } = await abortable(reader.read(), signal);
727
+ if (done) break;
728
+ if (!value) continue;
729
+ total += value.byteLength;
730
+ if (total > maxBytes) throw new ResponseTooLargeError(maxBytes);
731
+ chunks.push(value);
732
+ }
733
+ } catch (error) {
734
+ await reader.cancel().catch(() => {
735
+ });
736
+ throw error;
737
+ } finally {
738
+ reader.releaseLock();
739
+ }
740
+ return JSON.parse(Buffer.concat(chunks, total).toString("utf8"));
741
+ }
742
+ function abortable(operation, signal) {
743
+ if (signal.aborted) return Promise.reject(abortedError(signal));
744
+ return new Promise((resolve2, reject) => {
745
+ const onAbort = () => reject(abortedError(signal));
746
+ signal.addEventListener("abort", onAbort, { once: true });
747
+ operation.then(
748
+ (value) => {
749
+ signal.removeEventListener("abort", onAbort);
750
+ resolve2(value);
751
+ },
752
+ (error) => {
753
+ signal.removeEventListener("abort", onAbort);
754
+ reject(error);
755
+ }
756
+ );
757
+ });
758
+ }
759
+ function throwIfAborted$1(signal) {
760
+ if (signal?.aborted) throw abortedError(signal);
761
+ }
762
+ function abortedError(signal) {
763
+ return new Error("OpenAI Codex search aborted", { cause: signal.reason });
764
+ }
765
+ function providerMessage(value) {
766
+ if (!isRecord(value)) return void 0;
767
+ const raw = typeof value.error === "string" ? value.error : isRecord(value.error) && typeof value.error.message === "string" ? value.error.message : typeof value.message === "string" ? value.message : void 0;
768
+ return raw?.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[REDACTED]").slice(0, 1e3);
769
+ }
770
+ function citeableUrl(value) {
771
+ if (typeof value !== "string") return void 0;
772
+ try {
773
+ const url = new URL(value);
774
+ return url.protocol === "http:" || url.protocol === "https:" ? value : void 0;
775
+ } catch {
776
+ return void 0;
777
+ }
778
+ }
779
+ function isRecord(value) {
780
+ return typeof value === "object" && value !== null && !Array.isArray(value);
781
+ }
782
+ function readNonEmptyString(value) {
783
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
784
+ }
785
+ class DemoProvider {
786
+ id = "demo";
787
+ name = "Demo Provider";
788
+ description = "Returns canned results for integration testing without calling any external API.";
789
+ searchable = true;
790
+ fetchable = true;
791
+ constructor(_config) {
792
+ }
793
+ available() {
794
+ return true;
795
+ }
796
+ async search(request) {
797
+ return {
798
+ status: "ok",
799
+ source: this.name,
800
+ summary: `Demo search results for "${request.query}"`,
801
+ items: [
802
+ {
803
+ title: "Demo Result 1",
804
+ url: "https://example.com/demo1",
805
+ snippet: "This is a demo search result used to verify the DSH plugin wiring."
806
+ },
807
+ {
808
+ title: "Demo Result 2",
809
+ url: "https://example.com/demo2",
810
+ snippet: "Another canned result so citation cards have something to render."
811
+ }
812
+ ],
813
+ uncertainty: ["Demo mode: results are not from a real search engine."]
814
+ };
815
+ }
816
+ async fetch(request) {
817
+ return {
818
+ summary: `Demo fetch of ${request.url}`,
819
+ content: `# Demo Page
820
+
821
+ This is canned content for the URL "${request.url}".`,
822
+ links: [
823
+ { text: "Example", url: "https://example.com" }
824
+ ],
825
+ uncertainty: ["Demo mode: content is not from a real web page."],
826
+ warnings: []
827
+ };
828
+ }
829
+ }
830
+ const fetchSchema = {
831
+ type: "object",
832
+ properties: {
833
+ summary: { type: "string" },
834
+ content: { type: "string" },
835
+ links: {
836
+ type: "array",
837
+ items: {
838
+ type: "object",
839
+ properties: {
840
+ text: { type: "string" },
841
+ url: { type: "string" }
842
+ },
843
+ required: ["text", "url"]
844
+ }
845
+ },
846
+ uncertainty: {
847
+ type: "array",
848
+ items: { type: "string" }
849
+ },
850
+ warnings: {
851
+ type: "array",
852
+ items: { type: "string" }
853
+ }
854
+ },
855
+ required: ["summary", "content", "uncertainty"]
856
+ };
857
+ const FALLBACK_ATTEMPT_TIMEOUT_MS = 2e4;
858
+ function resolveCandidateIds(registry, fallbackChain) {
859
+ let activeId;
860
+ try {
861
+ activeId = registry.getActive().id;
862
+ } catch {
863
+ }
864
+ const configured = fallbackChain === void 0 ? registry.list() : fallbackChain;
865
+ const seen = /* @__PURE__ */ new Set();
866
+ const candidates = [];
867
+ for (const id of activeId ? [activeId, ...configured] : configured) {
868
+ if (seen.has(id)) continue;
869
+ seen.add(id);
870
+ candidates.push(id);
871
+ }
872
+ return candidates;
873
+ }
874
+ function createAttemptContext(parent, timeoutMs) {
875
+ const controller = new AbortController();
876
+ let didTimeOut = false;
877
+ let timer;
878
+ let onParentAbort;
879
+ if (parent?.aborted) {
880
+ controller.abort(parent.reason);
881
+ } else {
882
+ if (parent) {
883
+ onParentAbort = () => controller.abort(parent.reason);
884
+ parent.addEventListener("abort", onParentAbort, { once: true });
885
+ }
886
+ timer = setTimeout(() => {
887
+ didTimeOut = true;
888
+ controller.abort(new Error(`fallback attempt timed out after ${timeoutMs}ms`));
889
+ }, timeoutMs);
890
+ }
891
+ return {
892
+ signal: controller.signal,
893
+ timedOut: () => didTimeOut,
894
+ dispose() {
895
+ if (timer !== void 0) clearTimeout(timer);
896
+ if (parent && onParentAbort) parent.removeEventListener("abort", onParentAbort);
897
+ }
898
+ };
899
+ }
900
+ function raceWithSignal(operation, signal) {
901
+ if (!signal) return operation;
902
+ if (signal.aborted) {
903
+ operation.catch(() => {
904
+ });
905
+ return Promise.reject(signal.reason);
906
+ }
907
+ return new Promise((resolve2, reject) => {
908
+ const onAbort = () => {
909
+ signal.removeEventListener("abort", onAbort);
910
+ reject(signal.reason);
911
+ };
912
+ signal.addEventListener("abort", onAbort, { once: true });
913
+ operation.then(
914
+ (value) => {
915
+ signal.removeEventListener("abort", onAbort);
916
+ resolve2(value);
917
+ },
918
+ (error) => {
919
+ signal.removeEventListener("abort", onAbort);
920
+ reject(error);
921
+ }
922
+ );
923
+ });
924
+ }
925
+ function throwIfAborted(signal) {
926
+ if (signal?.aborted) throw signal.reason;
927
+ }
928
+ const READ_PAGE_TIMEOUT_MS = 18e4;
929
+ const RENDER_CONTENT_CAP = 2e4;
930
+ const RENDER_LINK_CAP = 20;
931
+ function registerReadPageTool(ctx, registry, cooldown, fallbackChain) {
932
+ if (typeof ctx.tools?.register !== "function") {
933
+ console.error("[search-providers] ctx.tools.register is not available; read_page tool skipped");
934
+ return;
935
+ }
936
+ ctx.tools.register({
937
+ name: "read_page",
938
+ description: 'Read one web page through the configured search provider. Use when a message references a specific http(s) URL whose content matters: docs, an article, a changelog, a thread. Returns structured evidence with a summary, the extracted content, outgoing links, uncertainty, and operational warnings. Pass "query" to focus the reading on one question.',
939
+ parameters: {
940
+ type: "object",
941
+ properties: {
942
+ url: {
943
+ type: "string",
944
+ description: "The http(s) URL to read"
945
+ },
946
+ query: {
947
+ type: "string",
948
+ description: 'Optional question to focus the reading on (e.g. "what are the rate limits")'
949
+ }
950
+ },
951
+ required: ["url"]
952
+ },
953
+ output: {
954
+ schema: fetchSchema,
955
+ render: (_args, value) => [
956
+ { type: "text", text: renderFetchEvidence(value) }
957
+ ]
958
+ },
959
+ timeoutMs: READ_PAGE_TIMEOUT_MS + 2e4,
960
+ isConcurrencySafe: () => true,
961
+ presentCall: (args) => ({
962
+ card: "generic",
963
+ title: "read_page",
964
+ kind: "fetch",
965
+ rawInput: args
966
+ }),
967
+ async execute(args, exec) {
968
+ if (typeof args?.url !== "string" || !/^https?:\/\//i.test(args.url.trim())) {
969
+ throw new Error('read_page needs an http(s) "url".');
970
+ }
971
+ const request = {
972
+ url: args.url.trim(),
973
+ query: args.query,
974
+ format: "markdown"
975
+ };
976
+ return fetchWithFallback(registry, cooldown, fallbackChain, request, exec.signal);
977
+ }
978
+ });
979
+ }
980
+ function resolveFetchCandidateIds(registry, fallbackChain) {
981
+ return resolveCandidateIds(registry, fallbackChain).filter((id) => {
982
+ const provider = registry.get(id);
983
+ return provider !== void 0 && provider.fetchable !== false && typeof provider.fetch === "function";
984
+ });
985
+ }
986
+ async function fetchWithFallback(registry, cooldown, fallbackChain, request, signal) {
987
+ throwIfAborted(signal);
988
+ const candidates = resolveFetchCandidateIds(registry, fallbackChain);
989
+ const attempted = [];
990
+ const attemptedIds = /* @__PURE__ */ new Set();
991
+ let lastError;
992
+ while (true) {
993
+ throwIfAborted(signal);
994
+ const { ready } = cooldown.filter(candidates, "fetch");
995
+ const id = ready.find((candidateId) => {
996
+ if (attemptedIds.has(candidateId)) return false;
997
+ const provider2 = registry.get(candidateId);
998
+ return provider2?.available() && provider2.fetchable !== false && typeof provider2.fetch === "function";
999
+ });
1000
+ if (id === void 0) break;
1001
+ const provider = registry.get(id);
1002
+ attemptedIds.add(id);
1003
+ const attempt = attempted.length === 0 ? void 0 : createAttemptContext(signal, FALLBACK_ATTEMPT_TIMEOUT_MS);
1004
+ try {
1005
+ const attemptSignal = attempt?.signal ?? signal;
1006
+ const result = await raceWithSignal(provider.fetch(request, attemptSignal), attemptSignal);
1007
+ throwIfAborted(signal);
1008
+ if (attempt?.timedOut()) throw attempt.signal.reason;
1009
+ const { cooling: cooling2, disabled: disabled2 } = cooldown.filter(candidates, "fetch");
1010
+ if (attempted.length > 0) {
1011
+ result.warnings = result.warnings ?? [];
1012
+ result.warnings.push(`Fallback used after ${attempted.join(", ")} failed`);
1013
+ }
1014
+ if (cooling2.length > 0) {
1015
+ result.warnings = result.warnings ?? [];
1016
+ result.warnings.push(`Cooling providers skipped: ${cooling2.join(", ")}`);
1017
+ }
1018
+ if (disabled2.length > 0) {
1019
+ result.warnings = result.warnings ?? [];
1020
+ result.warnings.push(`Providers disabled until restart: ${disabled2.join(", ")}`);
1021
+ }
1022
+ return result;
1023
+ } catch (error) {
1024
+ throwIfAborted(signal);
1025
+ const caught = error instanceof Error ? error : new Error(String(error));
1026
+ lastError = caught instanceof ProviderAuthenticationError ? caught : attempt?.timedOut() ? new Error(`fallback attempt timed out after ${FALLBACK_ATTEMPT_TIMEOUT_MS}ms`, { cause: caught }) : caught;
1027
+ attempted.push(id);
1028
+ console.warn(`[search-providers] provider '${id}' fetch failed, trying next: ${lastError.message}`);
1029
+ cooldown.record(id, "fetch", lastError);
1030
+ } finally {
1031
+ attempt?.dispose();
1032
+ }
1033
+ }
1034
+ const { cooling, disabled } = cooldown.filter(candidates, "fetch");
1035
+ const coolingIds = cooling.length > 0 ? ` [cooling: ${cooling.join(", ")}]` : "";
1036
+ const disabledIds = disabled.length > 0 ? ` [disabled until restart: ${disabled.join(", ")}]` : "";
1037
+ throw new Error(
1038
+ `No fetch provider succeeded${attempted.length > 0 ? ` (tried: ${attempted.join(", ")})` : ""}${coolingIds}${disabledIds}. ${lastError?.message ?? "No provider available."}`,
1039
+ { cause: lastError }
1040
+ );
1041
+ }
1042
+ function renderFetchEvidence(value) {
1043
+ const lines = [String(value.summary ?? "")];
1044
+ const content = String(value.content ?? "").trim();
1045
+ if (content) {
1046
+ lines.push("", "Content:", content.length > RENDER_CONTENT_CAP ? `${content.slice(0, RENDER_CONTENT_CAP)}…` : content);
1047
+ }
1048
+ const links = Array.isArray(value.links) ? value.links.slice(0, RENDER_LINK_CAP) : [];
1049
+ if (links.length > 0) {
1050
+ lines.push("", "Links:");
1051
+ for (const link of links) {
1052
+ const text = typeof link === "object" && link !== null ? String(link.text ?? "") : "";
1053
+ const url = typeof link === "object" && link !== null ? String(link.url ?? "") : "";
1054
+ lines.push(`- ${text} — ${url}`);
1055
+ }
1056
+ }
1057
+ const uncertainty = Array.isArray(value.uncertainty) ? value.uncertainty : [];
1058
+ if (uncertainty.length > 0) {
1059
+ lines.push("", `Uncertain: ${uncertainty.join("; ")}`);
1060
+ }
1061
+ const warnings = Array.isArray(value.warnings) ? value.warnings : [];
1062
+ if (warnings.length > 0) {
1063
+ lines.push("", `Warnings: ${warnings.join("; ")}`);
1064
+ }
1065
+ return lines.filter(Boolean).join("\n");
1066
+ }
1067
+ function registerWebSearchProvider(ctx, registry, cooldown, fallbackChain) {
1068
+ if (typeof ctx.web?.registerSearchProvider !== "function") {
1069
+ console.error("[search-providers] ctx.web.registerSearchProvider is not available; web_search provider skipped");
1070
+ return;
1071
+ }
1072
+ ctx.web.registerSearchProvider({
1073
+ id: "search-providers",
1074
+ available: () => anyProviderAvailable(registry, cooldown, fallbackChain),
1075
+ async search(request, signal) {
1076
+ const searchRequest = {
1077
+ query: request.query,
1078
+ maxResults: request.maxResults,
1079
+ recencyDays: request.recencyDays,
1080
+ purpose: request.purpose,
1081
+ location: request.location,
1082
+ language: request.language
1083
+ };
1084
+ const { result, attempted, cooling, disabled } = await searchWithFallback(
1085
+ registry,
1086
+ cooldown,
1087
+ fallbackChain,
1088
+ searchRequest,
1089
+ signal
1090
+ );
1091
+ return {
1092
+ content: renderContent(result, attempted, cooling, disabled),
1093
+ sources: result.items.map((item) => ({
1094
+ url: item.url,
1095
+ ...item.title ? { title: item.title } : {},
1096
+ ...item.snippet ? { snippet: item.snippet } : {},
1097
+ ...item.publishedAt ? { publishedAt: item.publishedAt } : {}
1098
+ })),
1099
+ truncated: false
1100
+ };
1101
+ }
1102
+ });
1103
+ }
1104
+ function resolveSearchCandidateIds(registry, fallbackChain) {
1105
+ return resolveCandidateIds(registry, fallbackChain).filter((id) => {
1106
+ const provider = registry.get(id);
1107
+ return provider !== void 0 && provider.searchable !== false;
1108
+ });
1109
+ }
1110
+ function anyProviderAvailable(registry, cooldown, fallbackChain) {
1111
+ const candidates = resolveSearchCandidateIds(registry, fallbackChain);
1112
+ const { ready } = cooldown.filter(candidates, "search");
1113
+ return ready.some((id) => {
1114
+ const provider = registry.get(id);
1115
+ return provider?.available() && provider.searchable !== false;
1116
+ });
1117
+ }
1118
+ async function searchWithFallback(registry, cooldown, fallbackChain, request, signal) {
1119
+ throwIfAborted(signal);
1120
+ const candidates = resolveSearchCandidateIds(registry, fallbackChain);
1121
+ const attempted = [];
1122
+ const attemptedIds = /* @__PURE__ */ new Set();
1123
+ let lastError;
1124
+ while (true) {
1125
+ throwIfAborted(signal);
1126
+ const { ready } = cooldown.filter(candidates, "search");
1127
+ const id = ready.find((candidateId) => {
1128
+ if (attemptedIds.has(candidateId)) return false;
1129
+ const provider2 = registry.get(candidateId);
1130
+ return provider2?.available() && provider2.searchable !== false;
1131
+ });
1132
+ if (id === void 0) break;
1133
+ const provider = registry.get(id);
1134
+ attemptedIds.add(id);
1135
+ const attempt = attempted.length === 0 ? void 0 : createAttemptContext(signal, FALLBACK_ATTEMPT_TIMEOUT_MS);
1136
+ try {
1137
+ const attemptSignal = attempt?.signal ?? signal;
1138
+ const result = await raceWithSignal(provider.search(request, attemptSignal), attemptSignal);
1139
+ throwIfAborted(signal);
1140
+ if (attempt?.timedOut()) throw attempt.signal.reason;
1141
+ const { cooling: cooling2, disabled: disabled2 } = cooldown.filter(candidates, "search");
1142
+ return { result, attempted, cooling: cooling2, disabled: disabled2 };
1143
+ } catch (error) {
1144
+ throwIfAborted(signal);
1145
+ const caught = error instanceof Error ? error : new Error(String(error));
1146
+ lastError = caught instanceof ProviderAuthenticationError ? caught : attempt?.timedOut() ? new Error(`fallback attempt timed out after ${FALLBACK_ATTEMPT_TIMEOUT_MS}ms`, { cause: caught }) : caught;
1147
+ attempted.push(id);
1148
+ console.warn(`[search-providers] provider '${id}' search failed, trying next: ${lastError.message}`);
1149
+ cooldown.record(id, "search", lastError);
1150
+ } finally {
1151
+ attempt?.dispose();
1152
+ }
1153
+ }
1154
+ const { cooling, disabled } = cooldown.filter(candidates, "search");
1155
+ const coolingIds = cooling.length > 0 ? ` [cooling: ${cooling.join(", ")}]` : "";
1156
+ const disabledIds = disabled.length > 0 ? ` [disabled until restart: ${disabled.join(", ")}]` : "";
1157
+ throw new Error(
1158
+ `No search provider succeeded${attempted.length > 0 ? ` (tried: ${attempted.join(", ")})` : ""}${coolingIds}${disabledIds}. ${lastError?.message ?? "No provider available."}`,
1159
+ { cause: lastError }
1160
+ );
1161
+ }
1162
+ function renderContent(result, attempted, cooling, disabled) {
1163
+ const lines = [`[Search provider: ${result.source}]`, result.summary];
1164
+ if (attempted.length > 0) {
1165
+ lines.push(`[Fallback used after ${attempted.join(", ")} failed]`);
1166
+ }
1167
+ if (cooling.length > 0) {
1168
+ lines.push(`[Cooling: ${cooling.join(", ")}]`);
1169
+ }
1170
+ if (disabled.length > 0) {
1171
+ lines.push(`[Disabled until restart: ${disabled.join(", ")}]`);
1172
+ }
1173
+ if (result.uncertainty.length > 0) {
1174
+ lines.push(`Uncertain: ${result.uncertainty.join("; ")}`);
1175
+ }
1176
+ return lines.filter(Boolean).join("\n");
1177
+ }
1178
+ const name = "search-providers";
1179
+ const inject = ["tools", "web"];
1180
+ const DEFAULT_PROVIDER_ORDER = ["codex", "tinyfish", "tavily", "exa", "firecrawl"];
1181
+ function apply(ctx, rawConfig = {}) {
1182
+ const registry = new ProviderRegistry();
1183
+ const cooldown = new CooldownController();
1184
+ const demoMode = rawConfig.provider === "demo" || process.env.SEARCH_PROVIDERS_DEMO === "1";
1185
+ registry.register(new CodexProvider(resolveProviderConfig(rawConfig, "codex", "CODEX")));
1186
+ registry.register(new TinyfishProvider(resolveProviderConfig(rawConfig, "tinyfish", "TINYFISH")));
1187
+ registry.register(new TavilyProvider(resolveProviderConfig(rawConfig, "tavily", "TAVILY")));
1188
+ registry.register(new ExaProvider(resolveProviderConfig(rawConfig, "exa", "EXA")));
1189
+ registry.register(new FirecrawlProvider(resolveProviderConfig(rawConfig, "firecrawl", "FIRECRAWL")));
1190
+ if (demoMode) {
1191
+ registry.register(new DemoProvider(resolveProviderConfig(rawConfig, "tinyfish", "TINYFISH")));
1192
+ }
1193
+ const activeProvider = rawConfig.provider ?? (demoMode ? "demo" : void 0);
1194
+ if (activeProvider) {
1195
+ try {
1196
+ registry.setActive(activeProvider);
1197
+ } catch (error) {
1198
+ console.warn(`[search-providers] configured provider '${activeProvider}' is not registered; falling back to '${registry.list()[0]}'`);
1199
+ }
1200
+ }
1201
+ const fallbackChain = rawConfig.fallbackChain ?? DEFAULT_PROVIDER_ORDER;
1202
+ registerWebSearchProvider(ctx, registry, cooldown, fallbackChain);
1203
+ registerReadPageTool(ctx, registry, cooldown, fallbackChain);
1204
+ console.log(`[search-providers] loaded with active provider: ${registry.getActive().id}, fallback after active: [${fallbackChain.join(", ")}]`);
1205
+ }
1206
+ export {
1207
+ apply,
1208
+ inject,
1209
+ name
1210
+ };
1211
+ //# sourceMappingURL=index.js.map