@khanglvm/llm-router 2.0.0-beta.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +163 -426
  3. package/package.json +3 -3
  4. package/src/cli/router-module.js +2773 -2587
  5. package/src/cli-entry.js +32 -103
  6. package/src/node/activity-log.js +119 -0
  7. package/src/node/coding-tool-config.js +85 -11
  8. package/src/node/config-workflows.js +51 -12
  9. package/src/node/instance-state.js +1 -1
  10. package/src/node/litellm-context-catalog.js +184 -0
  11. package/src/node/local-server.js +23 -3
  12. package/src/node/port-reclaim.js +2 -2
  13. package/src/node/start-command.js +22 -22
  14. package/src/node/startup-manager.js +3 -3
  15. package/src/node/web-command.js +1 -1
  16. package/src/node/web-console-assets.js +1 -1
  17. package/src/node/web-console-client.js +34 -29
  18. package/src/node/web-console-server.js +420 -38
  19. package/src/node/web-console-styles.generated.js +1 -1
  20. package/src/node/web-console-ui/buffered-text-input.js +133 -0
  21. package/src/node/web-console-ui/config-editor-utils.js +57 -4
  22. package/src/node/web-console-ui/dropdown-placement.js +153 -0
  23. package/src/node/web-console-ui/select-search-utils.js +6 -0
  24. package/src/node/web-console-ui/transient-integer-input-utils.js +12 -0
  25. package/src/runtime/balancer.js +78 -1
  26. package/src/runtime/codex-request-transformer.js +16 -7
  27. package/src/runtime/config.js +448 -12
  28. package/src/runtime/handler/amp-response.js +5 -3
  29. package/src/runtime/handler/amp-web-search.js +2232 -0
  30. package/src/runtime/handler/fallback.js +30 -2
  31. package/src/runtime/handler/provider-call.js +353 -36
  32. package/src/runtime/handler/provider-translation.js +14 -0
  33. package/src/runtime/handler/request.js +128 -2
  34. package/src/runtime/handler/route-debug.js +36 -0
  35. package/src/runtime/handler.js +210 -20
  36. package/src/runtime/subscription-provider.js +1 -1
  37. package/src/shared/coding-tool-bindings.js +49 -0
  38. package/src/shared/local-router-defaults.js +62 -0
  39. package/src/translator/request/claude-to-openai.js +43 -0
@@ -0,0 +1,2232 @@
1
+ import { FORMATS } from "../../translator/index.js";
2
+ import { buildTimeoutSignal } from "../../shared/timeout-signal.js";
3
+ import { commitRouteSelection, rankRouteCandidates } from "../balancer.js";
4
+ import { buildCandidateKey } from "../state-store.js";
5
+ import { consumeCandidateRateLimits, resolveWindowRange } from "../rate-limits.js";
6
+ import {
7
+ buildProviderHeaders,
8
+ resolveProviderFormat,
9
+ resolveProviderUrl,
10
+ resolveRouteReference
11
+ } from "../config.js";
12
+ import { isSubscriptionProvider, makeSubscriptionProviderCall } from "../subscription-provider.js";
13
+
14
+ const SEARCH_TOOL_NAME = "web_search";
15
+ const READ_WEB_PAGE_TOOL_NAME = "read_web_page";
16
+ const DEFAULT_SEARCH_COUNT = 5;
17
+ const MIN_SEARCH_COUNT = 1;
18
+ const MAX_SEARCH_COUNT = 20;
19
+ const DEFAULT_TIMEOUT_MS = 15_000;
20
+ const MAX_READ_WEB_PAGE_TEXT_CHARS = 24_000;
21
+ const MAX_READ_WEB_PAGE_TABLES = 8;
22
+ const MAX_READ_WEB_PAGE_TABLE_ROWS = 40;
23
+ const SEARCH_ROUTE_KEY = "route:amp-web-search";
24
+ const HOSTED_WEB_SEARCH_TEST_QUERY = "Find the sunrise time in Paris today and cite the source.";
25
+ const SEARCH_SYSTEM_INSTRUCTION = [
26
+ "You just performed web searches and received real, current search results.",
27
+ "Synthesize the results into a direct answer with clear headings or bullets when helpful.",
28
+ "Mention source names, but do not include raw URLs unless the user explicitly asks for them.",
29
+ "Include specific dates, names, and facts when the results contain them.",
30
+ "Do not say that web search is unavailable."
31
+ ].join(" ");
32
+
33
+ const AMP_WEB_SEARCH_PROVIDER_DEFINITIONS = Object.freeze([
34
+ Object.freeze({ id: "brave", label: "Brave", type: "api-key", defaultLimit: 1000 }),
35
+ Object.freeze({ id: "tavily", label: "Tavily", type: "api-key", defaultLimit: 1000 }),
36
+ Object.freeze({ id: "exa", label: "Exa", type: "api-key", defaultLimit: 1000 }),
37
+ Object.freeze({ id: "searxng", label: "SearXNG", type: "url", defaultLimit: 0 })
38
+ ]);
39
+
40
+ const AMP_WEB_SEARCH_PROVIDER_META = new Map(
41
+ AMP_WEB_SEARCH_PROVIDER_DEFINITIONS.map((entry) => [entry.id, entry])
42
+ );
43
+
44
+ const SEARCH_BACKENDS = Object.freeze([
45
+ "brave",
46
+ "tavily",
47
+ "exa",
48
+ "searxng",
49
+ "gnews",
50
+ "ddghtml",
51
+ "bing",
52
+ "ddglite"
53
+ ]);
54
+
55
+ const FREE_FALLBACK_BACKENDS = Object.freeze(["gnews", "ddghtml", "bing", "ddglite"]);
56
+ const inMemoryBucketUsage = new Map();
57
+ const inMemoryRouteCursor = new Map();
58
+
59
+ const WEB_SEARCH_FUNCTION_PARAMETERS = {
60
+ type: "object",
61
+ properties: {
62
+ query: {
63
+ type: "string",
64
+ description: "The search query to run against the web."
65
+ }
66
+ },
67
+ required: ["query"],
68
+ additionalProperties: false
69
+ };
70
+
71
+ const READ_WEB_PAGE_FUNCTION_PARAMETERS = {
72
+ type: "object",
73
+ properties: {
74
+ url: {
75
+ type: "string",
76
+ description: "The absolute URL of the web page to read."
77
+ }
78
+ },
79
+ required: ["url"],
80
+ additionalProperties: true
81
+ };
82
+
83
+ const OPENAI_WEB_SEARCH_TOOL = Object.freeze({
84
+ type: "function",
85
+ function: {
86
+ name: SEARCH_TOOL_NAME,
87
+ description: "Search the web for current information, news, documentation, or real-time facts.",
88
+ parameters: WEB_SEARCH_FUNCTION_PARAMETERS
89
+ }
90
+ });
91
+
92
+ const CLAUDE_WEB_SEARCH_TOOL = Object.freeze({
93
+ name: SEARCH_TOOL_NAME,
94
+ description: "Search the web for current information, news, documentation, or real-time facts.",
95
+ input_schema: WEB_SEARCH_FUNCTION_PARAMETERS
96
+ });
97
+
98
+ const OPENAI_READ_WEB_PAGE_TOOL = Object.freeze({
99
+ type: "function",
100
+ function: {
101
+ name: READ_WEB_PAGE_TOOL_NAME,
102
+ description: "Fetch and extract the readable text and table content from a web page URL.",
103
+ parameters: READ_WEB_PAGE_FUNCTION_PARAMETERS
104
+ }
105
+ });
106
+
107
+ const CLAUDE_READ_WEB_PAGE_TOOL = Object.freeze({
108
+ name: READ_WEB_PAGE_TOOL_NAME,
109
+ description: "Fetch and extract the readable text and table content from a web page URL.",
110
+ input_schema: READ_WEB_PAGE_FUNCTION_PARAMETERS
111
+ });
112
+
113
+ function toInteger(value, fallback, { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = {}) {
114
+ const parsed = Number.parseInt(String(value ?? ""), 10);
115
+ if (!Number.isFinite(parsed)) return fallback;
116
+ return Math.min(max, Math.max(min, parsed));
117
+ }
118
+
119
+ function toTrimmedString(value) {
120
+ const normalized = String(value ?? "").trim();
121
+ return normalized || undefined;
122
+ }
123
+
124
+ function parseJsonSafely(raw, fallback = null) {
125
+ if (typeof raw !== "string" || !raw.trim()) return fallback;
126
+ try {
127
+ return JSON.parse(raw);
128
+ } catch {
129
+ return fallback;
130
+ }
131
+ }
132
+
133
+ function buildSearchTimeoutSignal(timeoutMs = DEFAULT_TIMEOUT_MS) {
134
+ const timeoutControl = buildTimeoutSignal(timeoutMs);
135
+ return {
136
+ signal: timeoutControl.signal,
137
+ cleanup: timeoutControl.cleanup
138
+ };
139
+ }
140
+
141
+ function runFetchWithTimeout(url, init = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
142
+ const timeoutControl = buildSearchTimeoutSignal(timeoutMs);
143
+ return fetch(url, {
144
+ ...init,
145
+ ...(timeoutControl.signal ? { signal: timeoutControl.signal } : {})
146
+ }).finally(() => timeoutControl.cleanup());
147
+ }
148
+
149
+ function stripHtml(text) {
150
+ return String(text || "")
151
+ .replace(/<[^>]+>/g, " ")
152
+ .replace(/&nbsp;/gi, " ")
153
+ .replace(/&amp;/gi, "&")
154
+ .replace(/&#x27;|&#39;/gi, "'")
155
+ .replace(/&quot;/gi, "\"")
156
+ .replace(/&lt;/gi, "<")
157
+ .replace(/&gt;/gi, ">")
158
+ .replace(/\s+/g, " ")
159
+ .trim();
160
+ }
161
+
162
+ function clampText(value, maxChars = MAX_READ_WEB_PAGE_TEXT_CHARS) {
163
+ const normalized = String(value || "").trim();
164
+ if (!normalized) return "";
165
+ if (normalized.length <= maxChars) return normalized;
166
+ return `${normalized.slice(0, Math.max(0, maxChars - 15)).trimEnd()}\n[truncated]`;
167
+ }
168
+
169
+ function stripHtmlPreservingLines(text) {
170
+ const normalized = String(text || "")
171
+ .replace(/<(br|hr)\s*\/?>/gi, "\n")
172
+ .replace(/<\/(p|div|section|article|main|header|footer|aside|nav|li|tr|h1|h2|h3|h4|h5|h6|ul|ol|table)>/gi, "\n")
173
+ .replace(/<li\b[^>]*>/gi, "- ")
174
+ .replace(/<\/t[dh]>/gi, " | ")
175
+ .replace(/<t[dh]\b[^>]*>/gi, " ")
176
+ .replace(/<[^>]+>/g, " ");
177
+ return normalized
178
+ .replace(/&nbsp;/gi, " ")
179
+ .replace(/&amp;/gi, "&")
180
+ .replace(/&#x27;|&#39;/gi, "'")
181
+ .replace(/&quot;/gi, "\"")
182
+ .replace(/&lt;/gi, "<")
183
+ .replace(/&gt;/gi, ">")
184
+ .split(/\n+/)
185
+ .map((line) => line.replace(/\s+/g, " ").trim())
186
+ .filter(Boolean)
187
+ .join("\n")
188
+ .trim();
189
+ }
190
+
191
+ function extractHtmlTitle(html) {
192
+ return stripHtml((String(html || "").match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || "");
193
+ }
194
+
195
+ function extractPreferredHtmlSection(html) {
196
+ const normalized = String(html || "");
197
+ for (const tagName of ["main", "article", "body"]) {
198
+ const match = normalized.match(new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "i"));
199
+ if (match?.[1]) return match[1];
200
+ }
201
+ return normalized;
202
+ }
203
+
204
+ function removeHtmlNoise(html) {
205
+ return String(html || "")
206
+ .replace(/<script\b[\s\S]*?<\/script>/gi, " ")
207
+ .replace(/<style\b[\s\S]*?<\/style>/gi, " ")
208
+ .replace(/<noscript\b[\s\S]*?<\/noscript>/gi, " ")
209
+ .replace(/<svg\b[\s\S]*?<\/svg>/gi, " ")
210
+ .replace(/<template\b[\s\S]*?<\/template>/gi, " ");
211
+ }
212
+
213
+ function extractHtmlTables(html) {
214
+ const tableBlocks = [...String(html || "").matchAll(/<table\b[\s\S]*?<\/table>/gi)].slice(0, MAX_READ_WEB_PAGE_TABLES);
215
+ const tables = [];
216
+
217
+ for (let index = 0; index < tableBlocks.length; index += 1) {
218
+ const tableHtml = tableBlocks[index]?.[0] || "";
219
+ const caption = stripHtml((tableHtml.match(/<caption\b[^>]*>([\s\S]*?)<\/caption>/i) || [])[1] || "");
220
+ const rowBlocks = [...tableHtml.matchAll(/<tr\b[\s\S]*?<\/tr>/gi)].slice(0, MAX_READ_WEB_PAGE_TABLE_ROWS);
221
+ const rows = rowBlocks.map((rowBlock) => {
222
+ const rowHtml = rowBlock?.[0] || "";
223
+ return [...rowHtml.matchAll(/<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]>/gi)]
224
+ .map((cellMatch) => stripHtml(cellMatch?.[1] || ""))
225
+ .filter((cell) => cell.length > 0);
226
+ }).filter((row) => row.length > 0);
227
+
228
+ if (rows.length === 0) continue;
229
+ const formattedRows = rows.map((row) => `| ${row.join(" | ")} |`).join("\n");
230
+ tables.push([
231
+ caption ? `Table ${tables.length + 1}: ${caption}` : `Table ${tables.length + 1}:`,
232
+ formattedRows
233
+ ].join("\n"));
234
+ }
235
+
236
+ return tables;
237
+ }
238
+
239
+ function formatReadWebPageHtml(url, html) {
240
+ const cleanHtml = removeHtmlNoise(html);
241
+ const title = extractHtmlTitle(cleanHtml);
242
+ const mainSection = extractPreferredHtmlSection(cleanHtml);
243
+ const tables = extractHtmlTables(mainSection);
244
+ const pageText = clampText(stripHtmlPreservingLines(mainSection));
245
+ const sections = [
246
+ `URL: ${url}`
247
+ ];
248
+
249
+ if (title) sections.push(`Title: ${title}`);
250
+ if (tables.length > 0) sections.push(`Tables:\n${tables.join("\n\n")}`);
251
+ if (pageText) sections.push(`Page text:\n${pageText}`);
252
+
253
+ return sections.join("\n\n").trim();
254
+ }
255
+
256
+ function formatReadWebPageBody(url, bodyText, contentType = "") {
257
+ const sections = [
258
+ `URL: ${url}`
259
+ ];
260
+ const normalizedContentType = String(contentType || "").trim();
261
+ if (normalizedContentType) sections.push(`Content-Type: ${normalizedContentType}`);
262
+ sections.push(`Page text:\n${clampText(bodyText) || "[No readable page text extracted]"}`);
263
+ return sections.join("\n\n").trim();
264
+ }
265
+
266
+ function looksLikeHtml(contentType, bodyText) {
267
+ const normalizedContentType = String(contentType || "").toLowerCase();
268
+ if (normalizedContentType.includes("text/html") || normalizedContentType.includes("application/xhtml+xml")) {
269
+ return true;
270
+ }
271
+ const sample = String(bodyText || "").trim().slice(0, 512).toLowerCase();
272
+ return sample.startsWith("<!doctype html") || sample.startsWith("<html") || sample.includes("<body");
273
+ }
274
+
275
+ function formatSearchResults(results) {
276
+ const lines = [];
277
+ for (let index = 0; index < results.length; index += 1) {
278
+ const result = results[index];
279
+ if (!result) continue;
280
+ const title = String(result.title || "").trim() || "(untitled)";
281
+ const url = String(result.url || "").trim();
282
+ const snippet = String(result.snippet || "").trim() || "(no description)";
283
+ lines.push(`[${index + 1}] ${title}`);
284
+ if (url) lines.push(`URL: ${url}`);
285
+ lines.push(snippet);
286
+ lines.push("");
287
+ }
288
+ return lines.join("\n").trim();
289
+ }
290
+
291
+ function hasSearchToolType(type) {
292
+ const normalized = String(type || "").trim().toLowerCase();
293
+ if (!normalized) return false;
294
+ return normalized === SEARCH_TOOL_NAME
295
+ || normalized.startsWith("web_search_preview")
296
+ || normalized === "web_search_20250305";
297
+ }
298
+
299
+ function hasSearchToolName(name) {
300
+ const normalized = String(name || "").trim().toLowerCase();
301
+ return normalized === SEARCH_TOOL_NAME || normalized === "web_search_preview";
302
+ }
303
+
304
+ function hasReadWebPageToolName(name) {
305
+ return String(name || "").trim().toLowerCase() === READ_WEB_PAGE_TOOL_NAME;
306
+ }
307
+
308
+ function hasInterceptableTool(tool) {
309
+ if (!tool || typeof tool !== "object") return false;
310
+ return hasSearchToolType(tool.type)
311
+ || hasSearchToolName(tool.name)
312
+ || hasSearchToolName(tool.function?.name)
313
+ || hasReadWebPageToolName(tool.name)
314
+ || hasReadWebPageToolName(tool.function?.name);
315
+ }
316
+
317
+ function hasInterceptableToolName(name) {
318
+ return hasSearchToolName(name) || hasReadWebPageToolName(name);
319
+ }
320
+
321
+ function getToolName(tool) {
322
+ if (!tool || typeof tool !== "object") return "";
323
+ if (hasReadWebPageToolName(tool.name) || hasReadWebPageToolName(tool.function?.name)) {
324
+ return READ_WEB_PAGE_TOOL_NAME;
325
+ }
326
+ if (hasSearchToolType(tool.type) || hasSearchToolName(tool.name) || hasSearchToolName(tool.function?.name)) {
327
+ return SEARCH_TOOL_NAME;
328
+ }
329
+ return "";
330
+ }
331
+
332
+ function dedupeStrings(values = []) {
333
+ return [...new Set(
334
+ (Array.isArray(values) ? values : [values])
335
+ .map((value) => String(value || "").trim())
336
+ .filter(Boolean)
337
+ )];
338
+ }
339
+
340
+ function buildHostedSearchProviderId(providerId, modelId) {
341
+ const normalizedProviderId = String(providerId || "").trim();
342
+ const normalizedModelId = String(modelId || "").trim();
343
+ if (!normalizedProviderId || !normalizedModelId) return "";
344
+ return `${normalizedProviderId}/${normalizedModelId}`;
345
+ }
346
+
347
+ function looksLikeHostedSearchProviderId(value) {
348
+ const normalized = String(value || "").trim();
349
+ return normalized.includes("/");
350
+ }
351
+
352
+ function isHostedSearchProvider(provider) {
353
+ return Boolean(
354
+ provider
355
+ && typeof provider === "object"
356
+ && looksLikeHostedSearchProviderId(provider.id)
357
+ && String(provider?.providerId || "").trim()
358
+ && String(provider?.model || "").trim()
359
+ );
360
+ }
361
+
362
+ function normalizeHostedSearchProviderEntry(entry, explicitId = "") {
363
+ const routeId = String(
364
+ explicitId
365
+ || entry?.id
366
+ || buildHostedSearchProviderId(
367
+ entry?.providerId ?? entry?.provider,
368
+ entry?.model ?? entry?.modelId
369
+ )
370
+ ).trim();
371
+ if (!looksLikeHostedSearchProviderId(routeId)) return null;
372
+
373
+ const providerId = String(entry?.providerId ?? entry?.provider ?? routeId.slice(0, routeId.indexOf("/"))).trim();
374
+ const model = String(entry?.model ?? entry?.modelId ?? routeId.slice(routeId.indexOf("/") + 1)).trim();
375
+ const normalizedRouteId = buildHostedSearchProviderId(providerId, model);
376
+ if (!normalizedRouteId || normalizedRouteId !== routeId) return null;
377
+
378
+ return {
379
+ id: normalizedRouteId,
380
+ providerId,
381
+ model
382
+ };
383
+ }
384
+
385
+ function normalizeSearchProviderId(value) {
386
+ const normalized = String(value || "").trim().toLowerCase();
387
+ return AMP_WEB_SEARCH_PROVIDER_META.has(normalized) ? normalized : "";
388
+ }
389
+
390
+ function normalizeSearchRoutingStrategy(value) {
391
+ const normalized = String(value || "").trim().toLowerCase();
392
+ if (!normalized) return "ordered";
393
+ if (normalized === "quota-balance" || normalized === "quota-aware-weighted-rr") return "quota-balance";
394
+ return "ordered";
395
+ }
396
+
397
+ function resolveProviderDefaultLimit(providerId) {
398
+ return AMP_WEB_SEARCH_PROVIDER_META.get(providerId)?.defaultLimit || 0;
399
+ }
400
+
401
+ function hasOwnSearchProviderField(entry, keys = []) {
402
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
403
+ return keys.some((key) => Object.prototype.hasOwnProperty.call(entry, key) && entry[key] !== undefined && entry[key] !== null && String(entry[key]).trim() !== "");
404
+ }
405
+
406
+ function resolveSearchProviderCount(provider = {}, fallback = DEFAULT_SEARCH_COUNT) {
407
+ return toInteger(
408
+ provider?.count ?? provider?.resultCount ?? provider?.["result-count"] ?? provider?.resultsPerCall ?? provider?.["results-per-call"],
409
+ fallback,
410
+ { min: MIN_SEARCH_COUNT, max: MAX_SEARCH_COUNT }
411
+ );
412
+ }
413
+
414
+ function normalizeSearchProviderEntry(entry, explicitId = "", env = {}, { preserveUnconfigured = true, inheritedCount = DEFAULT_SEARCH_COUNT } = {}) {
415
+ const hostedProvider = normalizeHostedSearchProviderEntry(entry, explicitId);
416
+ if (hostedProvider) return hostedProvider;
417
+
418
+ const providerId = normalizeSearchProviderId(explicitId || entry?.id || entry?.provider || entry?.backend || entry?.name);
419
+ if (!providerId) return null;
420
+
421
+ const defaultLimit = resolveProviderDefaultLimit(providerId);
422
+ const apiKeyEnvName = providerId === "brave"
423
+ ? "BRAVE_API_KEY"
424
+ : providerId === "tavily"
425
+ ? "TAVILY_API_KEY"
426
+ : providerId === "exa"
427
+ ? "EXA_API_KEY"
428
+ : "";
429
+ const limitEnvName = providerId === "brave"
430
+ ? "BRAVE_MONTHLY_LIMIT"
431
+ : providerId === "tavily"
432
+ ? "TAVILY_MONTHLY_LIMIT"
433
+ : providerId === "exa"
434
+ ? "EXA_MONTHLY_LIMIT"
435
+ : "";
436
+
437
+ const apiKey = providerId === "searxng"
438
+ ? undefined
439
+ : toTrimmedString(entry?.apiKey ?? entry?.["api-key"] ?? env?.[apiKeyEnvName]);
440
+ const url = providerId === "searxng"
441
+ ? toTrimmedString(entry?.url ?? entry?.baseUrl ?? entry?.["base-url"] ?? entry?.searxngUrl ?? entry?.["searxng-url"] ?? env?.WEB_SEARCH_URL)?.replace(/\/+$/, "")
442
+ : undefined;
443
+ const hasExplicitCount = hasOwnSearchProviderField(entry, ["count", "resultCount", "result-count", "resultsPerCall", "results-per-call"]);
444
+ const hasExplicitLimit = hasOwnSearchProviderField(entry, ["limit", "monthlyLimit", "monthly-limit"]);
445
+ const hasExplicitRemaining = hasOwnSearchProviderField(entry, ["remaining", "remainingQuota", "remaining-quota", "remainingQueries", "remaining-queries"]);
446
+ const hasCredential = providerId === "searxng" ? Boolean(url) : Boolean(apiKey);
447
+ if (!preserveUnconfigured && !hasCredential && !hasExplicitCount && !hasExplicitLimit && !hasExplicitRemaining) {
448
+ return null;
449
+ }
450
+ const count = resolveSearchProviderCount(entry, inheritedCount);
451
+ const includeQuotaDefaults = hasCredential || hasExplicitLimit || hasExplicitRemaining;
452
+ const limit = toInteger(
453
+ entry?.limit ?? entry?.monthlyLimit ?? entry?.["monthly-limit"] ?? env?.[limitEnvName],
454
+ includeQuotaDefaults ? defaultLimit : 0,
455
+ { min: 0 }
456
+ );
457
+ const remaining = toInteger(
458
+ entry?.remaining ?? entry?.remainingQuota ?? entry?.["remaining-quota"] ?? entry?.remainingQueries ?? entry?.["remaining-queries"],
459
+ includeQuotaDefaults && limit > 0 ? limit : 0,
460
+ { min: 0 }
461
+ );
462
+
463
+ return {
464
+ id: providerId,
465
+ ...(apiKey ? { apiKey } : {}),
466
+ ...(url ? { url } : {}),
467
+ ...(count !== DEFAULT_SEARCH_COUNT ? { count } : {}),
468
+ ...(hasExplicitLimit || (includeQuotaDefaults && limit > 0) ? { limit } : {}),
469
+ ...(hasExplicitRemaining || (includeQuotaDefaults && (limit > 0 || remaining > 0))
470
+ ? { remaining: limit > 0 ? Math.min(remaining, limit) : remaining }
471
+ : {})
472
+ };
473
+ }
474
+
475
+ function normalizeConfiguredSearchProviders(rawProviders, raw = {}, env = {}) {
476
+ const inheritedCount = resolveSearchProviderCount(raw, toInteger(
477
+ env.AMP_WEB_SEARCH_COUNT ?? env.WEB_SEARCH_COUNT,
478
+ DEFAULT_SEARCH_COUNT,
479
+ { min: MIN_SEARCH_COUNT, max: MAX_SEARCH_COUNT }
480
+ ));
481
+ if (Array.isArray(rawProviders) && rawProviders.length > 0) {
482
+ const out = [];
483
+ const seen = new Set();
484
+ for (const entry of rawProviders) {
485
+ const normalized = normalizeSearchProviderEntry(entry, "", env, {
486
+ preserveUnconfigured: true,
487
+ inheritedCount
488
+ });
489
+ if (!normalized || seen.has(normalized.id)) continue;
490
+ seen.add(normalized.id);
491
+ out.push(normalized);
492
+ }
493
+ return out;
494
+ }
495
+
496
+ if (rawProviders && typeof rawProviders === "object" && !Array.isArray(rawProviders)) {
497
+ const out = [];
498
+ const seen = new Set();
499
+ for (const [providerId, value] of Object.entries(rawProviders)) {
500
+ const normalized = normalizeSearchProviderEntry(value, providerId, env, {
501
+ preserveUnconfigured: true,
502
+ inheritedCount
503
+ });
504
+ if (!normalized || seen.has(normalized.id)) continue;
505
+ seen.add(normalized.id);
506
+ out.push(normalized);
507
+ }
508
+ return out;
509
+ }
510
+
511
+ const legacyPreferredBackend = normalizeSearchProviderId(
512
+ raw.preferredBackend ?? raw["preferred-backend"] ?? env.AMP_WEB_SEARCH_BACKEND ?? env.WEB_SEARCH_BACKEND
513
+ );
514
+ const legacyEntries = [
515
+ normalizeSearchProviderEntry({
516
+ apiKey: raw.braveApiKey ?? raw["brave-api-key"],
517
+ count: raw.count,
518
+ limit: raw.braveMonthlyLimit ?? raw["brave-monthly-limit"],
519
+ remaining: raw.braveRemaining ?? raw["brave-remaining"]
520
+ }, "brave", env, { preserveUnconfigured: false, inheritedCount }),
521
+ normalizeSearchProviderEntry({
522
+ apiKey: raw.tavilyApiKey ?? raw["tavily-api-key"],
523
+ count: raw.count,
524
+ limit: raw.tavilyMonthlyLimit ?? raw["tavily-monthly-limit"],
525
+ remaining: raw.tavilyRemaining ?? raw["tavily-remaining"]
526
+ }, "tavily", env, { preserveUnconfigured: false, inheritedCount }),
527
+ normalizeSearchProviderEntry({
528
+ apiKey: raw.exaApiKey ?? raw["exa-api-key"],
529
+ count: raw.count,
530
+ limit: raw.exaMonthlyLimit ?? raw["exa-monthly-limit"],
531
+ remaining: raw.exaRemaining ?? raw["exa-remaining"]
532
+ }, "exa", env, { preserveUnconfigured: false, inheritedCount }),
533
+ normalizeSearchProviderEntry({
534
+ count: raw.count,
535
+ url: raw.searxngUrl ?? raw["searxng-url"] ?? raw.url
536
+ }, "searxng", env, { preserveUnconfigured: false, inheritedCount })
537
+ ].filter(Boolean);
538
+
539
+ if (!legacyPreferredBackend) return legacyEntries;
540
+ return [
541
+ ...legacyEntries.filter((entry) => entry.id === legacyPreferredBackend),
542
+ ...legacyEntries.filter((entry) => entry.id !== legacyPreferredBackend)
543
+ ];
544
+ }
545
+
546
+ export function resolveAmpWebSearchConfig(runtimeConfig = {}, env = {}) {
547
+ const amp = runtimeConfig?.amp && typeof runtimeConfig.amp === "object" ? runtimeConfig.amp : {};
548
+ const raw = runtimeConfig?.webSearch && typeof runtimeConfig.webSearch === "object" && !Array.isArray(runtimeConfig.webSearch)
549
+ ? runtimeConfig.webSearch
550
+ : (amp.webSearch && typeof amp.webSearch === "object" && !Array.isArray(amp.webSearch)
551
+ ? amp.webSearch
552
+ : {});
553
+ const count = toInteger(
554
+ raw.count ?? env.AMP_WEB_SEARCH_COUNT ?? env.WEB_SEARCH_COUNT,
555
+ DEFAULT_SEARCH_COUNT,
556
+ { min: MIN_SEARCH_COUNT, max: MAX_SEARCH_COUNT }
557
+ );
558
+ const providers = normalizeConfiguredSearchProviders(raw.providers, raw, env);
559
+
560
+ return {
561
+ strategy: normalizeSearchRoutingStrategy(raw.strategy ?? env.AMP_WEB_SEARCH_STRATEGY),
562
+ count,
563
+ providers
564
+ };
565
+ }
566
+
567
+ function isSearchProviderConfigured(provider) {
568
+ if (!provider || typeof provider !== "object") return false;
569
+ if (isHostedSearchProvider(provider)) {
570
+ return Boolean(String(provider.providerId || "").trim() && String(provider.model || "").trim());
571
+ }
572
+ if (provider.id === "searxng") return Boolean(String(provider.url || "").trim());
573
+ return Boolean(String(provider.apiKey || "").trim());
574
+ }
575
+
576
+ function getResolvedHostedSearchRoute(runtimeConfig = {}, providerEntry = {}) {
577
+ if (!isHostedSearchProvider(providerEntry)) return null;
578
+ const resolvedRoute = resolveRouteReference(runtimeConfig, providerEntry.id);
579
+ if (!resolvedRoute?.provider || !resolvedRoute?.model) return null;
580
+ if (String(resolvedRoute.provider?.id || "").trim() !== String(providerEntry.providerId || "").trim()) return null;
581
+ if (String(resolvedRoute.model?.id || "").trim() !== String(providerEntry.model || "").trim()) return null;
582
+ return resolvedRoute;
583
+ }
584
+
585
+ function getResolvedHostedSearchModelFormats(provider, model) {
586
+ const modelId = String(model?.id || "").trim();
587
+ if (!modelId) return [];
588
+
589
+ const preferredFormat = String(provider?.lastProbe?.modelPreferredFormat?.[modelId] || "").trim();
590
+ if (preferredFormat === FORMATS.OPENAI || preferredFormat === FORMATS.CLAUDE) {
591
+ return [preferredFormat];
592
+ }
593
+
594
+ const probedFormats = dedupeStrings(provider?.lastProbe?.modelSupport?.[modelId] || [])
595
+ .filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE);
596
+ if (probedFormats.length > 0) return probedFormats;
597
+
598
+ return dedupeStrings([...(model?.formats || []), model?.format])
599
+ .filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE);
600
+ }
601
+
602
+ function supportsResolvedHostedSearchRoute(provider, model) {
603
+ const providerFormats = dedupeStrings([...(provider?.formats || []), provider?.format]);
604
+ if (!providerFormats.includes(FORMATS.OPENAI)) return false;
605
+ const modelFormats = getResolvedHostedSearchModelFormats(provider, model);
606
+ return modelFormats.length === 0 || modelFormats.includes(FORMATS.OPENAI);
607
+ }
608
+
609
+ function buildHostedSearchProviderLabel(providerEntry, resolvedRoute = null) {
610
+ const providerLabel = String(
611
+ resolvedRoute?.provider?.name
612
+ || resolvedRoute?.provider?.id
613
+ || providerEntry?.providerId
614
+ || providerEntry?.id
615
+ || "Search provider"
616
+ ).trim();
617
+ const modelLabel = String(
618
+ resolvedRoute?.model?.id
619
+ || providerEntry?.model
620
+ || ""
621
+ ).trim();
622
+ return modelLabel ? `${providerLabel} · ${modelLabel}` : providerLabel;
623
+ }
624
+
625
+ function extractAssistantTextFragments(payload) {
626
+ const fragments = [];
627
+ if (!payload || typeof payload !== "object") return fragments;
628
+
629
+ if (typeof payload.output_text === "string" && payload.output_text.trim()) {
630
+ fragments.push(payload.output_text.trim());
631
+ }
632
+
633
+ if (Array.isArray(payload.choices)) {
634
+ for (const choice of payload.choices) {
635
+ const content = choice?.message?.content;
636
+ if (typeof content === "string" && content.trim()) {
637
+ fragments.push(content.trim());
638
+ }
639
+ }
640
+ }
641
+
642
+ if (payload.type === "message" && Array.isArray(payload.content)) {
643
+ for (const block of payload.content) {
644
+ if (typeof block?.text === "string" && block.text.trim()) {
645
+ fragments.push(block.text.trim());
646
+ }
647
+ }
648
+ }
649
+
650
+ if (Array.isArray(payload.output)) {
651
+ for (const item of payload.output) {
652
+ if (item?.type !== "message" || item.role !== "assistant" || !Array.isArray(item.content)) continue;
653
+ for (const block of item.content) {
654
+ if (typeof block?.text === "string" && block.text.trim()) {
655
+ fragments.push(block.text.trim());
656
+ continue;
657
+ }
658
+ if (typeof block?.refusal === "string" && block.refusal.trim()) {
659
+ fragments.push(block.refusal.trim());
660
+ }
661
+ }
662
+ }
663
+ }
664
+
665
+ return fragments;
666
+ }
667
+
668
+ function payloadHasHostedWebSearchEvidence(payload) {
669
+ if (!payload || typeof payload !== "object") return false;
670
+
671
+ if (Array.isArray(payload.output)) {
672
+ if (payload.output.some((item) => item?.type === "web_search_call")) return true;
673
+ if (payload.output.some((item) => item?.type === "function_call" && hasSearchToolName(item?.name))) return true;
674
+ }
675
+
676
+ if (Array.isArray(payload.choices)) {
677
+ for (const choice of payload.choices) {
678
+ const toolCalls = Array.isArray(choice?.message?.tool_calls) ? choice.message.tool_calls : [];
679
+ if (toolCalls.some((toolCall) => hasSearchToolName(toolCall?.function?.name))) {
680
+ return true;
681
+ }
682
+ }
683
+ }
684
+
685
+ const contentBlocks = Array.isArray(payload?.content) ? payload.content : [];
686
+ if (contentBlocks.some((block) => block?.type === "tool_use" && hasSearchToolName(block?.name))) {
687
+ return true;
688
+ }
689
+
690
+ return false;
691
+ }
692
+
693
+ function readHostedSearchResponseText(payload) {
694
+ return extractAssistantTextFragments(payload).join("\n\n").trim();
695
+ }
696
+
697
+ async function readSearchProviderError(response) {
698
+ if (!(response instanceof Response)) return "Search provider request failed.";
699
+ try {
700
+ const raw = await response.text();
701
+ const parsed = parseJsonSafely(raw, null);
702
+ return String(
703
+ parsed?.error?.message
704
+ || parsed?.error?.code
705
+ || parsed?.error?.type
706
+ || parsed?.error
707
+ || parsed?.message
708
+ || raw
709
+ || `Search provider request failed with status ${response.status}.`
710
+ ).trim() || `Search provider request failed with status ${response.status}.`;
711
+ } catch {
712
+ return `Search provider request failed with status ${response.status}.`;
713
+ }
714
+ }
715
+
716
+ function createInMemorySearchStateStore() {
717
+ return {
718
+ async getRouteCursor(routeKey) {
719
+ const key = String(routeKey || "").trim();
720
+ return key ? (inMemoryRouteCursor.get(key) || 0) : 0;
721
+ },
722
+
723
+ async setRouteCursor(routeKey, value) {
724
+ const key = String(routeKey || "").trim();
725
+ const normalized = Math.max(0, Math.floor(Number(value) || 0));
726
+ if (key) inMemoryRouteCursor.set(key, normalized);
727
+ return normalized;
728
+ },
729
+
730
+ async getCandidateState() {
731
+ return null;
732
+ },
733
+
734
+ async setCandidateState() {
735
+ return null;
736
+ },
737
+
738
+ async readBucketUsage(bucketKey, windowKey) {
739
+ const compositeKey = `${String(bucketKey || "").trim()}::${String(windowKey || "").trim()}`;
740
+ return inMemoryBucketUsage.get(compositeKey)?.count || 0;
741
+ },
742
+
743
+ async incrementBucketUsage(bucketKey, windowKey, amount = 1, options = {}) {
744
+ const compositeKey = `${String(bucketKey || "").trim()}::${String(windowKey || "").trim()}`;
745
+ const current = inMemoryBucketUsage.get(compositeKey) || { count: 0, expiresAt: 0 };
746
+ const next = {
747
+ count: current.count + Math.max(0, Math.floor(Number(amount) || 0)),
748
+ expiresAt: Number(options?.expiresAt) || current.expiresAt || 0
749
+ };
750
+ inMemoryBucketUsage.set(compositeKey, next);
751
+ return next.count;
752
+ },
753
+
754
+ async pruneExpired(now = Date.now()) {
755
+ const currentTime = Number(now) || Date.now();
756
+ let prunedBuckets = 0;
757
+ for (const [key, value] of inMemoryBucketUsage.entries()) {
758
+ const expiresAt = Number(value?.expiresAt) || 0;
759
+ if (expiresAt > 0 && expiresAt <= currentTime) {
760
+ inMemoryBucketUsage.delete(key);
761
+ prunedBuckets += 1;
762
+ }
763
+ }
764
+ return {
765
+ prunedBuckets,
766
+ prunedCandidateStates: 0
767
+ };
768
+ },
769
+
770
+ async close() {
771
+ return undefined;
772
+ }
773
+ };
774
+ }
775
+
776
+ function resolveSearchStateStore(stateStore) {
777
+ return stateStore || createInMemorySearchStateStore();
778
+ }
779
+
780
+ function buildSearchProviderBucketKey(providerId) {
781
+ return `amp-web-search:${String(providerId || "").trim()}`;
782
+ }
783
+
784
+ function buildSearchProviderWindow(provider, now = Date.now()) {
785
+ const monthlyWindow = resolveWindowRange({ unit: "month", size: 1 }, now);
786
+ const syncSeed = `${provider?.remaining ?? provider?.limit ?? 0}`;
787
+ return {
788
+ ...monthlyWindow,
789
+ key: `${monthlyWindow.key}:sync=${syncSeed}`
790
+ };
791
+ }
792
+
793
+ async function buildSearchProviderEvaluation(provider, stateStore, now = Date.now()) {
794
+ const configuredRemaining = Number.isFinite(Number(provider?.remaining)) ? Math.max(0, Math.floor(Number(provider.remaining))) : 0;
795
+ const limit = Number.isFinite(Number(provider?.limit)) ? Math.max(0, Math.floor(Number(provider.limit))) : 0;
796
+ const window = buildSearchProviderWindow(provider, now);
797
+ const hasQuota = limit > 0;
798
+ const bucketKey = buildSearchProviderBucketKey(provider?.id);
799
+ const usedSinceSync = hasQuota
800
+ ? Math.max(0, Math.floor(Number(await stateStore.readBucketUsage(bucketKey, window.key)) || 0))
801
+ : 0;
802
+ const currentRemaining = hasQuota
803
+ ? Math.max(0, configuredRemaining - usedSinceSync)
804
+ : Number.POSITIVE_INFINITY;
805
+ const remainingCapacityRatio = hasQuota
806
+ ? (limit > 0 ? (currentRemaining / limit) : 0)
807
+ : 1;
808
+ const eligible = !hasQuota || currentRemaining > 0;
809
+ const candidate = {
810
+ providerId: "amp-web-search",
811
+ modelId: provider.id,
812
+ requestModelId: `amp-web-search/${provider.id}`,
813
+ routeWeight: hasQuota ? Math.max(1, configuredRemaining || limit) : 1,
814
+ targetFormat: FORMATS.OPENAI
815
+ };
816
+ const bucket = hasQuota
817
+ ? {
818
+ providerId: "amp-web-search",
819
+ modelId: provider.id,
820
+ bucketId: provider.id,
821
+ bucketKey,
822
+ window,
823
+ windowKey: window.key,
824
+ requests: Math.max(1, configuredRemaining || limit),
825
+ models: [provider.id],
826
+ metadata: {
827
+ providerId: provider.id,
828
+ syncSeed: configuredRemaining
829
+ },
830
+ used: usedSinceSync,
831
+ remaining: currentRemaining,
832
+ remainingRatio: remainingCapacityRatio,
833
+ exhausted: currentRemaining <= 0
834
+ }
835
+ : null;
836
+
837
+ return {
838
+ candidate,
839
+ candidateKey: buildCandidateKey(candidate),
840
+ eligible,
841
+ remainingCapacityRatio,
842
+ buckets: bucket ? [bucket] : [],
843
+ exhaustedBuckets: bucket?.exhausted ? [bucket] : [],
844
+ usedSinceSync,
845
+ currentRemaining
846
+ };
847
+ }
848
+
849
+ function buildSearchProviderStatus(provider, evaluation, runtimeConfig = {}) {
850
+ const hostedRoute = getResolvedHostedSearchRoute(runtimeConfig, provider);
851
+ const hostedRouteReady = hostedRoute
852
+ ? supportsResolvedHostedSearchRoute(hostedRoute.provider, hostedRoute.model)
853
+ : false;
854
+ const definition = AMP_WEB_SEARCH_PROVIDER_META.get(provider.id) || { label: buildHostedSearchProviderLabel(provider, hostedRoute) };
855
+ const limit = Number.isFinite(Number(provider?.limit)) ? Math.max(0, Math.floor(Number(provider.limit))) : 0;
856
+ const configured = isSearchProviderConfigured(provider);
857
+ return {
858
+ ...provider,
859
+ label: definition.label,
860
+ configured,
861
+ ready: isHostedSearchProvider(provider) ? hostedRouteReady : configured,
862
+ count: resolveSearchProviderCount(provider),
863
+ limit,
864
+ configuredRemaining: Number.isFinite(Number(provider?.remaining)) ? Math.max(0, Math.floor(Number(provider.remaining))) : Math.max(0, limit),
865
+ usedSinceSync: evaluation.usedSinceSync,
866
+ currentRemaining: Number.isFinite(evaluation.currentRemaining) ? evaluation.currentRemaining : null,
867
+ remainingCapacityRatio: evaluation.remainingCapacityRatio,
868
+ exhausted: evaluation.eligible === false,
869
+ hostedRoute: hostedRoute
870
+ ? {
871
+ providerId: hostedRoute.provider.id,
872
+ providerName: hostedRoute.provider.name || hostedRoute.provider.id,
873
+ modelId: hostedRoute.model.id
874
+ }
875
+ : null,
876
+ evaluation
877
+ };
878
+ }
879
+
880
+ export async function buildAmpWebSearchSnapshot(runtimeConfig = {}, { env = {}, stateStore = null, now = Date.now() } = {}) {
881
+ const settings = resolveAmpWebSearchConfig(runtimeConfig, env);
882
+ const effectiveStateStore = resolveSearchStateStore(stateStore);
883
+ const providers = [];
884
+
885
+ for (const provider of settings.providers) {
886
+ const evaluation = await buildSearchProviderEvaluation(provider, effectiveStateStore, now);
887
+ providers.push(buildSearchProviderStatus(provider, evaluation, runtimeConfig));
888
+ }
889
+
890
+ return {
891
+ strategy: settings.strategy,
892
+ count: settings.count,
893
+ providers,
894
+ configuredProviderCount: providers.filter((provider) => provider.configured).length,
895
+ interceptEnabled: providers.some((provider) => provider.ready)
896
+ };
897
+ }
898
+
899
+ async function rankConfiguredSearchProviders(providerStatuses, strategy, stateStore, now = Date.now()) {
900
+ if (!Array.isArray(providerStatuses) || providerStatuses.length === 0) {
901
+ return [];
902
+ }
903
+
904
+ const evaluations = new Map();
905
+ const candidates = [];
906
+ for (const status of providerStatuses) {
907
+ evaluations.set(status.evaluation.candidateKey, status.evaluation);
908
+ candidates.push(status.evaluation.candidate);
909
+ }
910
+
911
+ const ranking = await rankRouteCandidates({
912
+ route: {
913
+ routeType: "amp-web-search",
914
+ routeRef: "webSearch",
915
+ routeStrategy: strategy === "quota-balance" ? "quota-aware-weighted-rr" : "ordered"
916
+ },
917
+ routeKey: SEARCH_ROUTE_KEY,
918
+ strategy: strategy === "quota-balance" ? "quota-aware-weighted-rr" : "ordered",
919
+ candidates,
920
+ stateStore,
921
+ config: { providers: [] },
922
+ rateLimitEvaluations: evaluations,
923
+ now
924
+ });
925
+
926
+ if (ranking.shouldAdvanceCursor) {
927
+ await commitRouteSelection(stateStore, ranking, {
928
+ amount: 0,
929
+ now
930
+ });
931
+ }
932
+
933
+ const statusById = new Map(providerStatuses.map((status) => [status.id, status]));
934
+ return ranking.entries
935
+ .filter((entry) => entry?.eligible)
936
+ .map((entry) => statusById.get(entry?.candidate?.modelId))
937
+ .filter(Boolean);
938
+ }
939
+
940
+ function buildSearchTag(providerStatus) {
941
+ const label = String(providerStatus?.label || providerStatus?.id || "Search").trim();
942
+ const limit = Number(providerStatus?.limit) || 0;
943
+ if (limit > 0 && Number.isFinite(providerStatus?.currentRemaining)) {
944
+ return `${label} (${providerStatus.currentRemaining}/${limit} remaining)`;
945
+ }
946
+ return label;
947
+ }
948
+
949
+ function decodeDuckDuckGoRedirect(url) {
950
+ const normalized = String(url || "").trim();
951
+ const uddgMatch = normalized.match(/[?&]uddg=([^&]+)/i);
952
+ return uddgMatch ? decodeURIComponent(uddgMatch[1]) : normalized;
953
+ }
954
+
955
+ async function searchBrave(query, count, provider) {
956
+ if (!provider?.apiKey) return null;
957
+ const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}&text_decorations=false`;
958
+ const response = await runFetchWithTimeout(url, {
959
+ headers: {
960
+ Accept: "application/json",
961
+ "X-Subscription-Token": provider.apiKey
962
+ }
963
+ });
964
+ if (!response.ok) return null;
965
+ const payload = await response.json();
966
+ const results = Array.isArray(payload?.web?.results) ? payload.web.results.slice(0, count) : [];
967
+ if (results.length === 0) return null;
968
+ return formatSearchResults(results.map((item) => ({
969
+ title: item?.title,
970
+ url: item?.url,
971
+ snippet: item?.description
972
+ })));
973
+ }
974
+
975
+ async function searchTavily(query, count, provider) {
976
+ if (!provider?.apiKey) return null;
977
+ const response = await runFetchWithTimeout("https://api.tavily.com/search", {
978
+ method: "POST",
979
+ headers: {
980
+ "Content-Type": "application/json"
981
+ },
982
+ body: JSON.stringify({
983
+ api_key: provider.apiKey,
984
+ query,
985
+ max_results: count,
986
+ include_answer: true,
987
+ search_depth: "basic"
988
+ })
989
+ });
990
+ if (!response.ok) return null;
991
+ const payload = await response.json();
992
+ const answer = String(payload?.answer || "").trim();
993
+ const results = Array.isArray(payload?.results) ? payload.results.slice(0, count) : [];
994
+ if (!answer && results.length === 0) return null;
995
+ const formatted = formatSearchResults(results.map((item) => ({
996
+ title: item?.title,
997
+ url: item?.url,
998
+ snippet: item?.content
999
+ })));
1000
+ return [answer ? `AI Summary: ${answer}` : "", formatted].filter(Boolean).join("\n\n").trim();
1001
+ }
1002
+
1003
+ async function searchExa(query, count, provider) {
1004
+ if (!provider?.apiKey) return null;
1005
+ const response = await runFetchWithTimeout("https://api.exa.ai/search", {
1006
+ method: "POST",
1007
+ headers: {
1008
+ "Content-Type": "application/json",
1009
+ "x-api-key": provider.apiKey
1010
+ },
1011
+ body: JSON.stringify({
1012
+ query,
1013
+ numResults: count,
1014
+ type: "auto",
1015
+ contents: {
1016
+ text: {
1017
+ maxCharacters: 500
1018
+ }
1019
+ }
1020
+ })
1021
+ });
1022
+ if (!response.ok) return null;
1023
+ const payload = await response.json();
1024
+ const results = Array.isArray(payload?.results) ? payload.results.slice(0, count) : [];
1025
+ if (results.length === 0) return null;
1026
+ return formatSearchResults(results.map((item) => ({
1027
+ title: item?.title,
1028
+ url: item?.url,
1029
+ snippet: item?.text || item?.snippet
1030
+ })));
1031
+ }
1032
+
1033
+ async function searchSearXng(query, count, provider) {
1034
+ if (!provider?.url) return null;
1035
+ const url = `${provider.url}/search?q=${encodeURIComponent(query)}&format=json&categories=general&language=auto`;
1036
+ const response = await runFetchWithTimeout(url, {
1037
+ headers: {
1038
+ Accept: "application/json",
1039
+ "User-Agent": "llm-router"
1040
+ }
1041
+ });
1042
+ if (!response.ok) return null;
1043
+ const payload = await response.json();
1044
+ const results = Array.isArray(payload?.results) ? payload.results.slice(0, count) : [];
1045
+ if (results.length === 0) return null;
1046
+ return formatSearchResults(results.map((item) => ({
1047
+ title: item?.title,
1048
+ url: item?.url,
1049
+ snippet: item?.content
1050
+ })));
1051
+ }
1052
+
1053
+ async function searchGoogleNews(query, count) {
1054
+ const url = `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=en-US&gl=US&ceid=US:en`;
1055
+ const response = await runFetchWithTimeout(url, {
1056
+ headers: {
1057
+ "User-Agent": "llm-router"
1058
+ }
1059
+ });
1060
+ if (!response.ok) return null;
1061
+ const xml = await response.text();
1062
+ const matches = [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].slice(0, count);
1063
+ if (matches.length === 0) return null;
1064
+ const results = matches.map((match) => {
1065
+ const block = match[1] || "";
1066
+ const title = stripHtml((block.match(/<title>([\s\S]*?)<\/title>/i) || [])[1] || "");
1067
+ const urlValue = String((block.match(/<link>([\s\S]*?)<\/link>/i) || [])[1] || "").trim();
1068
+ const source = stripHtml((block.match(/<source[^>]*>([\s\S]*?)<\/source>/i) || [])[1] || "");
1069
+ const published = stripHtml((block.match(/<pubDate>([\s\S]*?)<\/pubDate>/i) || [])[1] || "");
1070
+ return {
1071
+ title,
1072
+ url: urlValue,
1073
+ snippet: [source, published].filter(Boolean).join(" - ")
1074
+ };
1075
+ }).filter((entry) => entry.title || entry.url);
1076
+ if (results.length === 0) return null;
1077
+ return formatSearchResults(results);
1078
+ }
1079
+
1080
+ async function searchDuckDuckGoHtml(query, count) {
1081
+ const response = await runFetchWithTimeout("https://html.duckduckgo.com/html/", {
1082
+ method: "POST",
1083
+ headers: {
1084
+ "Content-Type": "application/x-www-form-urlencoded",
1085
+ Accept: "text/html",
1086
+ "User-Agent": "Mozilla/5.0"
1087
+ },
1088
+ body: `q=${encodeURIComponent(query)}`
1089
+ });
1090
+ if (!response.ok) return null;
1091
+ const html = await response.text();
1092
+ const linkMatches = [...html.matchAll(/class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g)].slice(0, count);
1093
+ if (linkMatches.length === 0) return null;
1094
+ const snippetMatches = [...html.matchAll(/class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g)];
1095
+ const results = linkMatches.map((match, index) => ({
1096
+ title: stripHtml(match[2]),
1097
+ url: decodeDuckDuckGoRedirect(match[1]),
1098
+ snippet: stripHtml(snippetMatches[index]?.[1] || "")
1099
+ }));
1100
+ return formatSearchResults(results);
1101
+ }
1102
+
1103
+ async function searchBing(query, count) {
1104
+ const url = `https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${count}`;
1105
+ const response = await runFetchWithTimeout(url, {
1106
+ headers: {
1107
+ Accept: "text/html",
1108
+ "Accept-Language": "en-US,en;q=0.9",
1109
+ "User-Agent": "Mozilla/5.0"
1110
+ }
1111
+ });
1112
+ if (!response.ok) return null;
1113
+ const html = await response.text();
1114
+ const matches = [...html.matchAll(/<li class="b_algo"[^>]*>([\s\S]*?)<\/li>/g)].slice(0, count);
1115
+ if (matches.length === 0) return null;
1116
+ const results = matches.map((match) => {
1117
+ const block = match[1] || "";
1118
+ const linkMatch = block.match(/<a[^>]*href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
1119
+ const snippetMatch = block.match(/<p[^>]*>([\s\S]*?)<\/p>/i);
1120
+ if (!linkMatch) return null;
1121
+ return {
1122
+ title: stripHtml(linkMatch[2]),
1123
+ url: linkMatch[1],
1124
+ snippet: stripHtml(snippetMatch?.[1] || "")
1125
+ };
1126
+ }).filter(Boolean);
1127
+ if (results.length === 0) return null;
1128
+ return formatSearchResults(results);
1129
+ }
1130
+
1131
+ async function searchDuckDuckGoLite(query, count) {
1132
+ const url = `https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}`;
1133
+ const response = await runFetchWithTimeout(url, {
1134
+ headers: {
1135
+ Accept: "text/html",
1136
+ "User-Agent": "Mozilla/5.0"
1137
+ }
1138
+ });
1139
+ if (!response.ok) return null;
1140
+ const html = await response.text();
1141
+ const linkMatches = [...html.matchAll(/href="([^"]+)"[^>]*class='result-link'>([\s\S]*?)<\/a>/g)].slice(0, count);
1142
+ if (linkMatches.length === 0) return null;
1143
+ const snippetMatches = [...html.matchAll(/class='result-snippet'>([\s\S]*?)<\/td>/g)];
1144
+ const results = linkMatches.map((match, index) => ({
1145
+ title: stripHtml(match[2]),
1146
+ url: decodeDuckDuckGoRedirect(match[1]),
1147
+ snippet: stripHtml(snippetMatches[index]?.[1] || "")
1148
+ }));
1149
+ return formatSearchResults(results);
1150
+ }
1151
+
1152
+ async function searchHostedProviderRoute(query, count, provider, runtimeConfig = {}, env = {}) {
1153
+ void count;
1154
+ if (!isHostedSearchProvider(provider)) return null;
1155
+ const result = await runHostedSearchProviderQuery(provider, query, runtimeConfig, env);
1156
+ return String(result?.text || "").trim() || null;
1157
+ }
1158
+
1159
+ const backendSearchers = Object.freeze({
1160
+ brave: searchBrave,
1161
+ tavily: searchTavily,
1162
+ exa: searchExa,
1163
+ searxng: searchSearXng,
1164
+ gnews: searchGoogleNews,
1165
+ ddghtml: searchDuckDuckGoHtml,
1166
+ bing: searchBing,
1167
+ ddglite: searchDuckDuckGoLite
1168
+ });
1169
+
1170
+ export async function executeAmpWebSearch(query, runtimeConfig = {}, env = {}, options = {}) {
1171
+ const normalizedQuery = String(query || "").trim();
1172
+ if (!normalizedQuery) {
1173
+ return {
1174
+ text: "[Empty search query]",
1175
+ backend: "",
1176
+ tag: ""
1177
+ };
1178
+ }
1179
+
1180
+ const snapshot = await buildAmpWebSearchSnapshot(runtimeConfig, {
1181
+ env,
1182
+ stateStore: options.stateStore,
1183
+ now: options.now
1184
+ });
1185
+ const stateStore = resolveSearchStateStore(options.stateStore);
1186
+ const configuredProviders = snapshot.providers.filter((provider) => provider.ready);
1187
+ const rankedConfiguredProviders = await rankConfiguredSearchProviders(
1188
+ configuredProviders,
1189
+ snapshot.strategy,
1190
+ stateStore,
1191
+ options.now
1192
+ );
1193
+
1194
+ for (const providerStatus of rankedConfiguredProviders) {
1195
+ try {
1196
+ const searcher = isHostedSearchProvider(providerStatus)
1197
+ ? searchHostedProviderRoute
1198
+ : backendSearchers[providerStatus.id];
1199
+ if (typeof searcher !== "function") continue;
1200
+ const providerCount = resolveSearchProviderCount(providerStatus, snapshot.count);
1201
+ const result = isHostedSearchProvider(providerStatus)
1202
+ ? await searcher(normalizedQuery, providerCount, providerStatus, runtimeConfig, env)
1203
+ : await searcher(normalizedQuery, providerCount, providerStatus);
1204
+ if (!result || !String(result).trim()) continue;
1205
+ await consumeCandidateRateLimits(stateStore, providerStatus.evaluation, {
1206
+ amount: 1,
1207
+ now: options.now
1208
+ });
1209
+ const refreshedSnapshot = await buildAmpWebSearchSnapshot(runtimeConfig, {
1210
+ env,
1211
+ stateStore,
1212
+ now: options.now
1213
+ });
1214
+ const refreshedProvider = refreshedSnapshot.providers.find((entry) => entry.id === providerStatus.id) || providerStatus;
1215
+ return {
1216
+ text: String(result).trim(),
1217
+ backend: providerStatus.id,
1218
+ providerId: providerStatus.id,
1219
+ tag: buildSearchTag(refreshedProvider)
1220
+ };
1221
+ } catch {
1222
+ continue;
1223
+ }
1224
+ }
1225
+
1226
+ for (const backend of FREE_FALLBACK_BACKENDS) {
1227
+ const searcher = backendSearchers[backend];
1228
+ if (typeof searcher !== "function") continue;
1229
+ try {
1230
+ const result = await searcher(normalizedQuery, snapshot.count);
1231
+ if (!result || !String(result).trim()) continue;
1232
+ return {
1233
+ text: String(result).trim(),
1234
+ backend,
1235
+ providerId: backend,
1236
+ tag: backend
1237
+ };
1238
+ } catch {
1239
+ continue;
1240
+ }
1241
+ }
1242
+
1243
+ return {
1244
+ text: "[No search results available]",
1245
+ backend: "",
1246
+ tag: ""
1247
+ };
1248
+ }
1249
+
1250
+ export function shouldInterceptAmpWebSearch({ clientType, originalBody, runtimeConfig, env }) {
1251
+ const tools = Array.isArray(originalBody?.tools) ? originalBody.tools : [];
1252
+ const requestedToolNames = dedupeStrings(tools.map((tool) => getToolName(tool)).filter(Boolean));
1253
+ if (requestedToolNames.length === 0) {
1254
+ return false;
1255
+ }
1256
+ const readyProviders = resolveAmpWebSearchConfig(runtimeConfig, env).providers.filter((provider) => {
1257
+ if (!isSearchProviderConfigured(provider)) return false;
1258
+ if (!isHostedSearchProvider(provider)) return true;
1259
+ const resolvedRoute = getResolvedHostedSearchRoute(runtimeConfig, provider);
1260
+ return Boolean(resolvedRoute && supportsResolvedHostedSearchRoute(resolvedRoute.provider, resolvedRoute.model));
1261
+ });
1262
+ if (readyProviders.length === 0) {
1263
+ return clientType === "amp" && requestedToolNames.includes(READ_WEB_PAGE_TOOL_NAME);
1264
+ }
1265
+ if (clientType === "amp") {
1266
+ if (requestedToolNames.includes(READ_WEB_PAGE_TOOL_NAME)) return true;
1267
+ return true;
1268
+ }
1269
+ return true;
1270
+ }
1271
+
1272
+ export function rewriteProviderBodyForAmpWebSearch(providerBody, targetFormat) {
1273
+ const tools = Array.isArray(providerBody?.tools) ? providerBody.tools : [];
1274
+ if (tools.length === 0) {
1275
+ return {
1276
+ providerBody,
1277
+ hasWebSearch: false
1278
+ };
1279
+ }
1280
+
1281
+ const interceptedToolNames = new Set();
1282
+ const nextTools = [];
1283
+ for (const tool of tools) {
1284
+ if (!tool || typeof tool !== "object") {
1285
+ nextTools.push(tool);
1286
+ continue;
1287
+ }
1288
+ const toolName = getToolName(tool);
1289
+ if (toolName) {
1290
+ interceptedToolNames.add(toolName);
1291
+ continue;
1292
+ }
1293
+ nextTools.push(tool);
1294
+ }
1295
+
1296
+ if (interceptedToolNames.size === 0) {
1297
+ return {
1298
+ providerBody,
1299
+ hasWebSearch: false
1300
+ };
1301
+ }
1302
+
1303
+ if (targetFormat === FORMATS.OPENAI) {
1304
+ if (interceptedToolNames.has(SEARCH_TOOL_NAME)) nextTools.push(OPENAI_WEB_SEARCH_TOOL);
1305
+ if (interceptedToolNames.has(READ_WEB_PAGE_TOOL_NAME)) nextTools.push(OPENAI_READ_WEB_PAGE_TOOL);
1306
+ } else if (targetFormat === FORMATS.CLAUDE) {
1307
+ if (interceptedToolNames.has(SEARCH_TOOL_NAME)) nextTools.push(CLAUDE_WEB_SEARCH_TOOL);
1308
+ if (interceptedToolNames.has(READ_WEB_PAGE_TOOL_NAME)) nextTools.push(CLAUDE_READ_WEB_PAGE_TOOL);
1309
+ }
1310
+
1311
+ return {
1312
+ hasWebSearch: true,
1313
+ providerBody: {
1314
+ ...providerBody,
1315
+ tools: nextTools
1316
+ }
1317
+ };
1318
+ }
1319
+
1320
+ function extractOpenAIChatProbe(payload) {
1321
+ const choice = Array.isArray(payload?.choices) ? payload.choices[0] : null;
1322
+ const message = choice?.message && typeof choice.message === "object" ? choice.message : null;
1323
+ const toolCalls = Array.isArray(message?.tool_calls)
1324
+ ? message.tool_calls.filter((item) => hasInterceptableToolName(item?.function?.name))
1325
+ : [];
1326
+
1327
+ return {
1328
+ hasWebSearchCalls: toolCalls.length > 0,
1329
+ toolCalls,
1330
+ assistantMessage: message ? {
1331
+ role: "assistant",
1332
+ content: typeof message.content === "string" ? message.content : (message.content || ""),
1333
+ ...(Array.isArray(message.tool_calls) && message.tool_calls.length > 0 ? { tool_calls: message.tool_calls } : {})
1334
+ } : null
1335
+ };
1336
+ }
1337
+
1338
+ function normalizeResponseInput(input) {
1339
+ if (Array.isArray(input)) return input.slice();
1340
+ const normalized = String(input || "").trim();
1341
+ if (!normalized) return [];
1342
+ return [{
1343
+ type: "message",
1344
+ role: "user",
1345
+ content: [{
1346
+ type: "input_text",
1347
+ text: normalized
1348
+ }]
1349
+ }];
1350
+ }
1351
+
1352
+ function extractOpenAIResponsesProbe(payload) {
1353
+ const output = Array.isArray(payload?.output) ? payload.output : [];
1354
+ const toolCalls = output.filter((item) => item?.type === "function_call" && hasInterceptableToolName(item?.name));
1355
+ const assistantInputItems = output
1356
+ .filter((item) => item && item.type !== "reasoning")
1357
+ .map((item) => {
1358
+ if (item.type === "message") {
1359
+ return {
1360
+ type: "message",
1361
+ role: item.role || "assistant",
1362
+ content: Array.isArray(item.content) ? item.content : []
1363
+ };
1364
+ }
1365
+ if (item.type === "function_call") {
1366
+ return {
1367
+ type: "function_call",
1368
+ call_id: item.call_id || item.id || `call_${Date.now()}`,
1369
+ name: item.name || SEARCH_TOOL_NAME,
1370
+ arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments || {})
1371
+ };
1372
+ }
1373
+ return null;
1374
+ })
1375
+ .filter(Boolean);
1376
+
1377
+ return {
1378
+ hasWebSearchCalls: toolCalls.length > 0,
1379
+ toolCalls,
1380
+ assistantInputItems
1381
+ };
1382
+ }
1383
+
1384
+ function extractClaudeProbe(payload) {
1385
+ const content = Array.isArray(payload?.content) ? payload.content : [];
1386
+ const assistantContent = content.filter((item) => item?.type !== "thinking" && item?.type !== "redacted_thinking");
1387
+ const toolCalls = assistantContent.filter((item) => item?.type === "tool_use" && hasInterceptableToolName(item?.name));
1388
+
1389
+ return {
1390
+ hasWebSearchCalls: toolCalls.length > 0,
1391
+ toolCalls,
1392
+ assistantContent
1393
+ };
1394
+ }
1395
+
1396
+ export function extractAmpWebSearchProbe(payload, { targetFormat, requestKind }) {
1397
+ if (targetFormat === FORMATS.CLAUDE) {
1398
+ return extractClaudeProbe(payload);
1399
+ }
1400
+ if (targetFormat === FORMATS.OPENAI && requestKind === "responses") {
1401
+ return extractOpenAIResponsesProbe(payload);
1402
+ }
1403
+ if (targetFormat === FORMATS.OPENAI) {
1404
+ return extractOpenAIChatProbe(payload);
1405
+ }
1406
+ return {
1407
+ hasWebSearchCalls: false,
1408
+ toolCalls: []
1409
+ };
1410
+ }
1411
+
1412
+ function extractQueryFromToolCall(toolCall) {
1413
+ if (!toolCall || typeof toolCall !== "object") return "";
1414
+ if (typeof toolCall?.input?.query === "string") return toolCall.input.query.trim();
1415
+ const parsedArguments = parseJsonSafely(toolCall?.arguments, {});
1416
+ if (typeof parsedArguments?.query === "string") return parsedArguments.query.trim();
1417
+ const parsedFunctionArguments = parseJsonSafely(toolCall?.function?.arguments, {});
1418
+ if (typeof parsedFunctionArguments?.query === "string") return parsedFunctionArguments.query.trim();
1419
+ if (typeof toolCall?.function?.query === "string") return toolCall.function.query.trim();
1420
+ return "";
1421
+ }
1422
+
1423
+ function extractUrlFromToolCall(toolCall) {
1424
+ if (!toolCall || typeof toolCall !== "object") return "";
1425
+ for (const candidate of [
1426
+ toolCall?.input?.url,
1427
+ toolCall?.input?.uri,
1428
+ toolCall?.input?.href
1429
+ ]) {
1430
+ if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
1431
+ }
1432
+ const parsedArguments = parseJsonSafely(toolCall?.arguments, {});
1433
+ for (const candidate of [parsedArguments?.url, parsedArguments?.uri, parsedArguments?.href]) {
1434
+ if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
1435
+ }
1436
+ const parsedFunctionArguments = parseJsonSafely(toolCall?.function?.arguments, {});
1437
+ for (const candidate of [parsedFunctionArguments?.url, parsedFunctionArguments?.uri, parsedFunctionArguments?.href]) {
1438
+ if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
1439
+ }
1440
+ for (const candidate of [toolCall?.function?.url, toolCall?.function?.uri, toolCall?.function?.href]) {
1441
+ if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
1442
+ }
1443
+ return "";
1444
+ }
1445
+
1446
+ function getToolCallName(toolCall) {
1447
+ return getToolName(toolCall);
1448
+ }
1449
+
1450
+ function buildToolResultText(query, searchText) {
1451
+ const normalizedQuery = String(query || "").trim();
1452
+ const normalizedResults = String(searchText || "").trim() || "[No search results available]";
1453
+ if (!normalizedQuery) return normalizedResults;
1454
+ return `Web search results for "${normalizedQuery}":\n\n${normalizedResults}`;
1455
+ }
1456
+
1457
+ function buildReadWebPageResultText(url, pageText) {
1458
+ const normalizedUrl = String(url || "").trim();
1459
+ const normalizedPageText = String(pageText || "").trim() || "[Unable to extract web page content]";
1460
+ if (!normalizedUrl) return normalizedPageText;
1461
+ return `Web page content from "${normalizedUrl}":\n\n${normalizedPageText}`;
1462
+ }
1463
+
1464
+ async function executeAmpReadWebPage(url) {
1465
+ const normalizedUrl = String(url || "").trim();
1466
+ if (!normalizedUrl) {
1467
+ return {
1468
+ text: "[Missing URL for read_web_page]",
1469
+ providerId: READ_WEB_PAGE_TOOL_NAME,
1470
+ backend: READ_WEB_PAGE_TOOL_NAME,
1471
+ tag: READ_WEB_PAGE_TOOL_NAME
1472
+ };
1473
+ }
1474
+
1475
+ let parsedUrl;
1476
+ try {
1477
+ parsedUrl = new URL(normalizedUrl);
1478
+ } catch {
1479
+ return {
1480
+ text: `[Invalid URL for read_web_page: ${normalizedUrl}]`,
1481
+ providerId: READ_WEB_PAGE_TOOL_NAME,
1482
+ backend: READ_WEB_PAGE_TOOL_NAME,
1483
+ tag: READ_WEB_PAGE_TOOL_NAME
1484
+ };
1485
+ }
1486
+
1487
+ if (!["http:", "https:"].includes(parsedUrl.protocol)) {
1488
+ return {
1489
+ text: `[Unsupported URL protocol for read_web_page: ${parsedUrl.protocol}]`,
1490
+ providerId: READ_WEB_PAGE_TOOL_NAME,
1491
+ backend: READ_WEB_PAGE_TOOL_NAME,
1492
+ tag: READ_WEB_PAGE_TOOL_NAME
1493
+ };
1494
+ }
1495
+
1496
+ try {
1497
+ const response = await runFetchWithTimeout(parsedUrl.toString(), {
1498
+ headers: {
1499
+ Accept: "text/html,application/xhtml+xml,text/plain,application/json;q=0.9,*/*;q=0.8",
1500
+ "User-Agent": "llm-router"
1501
+ }
1502
+ });
1503
+ if (!response.ok) {
1504
+ return {
1505
+ text: `[Failed to read web page: ${await readSearchProviderError(response)}]`,
1506
+ providerId: READ_WEB_PAGE_TOOL_NAME,
1507
+ backend: READ_WEB_PAGE_TOOL_NAME,
1508
+ tag: READ_WEB_PAGE_TOOL_NAME
1509
+ };
1510
+ }
1511
+
1512
+ const contentType = String(response.headers.get("content-type") || "").trim();
1513
+ const bodyText = await response.text();
1514
+ const formattedText = looksLikeHtml(contentType, bodyText)
1515
+ ? formatReadWebPageHtml(parsedUrl.toString(), bodyText)
1516
+ : formatReadWebPageBody(parsedUrl.toString(), bodyText, contentType);
1517
+
1518
+ return {
1519
+ text: formattedText,
1520
+ providerId: READ_WEB_PAGE_TOOL_NAME,
1521
+ backend: READ_WEB_PAGE_TOOL_NAME,
1522
+ tag: READ_WEB_PAGE_TOOL_NAME
1523
+ };
1524
+ } catch (error) {
1525
+ return {
1526
+ text: `[Failed to read web page: ${error instanceof Error ? error.message : String(error)}]`,
1527
+ providerId: READ_WEB_PAGE_TOOL_NAME,
1528
+ backend: READ_WEB_PAGE_TOOL_NAME,
1529
+ tag: READ_WEB_PAGE_TOOL_NAME
1530
+ };
1531
+ }
1532
+ }
1533
+
1534
+ async function executeAmpInterceptedToolCall(toolCall, runtimeConfig, env, options = {}) {
1535
+ const toolName = getToolCallName(toolCall);
1536
+ if (toolName === READ_WEB_PAGE_TOOL_NAME) {
1537
+ return executeAmpReadWebPage(extractUrlFromToolCall(toolCall));
1538
+ }
1539
+ return executeAmpWebSearch(
1540
+ extractQueryFromToolCall(toolCall),
1541
+ runtimeConfig,
1542
+ env,
1543
+ options
1544
+ );
1545
+ }
1546
+
1547
+ function mergeClaudeSystemInstruction(system, instruction) {
1548
+ if (typeof system === "string" && system.trim()) {
1549
+ return `${system.trim()}\n\n${instruction}`;
1550
+ }
1551
+ if (Array.isArray(system) && system.length > 0) {
1552
+ return [
1553
+ ...system,
1554
+ {
1555
+ type: "text",
1556
+ text: instruction
1557
+ }
1558
+ ];
1559
+ }
1560
+ return instruction;
1561
+ }
1562
+
1563
+ function mergeOpenAIInstructions(originalInstructions, instruction) {
1564
+ const existing = String(originalInstructions || "").trim();
1565
+ return existing ? `${existing}\n\n${instruction}` : instruction;
1566
+ }
1567
+
1568
+ export function buildAmpWebSearchFollowUp(providerBody, probePayload, probe, searchResultsByCall, { targetFormat, requestKind, stream }) {
1569
+ const toolCalls = Array.isArray(probe?.toolCalls) ? probe.toolCalls : [];
1570
+ const normalizedToolResults = Array.isArray(searchResultsByCall)
1571
+ ? searchResultsByCall
1572
+ : [];
1573
+
1574
+ if (targetFormat === FORMATS.CLAUDE) {
1575
+ const toolResults = toolCalls.map((toolCall, index) => ({
1576
+ type: "tool_result",
1577
+ tool_use_id: toolCall.id || `tool_${index + 1}`,
1578
+ content: getToolCallName(toolCall) === READ_WEB_PAGE_TOOL_NAME
1579
+ ? buildReadWebPageResultText(
1580
+ extractUrlFromToolCall(toolCall),
1581
+ normalizedToolResults[index]?.text
1582
+ )
1583
+ : buildToolResultText(
1584
+ extractQueryFromToolCall(toolCall),
1585
+ normalizedToolResults[index]?.text
1586
+ )
1587
+ }));
1588
+ return {
1589
+ ...providerBody,
1590
+ stream: Boolean(stream),
1591
+ system: mergeClaudeSystemInstruction(providerBody.system, SEARCH_SYSTEM_INSTRUCTION),
1592
+ messages: [
1593
+ ...(Array.isArray(providerBody.messages) ? providerBody.messages : []),
1594
+ {
1595
+ role: "assistant",
1596
+ content: Array.isArray(probe.assistantContent) ? probe.assistantContent : []
1597
+ },
1598
+ {
1599
+ role: "user",
1600
+ content: toolResults
1601
+ }
1602
+ ]
1603
+ };
1604
+ }
1605
+
1606
+ if (targetFormat === FORMATS.OPENAI && requestKind === "responses") {
1607
+ const toolOutputs = toolCalls.map((toolCall, index) => ({
1608
+ type: "function_call_output",
1609
+ call_id: toolCall.call_id || toolCall.id || `call_${index + 1}`,
1610
+ output: getToolCallName(toolCall) === READ_WEB_PAGE_TOOL_NAME
1611
+ ? buildReadWebPageResultText(
1612
+ extractUrlFromToolCall(toolCall),
1613
+ normalizedToolResults[index]?.text
1614
+ )
1615
+ : buildToolResultText(
1616
+ extractQueryFromToolCall(toolCall),
1617
+ normalizedToolResults[index]?.text
1618
+ )
1619
+ }));
1620
+ return {
1621
+ ...providerBody,
1622
+ stream: Boolean(stream),
1623
+ input: [
1624
+ ...normalizeResponseInput(providerBody.input),
1625
+ ...(Array.isArray(probe.assistantInputItems) ? probe.assistantInputItems : []),
1626
+ ...toolOutputs
1627
+ ],
1628
+ instructions: mergeOpenAIInstructions(providerBody.instructions, SEARCH_SYSTEM_INSTRUCTION)
1629
+ };
1630
+ }
1631
+
1632
+ const assistantMessage = probe?.assistantMessage && typeof probe.assistantMessage === "object"
1633
+ ? probe.assistantMessage
1634
+ : {
1635
+ role: "assistant",
1636
+ content: ""
1637
+ };
1638
+ const toolMessages = toolCalls.map((toolCall, index) => ({
1639
+ role: "tool",
1640
+ tool_call_id: toolCall.id || `call_${index + 1}`,
1641
+ content: getToolCallName(toolCall) === READ_WEB_PAGE_TOOL_NAME
1642
+ ? buildReadWebPageResultText(
1643
+ extractUrlFromToolCall(toolCall),
1644
+ normalizedToolResults[index]?.text
1645
+ )
1646
+ : buildToolResultText(
1647
+ extractQueryFromToolCall(toolCall),
1648
+ normalizedToolResults[index]?.text
1649
+ )
1650
+ }));
1651
+ const nextMessages = Array.isArray(providerBody.messages) ? providerBody.messages.slice() : [];
1652
+ const hasLeadingSystem = nextMessages[0]?.role === "system";
1653
+ if (hasLeadingSystem && typeof nextMessages[0].content === "string") {
1654
+ nextMessages[0] = {
1655
+ ...nextMessages[0],
1656
+ content: mergeOpenAIInstructions(nextMessages[0].content, SEARCH_SYSTEM_INSTRUCTION)
1657
+ };
1658
+ } else {
1659
+ nextMessages.unshift({
1660
+ role: "system",
1661
+ content: SEARCH_SYSTEM_INSTRUCTION
1662
+ });
1663
+ }
1664
+ nextMessages.push(assistantMessage, ...toolMessages);
1665
+ return {
1666
+ ...providerBody,
1667
+ stream: Boolean(stream),
1668
+ messages: nextMessages
1669
+ };
1670
+ }
1671
+
1672
+ export function stripAmpWebSearchFollowUpTools(providerBody) {
1673
+ const nextBody = {
1674
+ ...providerBody
1675
+ };
1676
+ delete nextBody.tool_choice;
1677
+ delete nextBody.parallel_tool_calls;
1678
+ delete nextBody.max_tool_calls;
1679
+ if (Array.isArray(nextBody.tools)) {
1680
+ delete nextBody.tools;
1681
+ }
1682
+ return nextBody;
1683
+ }
1684
+
1685
+ function splitSseEventBlocks(rawText) {
1686
+ return String(rawText || "")
1687
+ .replace(/\r\n/g, "\n")
1688
+ .split("\n\n")
1689
+ .map((block) => block.trim())
1690
+ .filter(Boolean);
1691
+ }
1692
+
1693
+ function parseSseDataLines(block) {
1694
+ const dataLines = [];
1695
+ for (const line of String(block || "").split("\n")) {
1696
+ if (!line.startsWith("data:")) continue;
1697
+ dataLines.push(line.slice(5).trimStart());
1698
+ }
1699
+ return dataLines.join("\n").trim();
1700
+ }
1701
+
1702
+ function parseOpenAIChatStreamPayload(rawText) {
1703
+ const toolCalls = [];
1704
+ const toolCallsByIndex = new Map();
1705
+ let responseId = "";
1706
+ let model = "";
1707
+ let content = "";
1708
+ let finishReason = "stop";
1709
+
1710
+ for (const block of splitSseEventBlocks(rawText)) {
1711
+ const dataText = parseSseDataLines(block);
1712
+ if (!dataText || dataText === "[DONE]") continue;
1713
+
1714
+ let payload;
1715
+ try {
1716
+ payload = JSON.parse(dataText);
1717
+ } catch {
1718
+ continue;
1719
+ }
1720
+
1721
+ responseId = responseId || String(payload?.id || "").trim();
1722
+ model = model || String(payload?.model || "").trim();
1723
+
1724
+ for (const choice of (Array.isArray(payload?.choices) ? payload.choices : [])) {
1725
+ const delta = choice?.delta || {};
1726
+ if (typeof delta?.content === "string") {
1727
+ content += delta.content;
1728
+ }
1729
+
1730
+ if (Array.isArray(delta?.tool_calls)) {
1731
+ for (const call of delta.tool_calls) {
1732
+ const callIndex = Number.isFinite(Number(call?.index)) ? Number(call.index) : toolCalls.length;
1733
+ const current = toolCallsByIndex.get(callIndex) || {
1734
+ id: call?.id || `call_${callIndex + 1}`,
1735
+ type: "function",
1736
+ function: {
1737
+ name: "",
1738
+ arguments: ""
1739
+ }
1740
+ };
1741
+ if (call?.id) current.id = call.id;
1742
+ if (call?.function?.name) current.function.name += call.function.name;
1743
+ if (call?.function?.arguments) current.function.arguments += call.function.arguments;
1744
+ toolCallsByIndex.set(callIndex, current);
1745
+ }
1746
+ }
1747
+
1748
+ if (delta?.function_call && typeof delta.function_call === "object") {
1749
+ const current = toolCallsByIndex.get(0) || {
1750
+ id: payload?.id ? `${payload.id}_tool_1` : "call_1",
1751
+ type: "function",
1752
+ function: {
1753
+ name: "",
1754
+ arguments: ""
1755
+ }
1756
+ };
1757
+ if (delta.function_call.name) current.function.name += delta.function_call.name;
1758
+ if (delta.function_call.arguments) current.function.arguments += delta.function_call.arguments;
1759
+ toolCallsByIndex.set(0, current);
1760
+ }
1761
+
1762
+ if (choice?.finish_reason) {
1763
+ finishReason = String(choice.finish_reason).trim() || finishReason;
1764
+ }
1765
+ }
1766
+ }
1767
+
1768
+ const orderedIndexes = [...toolCallsByIndex.keys()].sort((left, right) => left - right);
1769
+ for (const index of orderedIndexes) {
1770
+ toolCalls.push(toolCallsByIndex.get(index));
1771
+ }
1772
+
1773
+ return {
1774
+ id: responseId || `chatcmpl_${Date.now()}`,
1775
+ object: "chat.completion",
1776
+ model: model || "unknown",
1777
+ choices: [
1778
+ {
1779
+ index: 0,
1780
+ message: {
1781
+ role: "assistant",
1782
+ content,
1783
+ ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {})
1784
+ },
1785
+ finish_reason: finishReason
1786
+ }
1787
+ ]
1788
+ };
1789
+ }
1790
+
1791
+ function parseOpenAIResponsesStreamPayload(rawText) {
1792
+ let completedResponse = null;
1793
+ let responseId = "";
1794
+ let createdAt = Math.floor(Date.now() / 1000);
1795
+ let model = "";
1796
+ let usage = null;
1797
+ const output = [];
1798
+ const messageItems = new Map();
1799
+ const functionItems = new Map();
1800
+
1801
+ function ensureMessageItem(itemId) {
1802
+ const current = messageItems.get(itemId) || {
1803
+ type: "message",
1804
+ id: itemId,
1805
+ role: "assistant",
1806
+ status: "completed",
1807
+ content: [{ type: "output_text", text: "" }]
1808
+ };
1809
+ messageItems.set(itemId, current);
1810
+ return current;
1811
+ }
1812
+
1813
+ function ensureFunctionItem(itemId, callId = itemId) {
1814
+ const current = functionItems.get(itemId) || {
1815
+ type: "function_call",
1816
+ id: itemId,
1817
+ call_id: callId,
1818
+ name: SEARCH_TOOL_NAME,
1819
+ arguments: "",
1820
+ status: "completed"
1821
+ };
1822
+ functionItems.set(itemId, current);
1823
+ return current;
1824
+ }
1825
+
1826
+ for (const block of splitSseEventBlocks(rawText)) {
1827
+ const dataText = parseSseDataLines(block);
1828
+ if (!dataText || dataText === "[DONE]") continue;
1829
+
1830
+ let payload;
1831
+ try {
1832
+ payload = JSON.parse(dataText);
1833
+ } catch {
1834
+ continue;
1835
+ }
1836
+
1837
+ if (payload?.type === "response.completed" && payload?.response && typeof payload.response === "object") {
1838
+ completedResponse = payload.response;
1839
+ break;
1840
+ }
1841
+
1842
+ if (payload?.response && typeof payload.response === "object") {
1843
+ responseId = responseId || String(payload.response.id || "").trim();
1844
+ model = model || String(payload.response.model || "").trim();
1845
+ if (Number.isFinite(payload.response.created_at)) createdAt = Number(payload.response.created_at);
1846
+ if (payload.response.usage && typeof payload.response.usage === "object") usage = payload.response.usage;
1847
+ }
1848
+
1849
+ if (payload?.type === "response.output_item.added") {
1850
+ const item = payload.item || payload.output_item;
1851
+ if (item?.type === "message") {
1852
+ messageItems.set(item.id || `msg_${messageItems.size + 1}`, {
1853
+ ...item,
1854
+ content: Array.isArray(item.content) ? item.content.slice() : []
1855
+ });
1856
+ }
1857
+ if (item?.type === "function_call") {
1858
+ functionItems.set(item.id || `fc_${functionItems.size + 1}`, {
1859
+ ...item,
1860
+ arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments || {}),
1861
+ status: item.status || "completed"
1862
+ });
1863
+ }
1864
+ continue;
1865
+ }
1866
+
1867
+ if (payload?.type === "response.output_text.delta") {
1868
+ const messageItem = ensureMessageItem(String(payload.item_id || "msg_1"));
1869
+ const currentText = typeof messageItem.content?.[0]?.text === "string" ? messageItem.content[0].text : "";
1870
+ messageItem.content = [{ type: "output_text", text: `${currentText}${payload.delta || ""}` }];
1871
+ continue;
1872
+ }
1873
+
1874
+ if (payload?.type === "response.function_call_arguments.delta") {
1875
+ const functionItem = ensureFunctionItem(String(payload.item_id || "fc_1"));
1876
+ functionItem.arguments += String(payload.delta || "");
1877
+ continue;
1878
+ }
1879
+
1880
+ if (payload?.type === "response.output_item.done") {
1881
+ const item = payload.item || payload.output_item;
1882
+ if (item?.type === "message") {
1883
+ messageItems.set(item.id || `msg_${messageItems.size + 1}`, item);
1884
+ }
1885
+ if (item?.type === "function_call") {
1886
+ functionItems.set(item.id || `fc_${functionItems.size + 1}`, {
1887
+ ...item,
1888
+ arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments || {}),
1889
+ status: item.status || "completed"
1890
+ });
1891
+ }
1892
+ }
1893
+ }
1894
+
1895
+ if (completedResponse) return completedResponse;
1896
+
1897
+ for (const item of messageItems.values()) output.push(item);
1898
+ for (const item of functionItems.values()) output.push(item);
1899
+
1900
+ return {
1901
+ id: responseId || `resp_${Date.now()}`,
1902
+ object: "response",
1903
+ created_at: createdAt,
1904
+ model: model || "unknown",
1905
+ status: "completed",
1906
+ output,
1907
+ usage
1908
+ };
1909
+ }
1910
+
1911
+ function parseClaudeStreamPayload(rawText) {
1912
+ let messageId = "";
1913
+ let model = "";
1914
+ let stopReason = "end_turn";
1915
+ let usage = null;
1916
+ const activeBlocks = new Map();
1917
+ const orderedBlocks = [];
1918
+
1919
+ function ensureBlock(index, contentBlock = {}) {
1920
+ if (activeBlocks.has(index)) return activeBlocks.get(index);
1921
+ const blockType = String(contentBlock?.type || "").trim();
1922
+ const block = blockType === "tool_use"
1923
+ ? {
1924
+ type: "tool_use",
1925
+ id: contentBlock.id || `tool_${index + 1}`,
1926
+ name: contentBlock.name || SEARCH_TOOL_NAME,
1927
+ input: contentBlock.input && typeof contentBlock.input === "object" ? contentBlock.input : {}
1928
+ }
1929
+ : blockType === "thinking" || blockType === "redacted_thinking"
1930
+ ? {
1931
+ type: blockType,
1932
+ thinking: contentBlock.thinking || ""
1933
+ }
1934
+ : {
1935
+ type: "text",
1936
+ text: contentBlock.text || ""
1937
+ };
1938
+ activeBlocks.set(index, block);
1939
+ orderedBlocks[index] = block;
1940
+ return block;
1941
+ }
1942
+
1943
+ for (const block of splitSseEventBlocks(rawText)) {
1944
+ const dataText = parseSseDataLines(block);
1945
+ if (!dataText || dataText === "[DONE]") continue;
1946
+
1947
+ let payload;
1948
+ try {
1949
+ payload = JSON.parse(dataText);
1950
+ } catch {
1951
+ continue;
1952
+ }
1953
+
1954
+ const type = String(payload?.type || "").trim();
1955
+ if (!type) continue;
1956
+
1957
+ if (type === "message_start") {
1958
+ const message = payload.message || {};
1959
+ messageId = messageId || String(message.id || "").trim();
1960
+ model = model || String(message.model || "").trim();
1961
+ if (message.usage && typeof message.usage === "object") {
1962
+ usage = {
1963
+ input_tokens: Number(message.usage.input_tokens) || 0,
1964
+ output_tokens: Number(message.usage.output_tokens) || 0
1965
+ };
1966
+ }
1967
+ continue;
1968
+ }
1969
+
1970
+ if (type === "content_block_start") {
1971
+ const index = Number(payload.index);
1972
+ ensureBlock(index, payload.content_block || {});
1973
+ continue;
1974
+ }
1975
+
1976
+ if (type === "content_block_delta") {
1977
+ const index = Number(payload.index);
1978
+ const current = ensureBlock(index, payload.content_block || {});
1979
+ const delta = payload.delta || {};
1980
+ if (delta.type === "text_delta" && typeof delta.text === "string" && current.type === "text") {
1981
+ current.text += delta.text;
1982
+ }
1983
+ if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && current.type === "tool_use") {
1984
+ const existing = typeof current.__input_json === "string" ? current.__input_json : "";
1985
+ current.__input_json = `${existing || ""}${delta.partial_json}`;
1986
+ }
1987
+ if (delta.type === "thinking_delta" && typeof delta.thinking === "string" && (current.type === "thinking" || current.type === "redacted_thinking")) {
1988
+ current.thinking += delta.thinking;
1989
+ }
1990
+ continue;
1991
+ }
1992
+
1993
+ if (type === "content_block_stop") {
1994
+ const index = Number(payload.index);
1995
+ const current = activeBlocks.get(index);
1996
+ if (current?.type === "tool_use" && typeof current.__input_json === "string") {
1997
+ current.input = parseJsonSafely(current.__input_json, current.input || {});
1998
+ delete current.__input_json;
1999
+ }
2000
+ continue;
2001
+ }
2002
+
2003
+ if (type === "message_delta") {
2004
+ if (payload.delta && typeof payload.delta === "object") {
2005
+ stopReason = String(payload.delta.stop_reason || stopReason).trim() || stopReason;
2006
+ }
2007
+ if (payload.usage && typeof payload.usage === "object") {
2008
+ usage = {
2009
+ ...(usage || {}),
2010
+ output_tokens: Number(payload.usage.output_tokens) || Number(usage?.output_tokens) || 0
2011
+ };
2012
+ }
2013
+ }
2014
+ }
2015
+
2016
+ return {
2017
+ id: messageId || `msg_${Date.now()}`,
2018
+ type: "message",
2019
+ role: "assistant",
2020
+ model: model || "unknown",
2021
+ content: orderedBlocks.filter(Boolean),
2022
+ stop_reason: stopReason,
2023
+ usage
2024
+ };
2025
+ }
2026
+
2027
+ async function collectAmpWebSearchProbePayload(response, { targetFormat, requestKind }) {
2028
+ if (!(response instanceof Response)) return null;
2029
+ const contentType = String(response.headers.get("content-type") || "").toLowerCase();
2030
+
2031
+ if (!contentType.includes("text/event-stream")) {
2032
+ try {
2033
+ return await response.clone().json();
2034
+ } catch {
2035
+ return null;
2036
+ }
2037
+ }
2038
+
2039
+ let rawText = "";
2040
+ try {
2041
+ rawText = await response.clone().text();
2042
+ } catch {
2043
+ return null;
2044
+ }
2045
+
2046
+ if (targetFormat === FORMATS.CLAUDE) {
2047
+ return parseClaudeStreamPayload(rawText);
2048
+ }
2049
+ if (targetFormat === FORMATS.OPENAI && requestKind === "responses") {
2050
+ return parseOpenAIResponsesStreamPayload(rawText);
2051
+ }
2052
+ if (targetFormat === FORMATS.OPENAI) {
2053
+ return parseOpenAIChatStreamPayload(rawText);
2054
+ }
2055
+ return null;
2056
+ }
2057
+
2058
+ async function executeHostedSearchProviderRequest(resolvedRoute, body, env = {}) {
2059
+ const provider = resolvedRoute?.provider;
2060
+ if (!provider || typeof provider !== "object") {
2061
+ throw new Error("Hosted web search provider is not configured.");
2062
+ }
2063
+
2064
+ if (isSubscriptionProvider(provider)) {
2065
+ const subscriptionType = String(provider?.subscriptionType || provider?.subscription_type || "").trim().toLowerCase();
2066
+ const subscriptionResult = await makeSubscriptionProviderCall({
2067
+ provider,
2068
+ body,
2069
+ stream: subscriptionType === "chatgpt-codex"
2070
+ });
2071
+ if (!subscriptionResult?.ok || !(subscriptionResult.response instanceof Response)) {
2072
+ const message = await readSearchProviderError(subscriptionResult?.response);
2073
+ throw new Error(message || "Hosted web search subscription request failed.");
2074
+ }
2075
+ return subscriptionResult.response;
2076
+ }
2077
+
2078
+ const targetFormat = FORMATS.OPENAI;
2079
+ const providerUrl = resolveProviderUrl(provider, targetFormat, "responses");
2080
+ if (!providerUrl) {
2081
+ throw new Error(`Provider '${provider.id}' does not expose an OpenAI Responses endpoint.`);
2082
+ }
2083
+
2084
+ const response = await runFetchWithTimeout(providerUrl, {
2085
+ method: "POST",
2086
+ headers: buildProviderHeaders(provider, env, targetFormat),
2087
+ body: JSON.stringify(body)
2088
+ });
2089
+ if (!response.ok) {
2090
+ throw new Error(await readSearchProviderError(response.clone()));
2091
+ }
2092
+ return response;
2093
+ }
2094
+
2095
+ async function runHostedSearchProviderQuery(providerEntry, query, runtimeConfig = {}, env = {}) {
2096
+ const resolvedRoute = getResolvedHostedSearchRoute(runtimeConfig, providerEntry);
2097
+ if (!resolvedRoute?.provider || !resolvedRoute?.model) {
2098
+ throw new Error(`Hosted web search route '${providerEntry?.id || providerEntry?.providerId || "unknown"}' is not configured.`);
2099
+ }
2100
+ if (!supportsResolvedHostedSearchRoute(resolvedRoute.provider, resolvedRoute.model)) {
2101
+ throw new Error(`Hosted web search route '${providerEntry.id}' is not OpenAI-compatible.`);
2102
+ }
2103
+
2104
+ const requestBody = {
2105
+ model: resolvedRoute.model.id,
2106
+ input: String(query || "").trim() || HOSTED_WEB_SEARCH_TEST_QUERY,
2107
+ tools: [{ type: "web_search" }],
2108
+ tool_choice: "auto"
2109
+ };
2110
+ const response = await executeHostedSearchProviderRequest(resolvedRoute, requestBody, env);
2111
+ const payload = await collectAmpWebSearchProbePayload(response, {
2112
+ targetFormat: FORMATS.OPENAI,
2113
+ requestKind: "responses"
2114
+ });
2115
+ if (!payload) {
2116
+ throw new Error(`Hosted web search route '${providerEntry.id}' returned an unreadable response payload.`);
2117
+ }
2118
+
2119
+ const text = readHostedSearchResponseText(payload);
2120
+ if (!text) {
2121
+ throw new Error(`Hosted web search route '${providerEntry.id}' did not return assistant text.`);
2122
+ }
2123
+
2124
+ return {
2125
+ routeId: providerEntry.id,
2126
+ providerId: resolvedRoute.provider.id,
2127
+ providerName: resolvedRoute.provider.name || resolvedRoute.provider.id,
2128
+ modelId: resolvedRoute.model.id,
2129
+ label: buildHostedSearchProviderLabel(providerEntry, resolvedRoute),
2130
+ payload,
2131
+ usedWebSearch: payloadHasHostedWebSearchEvidence(payload),
2132
+ text
2133
+ };
2134
+ }
2135
+
2136
+ export async function testHostedWebSearchProviderRoute({
2137
+ runtimeConfig = {},
2138
+ routeId = "",
2139
+ env = {},
2140
+ query = HOSTED_WEB_SEARCH_TEST_QUERY
2141
+ } = {}) {
2142
+ const resolvedRouteId = String(routeId || "").trim();
2143
+ if (!resolvedRouteId) {
2144
+ throw new Error("Hosted web search route id is required.");
2145
+ }
2146
+ return runHostedSearchProviderQuery({
2147
+ id: resolvedRouteId,
2148
+ providerId: resolvedRouteId.slice(0, resolvedRouteId.indexOf("/")),
2149
+ model: resolvedRouteId.slice(resolvedRouteId.indexOf("/") + 1)
2150
+ }, query, runtimeConfig, env);
2151
+ }
2152
+
2153
+ export async function maybeInterceptAmpWebSearch({
2154
+ response,
2155
+ providerBody,
2156
+ targetFormat,
2157
+ requestKind,
2158
+ stream,
2159
+ runtimeConfig,
2160
+ env,
2161
+ stateStore,
2162
+ executeProviderRequest
2163
+ } = {}) {
2164
+ if (!(response instanceof Response)) {
2165
+ return {
2166
+ intercepted: false,
2167
+ response
2168
+ };
2169
+ }
2170
+
2171
+ const probePayload = await collectAmpWebSearchProbePayload(response, {
2172
+ targetFormat,
2173
+ requestKind
2174
+ });
2175
+ if (!probePayload) {
2176
+ return {
2177
+ intercepted: false,
2178
+ response
2179
+ };
2180
+ }
2181
+
2182
+ const probe = extractAmpWebSearchProbe(probePayload, {
2183
+ targetFormat,
2184
+ requestKind
2185
+ });
2186
+ if (!probe.hasWebSearchCalls) {
2187
+ return {
2188
+ intercepted: false,
2189
+ response
2190
+ };
2191
+ }
2192
+
2193
+ const searchResultsByCall = [];
2194
+ for (const toolCall of (probe.toolCalls || [])) {
2195
+ searchResultsByCall.push(await executeAmpInterceptedToolCall(
2196
+ toolCall,
2197
+ runtimeConfig,
2198
+ env,
2199
+ {
2200
+ stateStore
2201
+ }
2202
+ ));
2203
+ }
2204
+
2205
+ const followUpBody = stripAmpWebSearchFollowUpTools(buildAmpWebSearchFollowUp(
2206
+ providerBody,
2207
+ probePayload,
2208
+ probe,
2209
+ searchResultsByCall,
2210
+ {
2211
+ targetFormat,
2212
+ requestKind,
2213
+ stream
2214
+ }
2215
+ ));
2216
+
2217
+ const followUpResponse = await executeProviderRequest(followUpBody);
2218
+ if (!(followUpResponse instanceof Response) || !followUpResponse.ok) {
2219
+ return {
2220
+ intercepted: false,
2221
+ response
2222
+ };
2223
+ }
2224
+
2225
+ return {
2226
+ intercepted: true,
2227
+ response: followUpResponse,
2228
+ probePayload,
2229
+ probe,
2230
+ searchResultsByCall
2231
+ };
2232
+ }