@springbrand/agent-runtime 0.1.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 (75) hide show
  1. package/package.json +28 -0
  2. package/src/db/approval.repo.ts +291 -0
  3. package/src/db/ext-context.repo.ts +34 -0
  4. package/src/db/index.ts +83 -0
  5. package/src/db/message-ui.repo.ts +39 -0
  6. package/src/db/milestone.repo.ts +96 -0
  7. package/src/db/runtime-event-outbox.repo.ts +89 -0
  8. package/src/db/schema.ts +164 -0
  9. package/src/db/settlement.repo.ts +104 -0
  10. package/src/db/steer.repo.ts +73 -0
  11. package/src/db/submission.repo.ts +323 -0
  12. package/src/index.ts +133 -0
  13. package/src/kernel/approval-lifecycle.ts +552 -0
  14. package/src/kernel/bindings.ts +898 -0
  15. package/src/kernel/degradation.ts +15 -0
  16. package/src/kernel/extensions.ts +108 -0
  17. package/src/kernel/profile.ts +116 -0
  18. package/src/kernel/public-contracts.ts +17 -0
  19. package/src/kernel/receipts.ts +124 -0
  20. package/src/kernel/recoverable-chat-agent.ts +899 -0
  21. package/src/kernel/state.ts +76 -0
  22. package/src/kernel/submission-lifecycle.ts +600 -0
  23. package/src/layers/context/budget/gate.ts +88 -0
  24. package/src/layers/orchestration/subagents/agent-types/contract.ts +78 -0
  25. package/src/layers/orchestration/subagents/agent-types/extract/index.ts +47 -0
  26. package/src/layers/orchestration/subagents/agent-types/fanout/index.ts +53 -0
  27. package/src/layers/orchestration/subagents/agent-types/registry.ts +16 -0
  28. package/src/layers/orchestration/temporary-agent/core.ts +152 -0
  29. package/src/layers/orchestration/temporary-agent/runner.ts +133 -0
  30. package/src/layers/orchestration/temporary-agent/workspace.ts +154 -0
  31. package/src/lib/artifacts.ts +54 -0
  32. package/src/lib/egress.ts +44 -0
  33. package/src/lib/execution-level.ts +27 -0
  34. package/src/lib/extension-name.ts +18 -0
  35. package/src/lib/host-actions.ts +57 -0
  36. package/src/lib/mcp.ts +86 -0
  37. package/src/lib/model-catalog.ts +7 -0
  38. package/src/lib/prompt.ts +139 -0
  39. package/src/lib/telemetry-dev.ts +44 -0
  40. package/src/pi/assembly/context.ts +510 -0
  41. package/src/pi/assembly/extensions.ts +661 -0
  42. package/src/pi/assembly/index.ts +19 -0
  43. package/src/pi/assembly/snapshot.ts +200 -0
  44. package/src/pi/message/contract.ts +8 -0
  45. package/src/pi/message/conversion.ts +73 -0
  46. package/src/pi/message/index.ts +3 -0
  47. package/src/pi/message/projection.ts +604 -0
  48. package/src/pi/runtime-adapter/assembly.ts +552 -0
  49. package/src/pi/runtime-adapter/execution.ts +683 -0
  50. package/src/pi/runtime-adapter/index.ts +232 -0
  51. package/src/pi/runtime-adapter/models.ts +243 -0
  52. package/src/pi/runtime-adapter/recovery.ts +805 -0
  53. package/src/pi/runtime-adapter/transcript.ts +825 -0
  54. package/src/pi/session/index.ts +24 -0
  55. package/src/pi/session/storage.ts +353 -0
  56. package/src/pi/tool/ai-adapter.ts +100 -0
  57. package/src/pi/tool/base.ts +110 -0
  58. package/src/pi/tool/compiler.ts +444 -0
  59. package/src/pi/tool/core-host.ts +48 -0
  60. package/src/pi/tool/core.ts +251 -0
  61. package/src/pi/tool/index.ts +32 -0
  62. package/src/pi/tool/mcp.ts +319 -0
  63. package/src/pi/tool/schedule.ts +198 -0
  64. package/src/pi/tool/skill.ts +455 -0
  65. package/src/pi/tool/subagent.ts +148 -0
  66. package/src/pi/tool/web-search/api.ts +1292 -0
  67. package/src/pi/tool/web-search/index.ts +2 -0
  68. package/src/pi/tool/web-search/web-search.ts +127 -0
  69. package/src/pi/tool/workspace-sandbox.ts +664 -0
  70. package/src/pi/turn/approval.ts +181 -0
  71. package/src/pi/turn/index.ts +62 -0
  72. package/src/pi/turn/tool-recovery.ts +792 -0
  73. package/src/plugins.ts +1024 -0
  74. package/src/runtime-agent.ts +654 -0
  75. package/src/runtime.ts +2880 -0
@@ -0,0 +1,1292 @@
1
+ /**
2
+ * Adapted from ttttmr/pi-web-search at commit
3
+ * 118b3eee3f5900cef3141745109a300315630592.
4
+ * Copyright (c) ttttmr. Licensed under the MIT license.
5
+ * https://github.com/ttttmr/pi-web-search
6
+ */
7
+
8
+ import type { RuntimeModelEndpoint } from "../../../kernel/bindings";
9
+
10
+ export interface WebSearchSource {
11
+ title: string;
12
+ url: string;
13
+ }
14
+
15
+ export interface WebSearchResultDetail {
16
+ title?: string;
17
+ url?: string;
18
+ query?: string;
19
+ source?: string;
20
+ pageAge?: string | null;
21
+ citedText?: string;
22
+ status?: string;
23
+ type?: string;
24
+ }
25
+
26
+ export interface NativeSearchCall {
27
+ id?: string;
28
+ provider: string;
29
+ status?: string;
30
+ actionType?: string;
31
+ queries?: string[];
32
+ urls?: string[];
33
+ }
34
+
35
+ export interface WebSearchResult {
36
+ text: string;
37
+ sources: WebSearchSource[];
38
+ providerKind?: string;
39
+ nativeSearchUsed?: boolean;
40
+ nativeSearchEvents?: string[];
41
+ nativeSearchCalls?: NativeSearchCall[];
42
+ searchQueries?: string[];
43
+ searchResults?: WebSearchResultDetail[];
44
+ citations?: WebSearchResultDetail[];
45
+ retrieved?: string[];
46
+ failed?: Array<{ url?: string; status?: string }>;
47
+ model?: string;
48
+ grounded?: boolean;
49
+ }
50
+
51
+ export type WebSearch = (
52
+ input: { query: string; urls?: string[] },
53
+ execution: {
54
+ signal?: AbortSignal;
55
+ onProgress?: (text: string) => void;
56
+ },
57
+ ) => Promise<WebSearchResult>;
58
+
59
+ // --- Provider Configuration ---
60
+
61
+ type ProviderKind = "google" | "openai" | "anthropic" | "unsupported";
62
+ type NativeApi = "google-generative-ai" | "openai-responses" | "openai-codex-responses" | "anthropic-messages";
63
+
64
+ interface NativeWebSearchModel {
65
+ id: string;
66
+ provider: string;
67
+ api: NativeApi;
68
+ baseUrl: string;
69
+ reasoning: boolean;
70
+ maxTokens: number;
71
+ headers?: Record<string, string>;
72
+ }
73
+
74
+ interface NativeWebSearchContext {
75
+ modelRegistry: {
76
+ getApiKeyAndHeaders(model: NativeWebSearchModel): Promise<ResolvedAuth>;
77
+ };
78
+ }
79
+
80
+ type NativeProgress = (text: string) => void;
81
+
82
+ type GoogleRequestBuilder = (model: NativeWebSearchModel, body: any) => { url: string; headers: Record<string, string>; body: any };
83
+
84
+ type ProviderConfig = {
85
+ kind: ProviderKind;
86
+ searchTool?: string;
87
+ urlContextTool?: string;
88
+ buildRequest?: GoogleRequestBuilder;
89
+ };
90
+
91
+ const GOOGLE_PROVIDERS: Record<string, ProviderConfig> = {
92
+ "google-generative-ai": {
93
+ kind: "google",
94
+ searchTool: "google_search",
95
+ urlContextTool: "url_context",
96
+ buildRequest: (model, body) => ({
97
+ url: `${model.baseUrl}/models/${model.id}:streamGenerateContent?alt=sse`,
98
+ headers: {
99
+ "Content-Type": "application/json",
100
+ "Accept": "text/event-stream",
101
+ },
102
+ body
103
+ })
104
+ }
105
+ };
106
+
107
+ export function getProviderKind(model: NativeWebSearchModel): ProviderKind {
108
+ if (GOOGLE_PROVIDERS[model.provider] || GOOGLE_PROVIDERS[model.api]) return "google";
109
+ if (model.api === "openai-responses" || model.api === "openai-codex-responses") return "openai";
110
+ if (model.api === "anthropic-messages") return "anthropic";
111
+ return "unsupported";
112
+ }
113
+
114
+ export function getConfig(model: NativeWebSearchModel): ProviderConfig {
115
+ const googleConfig = GOOGLE_PROVIDERS[model.provider] || GOOGLE_PROVIDERS[model.api];
116
+ if (googleConfig) return googleConfig;
117
+ const kind = getProviderKind(model);
118
+ return { kind };
119
+ }
120
+
121
+ // --- Auth Compatibility Layer ---
122
+
123
+ type ResolvedAuth =
124
+ | { ok: true; apiKey?: string; headers?: Record<string, string>; }
125
+ | { ok: false; error: string; };
126
+
127
+ /**
128
+ * Get API key and headers for a model.
129
+ */
130
+ async function getAuth(ctx: NativeWebSearchContext, model: NativeWebSearchModel): Promise<ResolvedAuth> {
131
+ return ctx.modelRegistry.getApiKeyAndHeaders(model);
132
+ }
133
+
134
+ // --- Streaming API Call ---
135
+
136
+ export interface Source {
137
+ title: string;
138
+ url: string;
139
+ }
140
+
141
+ export interface SearchResultDetail {
142
+ title?: string;
143
+ url?: string;
144
+ query?: string;
145
+ source?: string;
146
+ pageAge?: string | null;
147
+ citedText?: string;
148
+ status?: string;
149
+ type?: string;
150
+ raw?: any;
151
+ }
152
+
153
+ export interface NativeSearchCallDetail {
154
+ id?: string;
155
+ provider: ProviderKind;
156
+ status?: string;
157
+ actionType?: string;
158
+ queries?: string[];
159
+ urls?: string[];
160
+ raw?: any;
161
+ }
162
+
163
+ export interface StreamResult {
164
+ text: string;
165
+ sources?: Source[];
166
+ providerKind?: ProviderKind;
167
+ nativeSearchUsed?: boolean;
168
+ nativeSearchEvents?: string[];
169
+ nativeSearchCalls?: NativeSearchCallDetail[];
170
+ searchQueries?: string[];
171
+ searchResults?: SearchResultDetail[];
172
+ citations?: SearchResultDetail[];
173
+ groundingMetadata?: any;
174
+ urlContextMetadata?: any;
175
+ }
176
+
177
+ type SseEvent = {
178
+ event: string;
179
+ data: any;
180
+ };
181
+
182
+ async function readSseEvents(
183
+ response: Response,
184
+ signal: AbortSignal | undefined,
185
+ onEvent: (event: SseEvent) => boolean | void | Promise<boolean | void>
186
+ ): Promise<void> {
187
+ if (!response.body) {
188
+ throw new Error("No response body");
189
+ }
190
+
191
+ const reader = response.body.getReader();
192
+ const decoder = new TextDecoder();
193
+ let buffer = "";
194
+ let currentEventData = "";
195
+ let currentEventName = "";
196
+ let stopRequested = false;
197
+ let reachedEof = false;
198
+
199
+ const flushEvent = async (): Promise<boolean> => {
200
+ if (!currentEventData) return false;
201
+ const raw = currentEventData.trim();
202
+ currentEventData = "";
203
+ const eventName = currentEventName;
204
+ currentEventName = "";
205
+ if (!raw || raw === "[DONE]") return false;
206
+
207
+ let data: any;
208
+ try {
209
+ data = JSON.parse(raw);
210
+ } catch {
211
+ return false;
212
+ }
213
+ return await onEvent({ event: eventName, data }) === true;
214
+ };
215
+
216
+ try {
217
+ readLoop: while (true) {
218
+ if (signal?.aborted) {
219
+ throw new Error("Request was aborted");
220
+ }
221
+
222
+ const { done, value } = await reader.read();
223
+ if (done) {
224
+ reachedEof = true;
225
+ break;
226
+ }
227
+
228
+ buffer += decoder.decode(value, { stream: true });
229
+ const lines = buffer.split("\n");
230
+ buffer = lines.pop() || "";
231
+
232
+ for (const line of lines) {
233
+ if (line === "" || line === "\r") {
234
+ if (await flushEvent()) {
235
+ stopRequested = true;
236
+ break readLoop;
237
+ }
238
+ continue;
239
+ }
240
+
241
+ if (line.startsWith("data:")) {
242
+ const data = line.slice(5).trim();
243
+ currentEventData = currentEventData ? currentEventData + "\n" + data : data;
244
+ } else if (line.startsWith("event:")) {
245
+ currentEventName = line.slice(6).trim();
246
+ }
247
+ }
248
+ }
249
+
250
+ if (!stopRequested && buffer.trim()) {
251
+ const line = buffer.trim();
252
+ if (line.startsWith("data:")) {
253
+ const data = line.slice(5).trim();
254
+ currentEventData = currentEventData ? currentEventData + "\n" + data : data;
255
+ }
256
+ }
257
+ if (!stopRequested) stopRequested = await flushEvent();
258
+ } finally {
259
+ if (!reachedEof) {
260
+ try {
261
+ await reader.cancel();
262
+ } catch {
263
+ // Ignore cancellation failures while cleaning up an interrupted stream.
264
+ }
265
+ }
266
+ reader.releaseLock();
267
+ }
268
+ }
269
+
270
+ function extractPromptFromGeminiBody(body: any): string {
271
+ const parts: string[] = [];
272
+ for (const content of body?.contents || []) {
273
+ for (const part of content?.parts || []) {
274
+ if (typeof part?.text === "string") {
275
+ parts.push(part.text);
276
+ } else if (part?.file_data?.file_uri) {
277
+ parts.push(String(part.file_data.file_uri));
278
+ }
279
+ }
280
+ }
281
+ return parts.join("\n\n").trim();
282
+ }
283
+
284
+ function trimTrailingSlash(value: string): string {
285
+ return value.replace(/\/+$/, "");
286
+ }
287
+
288
+ function isOpenAICodexModel(model: NativeWebSearchModel): boolean {
289
+ return model.api === "openai-codex-responses";
290
+ }
291
+
292
+ function resolveOpenAIResponsesUrl(model: NativeWebSearchModel): string {
293
+ const base = trimTrailingSlash(model.baseUrl);
294
+ if (!isOpenAICodexModel(model)) return `${base}/responses`;
295
+ if (base.endsWith("/codex/responses")) return base;
296
+ if (base.endsWith("/codex")) return `${base}/responses`;
297
+ return `${base}/codex/responses`;
298
+ }
299
+
300
+ function extractOpenAICodexAccountId(token: string): string {
301
+ try {
302
+ const parts = token.split(".");
303
+ if (parts.length !== 3) throw new Error("Invalid token");
304
+ const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
305
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
306
+ const bytes = Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
307
+ const payload = JSON.parse(new TextDecoder().decode(bytes));
308
+ const accountId = payload?.["https://api.openai.com/auth"]?.chatgpt_account_id;
309
+ if (typeof accountId !== "string" || !accountId) throw new Error("Missing account ID");
310
+ return accountId;
311
+ } catch {
312
+ throw new Error("Failed to extract ChatGPT account ID from openai-codex credentials");
313
+ }
314
+ }
315
+
316
+ function resolveAnthropicMessagesUrl(baseUrl: string): string {
317
+ const base = trimTrailingSlash(baseUrl);
318
+ return base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
319
+ }
320
+
321
+ function pushUniqueSource(sources: Source[], source: Source): number {
322
+ const url = normalizeSearchUrl(source.url || "");
323
+ const title = source.title || "Unknown";
324
+ const existingIndex = sources.findIndex((s) => s.url === url);
325
+ if (existingIndex >= 0) return existingIndex;
326
+ sources.push({ title, url });
327
+ return sources.length - 1;
328
+ }
329
+
330
+ function pushUniqueString(values: string[], value: string | undefined | null) {
331
+ if (!value || values.includes(value)) return;
332
+ values.push(value);
333
+ }
334
+
335
+ function pushUniqueSearchResult(results: SearchResultDetail[], result: SearchResultDetail) {
336
+ const key = `${result.url || ""}\t${result.title || ""}\t${result.query || ""}\t${result.citedText || ""}\t${result.type || ""}`;
337
+ const exists = results.some((item) => `${item.url || ""}\t${item.title || ""}\t${item.query || ""}\t${item.citedText || ""}\t${item.type || ""}` === key);
338
+ if (!exists) results.push(result);
339
+ }
340
+
341
+ function pushNativeSearchEvent(events: string[], event: string) {
342
+ if (!events.includes(event)) events.push(event);
343
+ }
344
+
345
+ export function normalizeSearchUrl(url: string): string {
346
+ try {
347
+ const parsed = new URL(url);
348
+ parsed.username = "";
349
+ parsed.password = "";
350
+ parsed.hash = "";
351
+ for (const name of [...parsed.searchParams.keys()]) {
352
+ if (/^(?:(?:access|auth|refresh|security|session)_?token|api_?key|auth(?:orization)?|code|credential|key|password|secret|sig(?:nature)?|token|x-(?:amz|goog)-(?:credential|security-token|signature))$/i.test(name)) {
353
+ parsed.searchParams.delete(name);
354
+ }
355
+ }
356
+ if (!/(^|\.)youtube\.com$/i.test(parsed.hostname) && !/(^|\.)youtu\.be$/i.test(parsed.hostname)) {
357
+ const removableParams = ["ref", "referral_type", "openLinerExtension", "_clear", "lang", "api-mode"];
358
+ for (const name of removableParams) parsed.searchParams.delete(name);
359
+ for (const name of [...parsed.searchParams.keys()]) {
360
+ if (name.toLowerCase().startsWith("utm_")) parsed.searchParams.delete(name);
361
+ }
362
+ }
363
+ const query = parsed.searchParams.toString();
364
+ parsed.search = query ? `?${query}` : "";
365
+ return parsed.toString();
366
+ } catch {
367
+ return url;
368
+ }
369
+ }
370
+
371
+ function titleFromUrl(url: string): string {
372
+ try {
373
+ const parsed = new URL(url);
374
+ const lastSegment = parsed.pathname.split("/").filter(Boolean).pop();
375
+ return lastSegment || parsed.hostname || url;
376
+ } catch {
377
+ return url;
378
+ }
379
+ }
380
+
381
+ function extractOpenAIUrlCitation(annotation: any): { endIndex?: number; title: string; url: string } | undefined {
382
+ const nested = annotation?.url_citation || annotation?.urlCitation;
383
+ const url = annotation?.url || nested?.url;
384
+ if (!url || typeof url !== "string") return undefined;
385
+
386
+ const title = annotation?.title || nested?.title || titleFromUrl(url);
387
+ const endIndexValue = annotation?.end_index ?? annotation?.endIndex ?? nested?.end_index ?? nested?.endIndex;
388
+ return {
389
+ endIndex: typeof endIndexValue === "number" ? endIndexValue : undefined,
390
+ title,
391
+ url,
392
+ };
393
+ }
394
+
395
+ function mergeSearchResultMetadata(results: SearchResultDetail[], extras: SearchResultDetail[]) {
396
+ for (const extra of extras) {
397
+ if (!extra.url) continue;
398
+ const existing = results.find((item) => item.url === extra.url);
399
+ if (!existing) continue;
400
+ if (!existing.title && extra.title) existing.title = extra.title;
401
+ if (!existing.query && extra.query) existing.query = extra.query;
402
+ if (!existing.citedText && extra.citedText) existing.citedText = extra.citedText;
403
+ if (!existing.status && extra.status) existing.status = extra.status;
404
+ if (!existing.type && extra.type) existing.type = extra.type;
405
+ if (!existing.source && extra.source) existing.source = extra.source;
406
+ }
407
+ }
408
+
409
+ function isLikelyJunkSearchUrl(url: string | undefined): boolean {
410
+ if (!url) return true;
411
+ try {
412
+ const parsed = new URL(url);
413
+ const decodedPath = decodeURIComponent(parsed.pathname).toLowerCase();
414
+ const suspiciousSuffixes = [
415
+ ".gz", ".zip", ".tgz", ".tar", ".woff", ".woff2", ".ttf", ".otf", ".eot",
416
+ ".webm", ".mp4", ".mp3", ".wav", ".eps", ".sql", ".csv", ".xls", ".xlsx", ".ppt", ".pptx"
417
+ ];
418
+ if (suspiciousSuffixes.some((suffix) => decodedPath.endsWith(suffix))) return true;
419
+ if (decodedPath === "/%" || decodedPath.endsWith("/%")) return true;
420
+ return false;
421
+ } catch {
422
+ return false;
423
+ }
424
+ }
425
+
426
+ function sanitizeSearchResults(results: SearchResultDetail[]): SearchResultDetail[] {
427
+ const sanitized: SearchResultDetail[] = [];
428
+ for (const result of results) {
429
+ const normalizedUrl = result.url ? normalizeSearchUrl(result.url) : result.url;
430
+ const normalized = { ...result, url: normalizedUrl };
431
+ if (normalized.url && isLikelyJunkSearchUrl(normalized.url)) continue;
432
+ pushUniqueSearchResult(sanitized, normalized);
433
+ }
434
+ return sanitized;
435
+ }
436
+
437
+ function deriveSources(searchResults: SearchResultDetail[], citations: SearchResultDetail[] = []): Source[] {
438
+ const sources: Source[] = [];
439
+ for (const item of [...citations, ...searchResults]) {
440
+ if (!item.url) continue;
441
+ const url = normalizeSearchUrl(item.url);
442
+ if (isLikelyJunkSearchUrl(url)) continue;
443
+ pushUniqueSource(sources, {
444
+ title: item.title || titleFromUrl(url),
445
+ url,
446
+ });
447
+ }
448
+ return sources;
449
+ }
450
+
451
+ function isGoogleGroundingRedirect(url: string | undefined): boolean {
452
+ return !!url && /^https:\/\/vertexaisearch\.cloud\.google\.com\/grounding-api-redirect\//.test(url);
453
+ }
454
+
455
+ async function resolveGoogleGroundingRedirectUrls(searchResults: SearchResultDetail[], citations: SearchResultDetail[], signal?: AbortSignal) {
456
+ const redirectUrls = [...new Set([...searchResults, ...citations].map((item) => item.url).filter((url): url is string => isGoogleGroundingRedirect(url)))];
457
+ if (redirectUrls.length === 0) return;
458
+
459
+ const resolved = new Map<string, string>();
460
+ await Promise.all(redirectUrls.slice(0, 20).map(async (url) => {
461
+ try {
462
+ const response = await fetch(url, { method: "HEAD", redirect: "manual", signal });
463
+ const location = response.headers.get("location");
464
+ if (location) resolved.set(url, location);
465
+ } catch {
466
+ signal?.throwIfAborted();
467
+ // Ignore redirect resolution failures and keep the original URL.
468
+ }
469
+ }));
470
+
471
+ if (resolved.size === 0) return;
472
+ for (const item of [...searchResults, ...citations]) {
473
+ if (!item.url) continue;
474
+ const canonicalUrl = resolved.get(item.url);
475
+ if (!canonicalUrl) continue;
476
+ item.url = canonicalUrl;
477
+ if (!item.title || item.title === "Unknown") item.title = titleFromUrl(canonicalUrl);
478
+ }
479
+ }
480
+
481
+ function applyIndexCitations(text: string, citations: Array<{ endIndex?: number; title: string; url: string }>): { text: string; sources: Source[] } {
482
+ const sources: Source[] = [];
483
+ const insertions = citations
484
+ .filter((c) => c.url && c.endIndex !== undefined)
485
+ .map((c) => ({
486
+ index: Math.max(0, Math.min(c.endIndex!, text.length)),
487
+ marker: `[${pushUniqueSource(sources, { title: c.title, url: c.url }) + 1}]`
488
+ }))
489
+ .sort((a, b) => b.index - a.index);
490
+
491
+ let result = text;
492
+ const seen = new Set<string>();
493
+ for (const insertion of insertions) {
494
+ const key = `${insertion.index}:${insertion.marker}`;
495
+ if (seen.has(key)) continue;
496
+ seen.add(key);
497
+ result = result.slice(0, insertion.index) + insertion.marker + result.slice(insertion.index);
498
+ }
499
+
500
+ // Preserve sources that had no end index.
501
+ for (const citation of citations) {
502
+ if (citation.url) pushUniqueSource(sources, { title: citation.title, url: citation.url });
503
+ }
504
+
505
+ return { text: result, sources };
506
+ }
507
+
508
+ function applyTextCitations(text: string, citations: Array<{ citedText?: string; title: string; url: string }>): { text: string; sources: Source[] } {
509
+ const sources: Source[] = [];
510
+ const insertions: Array<{ index: number; marker: string }> = [];
511
+ const usedRanges = new Set<string>();
512
+
513
+ for (const citation of citations) {
514
+ if (!citation.url) continue;
515
+ const marker = `[${pushUniqueSource(sources, { title: citation.title, url: citation.url }) + 1}]`;
516
+ const citedText = citation.citedText?.trim();
517
+ if (!citedText) continue;
518
+ const index = text.indexOf(citedText);
519
+ if (index < 0) continue;
520
+ const end = index + citedText.length;
521
+ const key = `${end}:${marker}`;
522
+ if (usedRanges.has(key)) continue;
523
+ usedRanges.add(key);
524
+ insertions.push({ index: end, marker });
525
+ }
526
+
527
+ let result = text;
528
+ for (const insertion of insertions.sort((a, b) => b.index - a.index)) {
529
+ result = result.slice(0, insertion.index) + insertion.marker + result.slice(insertion.index);
530
+ }
531
+
532
+ return { text: result, sources };
533
+ }
534
+
535
+ function extractGoogleSearchDetails(groundingMetadata: any): { searchQueries: string[]; searchResults: SearchResultDetail[]; citations: SearchResultDetail[] } {
536
+ const searchQueries = groundingMetadata?.webSearchQueries || [];
537
+ const chunks = groundingMetadata?.groundingChunks || [];
538
+ const supports = groundingMetadata?.groundingSupports || [];
539
+ const searchResults: SearchResultDetail[] = [];
540
+ const citations: SearchResultDetail[] = [];
541
+
542
+ chunks.forEach((chunk: any, index: number) => {
543
+ if (!chunk?.web) return;
544
+ pushUniqueSearchResult(searchResults, {
545
+ title: chunk.web.title || "Unknown",
546
+ url: chunk.web.uri || "",
547
+ source: "google.groundingChunks",
548
+ type: "web",
549
+ raw: { index, ...chunk.web },
550
+ });
551
+ });
552
+
553
+ supports.forEach((support: any) => {
554
+ for (const index of support?.groundingChunkIndices || []) {
555
+ const web = chunks[index]?.web;
556
+ if (!web) continue;
557
+ pushUniqueSearchResult(citations, {
558
+ title: web.title || "Unknown",
559
+ url: web.uri || "",
560
+ citedText: support?.segment?.text,
561
+ source: "google.groundingSupports",
562
+ type: "citation",
563
+ raw: support,
564
+ });
565
+ }
566
+ });
567
+
568
+ return { searchQueries, searchResults, citations };
569
+ }
570
+
571
+ async function callGoogleStream(
572
+ ctx: NativeWebSearchContext,
573
+ model: NativeWebSearchModel,
574
+ body: any,
575
+ onUpdate?: NativeProgress,
576
+ signal?: AbortSignal
577
+ ): Promise<StreamResult> {
578
+ const config = getConfig(model);
579
+ if (!config.buildRequest) {
580
+ throw new Error(`Unsupported Google provider: ${model.provider}`);
581
+ }
582
+
583
+ const auth = await getAuth(ctx, model);
584
+ if (!auth.ok) {
585
+ throw new Error(auth.error || "Failed to get API key and headers");
586
+ }
587
+
588
+ const req = config.buildRequest(model, body);
589
+
590
+ // Handle auth
591
+ if (auth.headers) {
592
+ Object.assign(req.headers, auth.headers);
593
+ }
594
+ if (auth.apiKey) {
595
+ req.headers["x-goog-api-key"] = auth.apiKey;
596
+ }
597
+
598
+ const response = await fetch(req.url, {
599
+ method: "POST",
600
+ headers: req.headers,
601
+ body: JSON.stringify(req.body),
602
+ signal
603
+ });
604
+
605
+ if (!response.ok) {
606
+ throw new Error(`Google API error (${response.status})`);
607
+ }
608
+
609
+ let accumulatedText = "";
610
+ let groundingMetadata: any;
611
+ let urlContextMetadata: any;
612
+
613
+ await readSseEvents(response, signal, ({ data: chunk }) => {
614
+ if (chunk.error) {
615
+ throw new Error("Google API stream error");
616
+ }
617
+
618
+ // Unwrap response for internal APIs
619
+ const data = chunk.response || chunk;
620
+ const candidate = data.candidates?.[0];
621
+
622
+ if (candidate?.content?.parts) {
623
+ for (const part of candidate.content.parts) {
624
+ if (part.text) {
625
+ accumulatedText += part.text;
626
+ onUpdate?.(accumulatedText);
627
+ }
628
+ }
629
+ }
630
+
631
+ // Capture metadata from final chunk
632
+ if (candidate?.groundingMetadata) {
633
+ groundingMetadata = candidate.groundingMetadata;
634
+ }
635
+ // Handle both camelCase and snake_case
636
+ if (candidate?.urlContextMetadata || candidate?.url_context_metadata) {
637
+ urlContextMetadata = candidate.urlContextMetadata || candidate.url_context_metadata;
638
+ }
639
+ });
640
+
641
+ const searchDetails = extractGoogleSearchDetails(groundingMetadata);
642
+ await resolveGoogleGroundingRedirectUrls(searchDetails.searchResults, searchDetails.citations, signal);
643
+ const searchResults = sanitizeSearchResults(searchDetails.searchResults);
644
+ const citations = sanitizeSearchResults(searchDetails.citations);
645
+ return {
646
+ text: accumulatedText || "No answer available.",
647
+ sources: deriveSources(searchResults, citations),
648
+ providerKind: "google",
649
+ nativeSearchUsed: searchDetails.searchQueries.length > 0 || searchResults.length > 0,
650
+ nativeSearchEvents: searchDetails.searchQueries.length > 0 ? ["google.groundingMetadata.webSearchQueries"] : [],
651
+ searchQueries: searchDetails.searchQueries,
652
+ searchResults,
653
+ citations,
654
+ groundingMetadata,
655
+ urlContextMetadata
656
+ };
657
+ }
658
+
659
+ async function callOpenAIStream(
660
+ ctx: NativeWebSearchContext,
661
+ model: NativeWebSearchModel,
662
+ prompt: string,
663
+ onUpdate?: NativeProgress,
664
+ signal?: AbortSignal
665
+ ): Promise<StreamResult> {
666
+ const auth = await getAuth(ctx, model);
667
+ if (!auth.ok) {
668
+ throw new Error(auth.error || "Failed to get API key and headers");
669
+ }
670
+
671
+ const headers = new Headers();
672
+ for (const [name, value] of Object.entries(model.headers || {})) headers.set(name, value);
673
+ for (const [name, value] of Object.entries(auth.headers || {})) headers.set(name, value);
674
+ if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
675
+ if (!headers.has("Accept")) headers.set("Accept", "text/event-stream");
676
+ if (auth.apiKey && !headers.has("Authorization")) headers.set("Authorization", `Bearer ${auth.apiKey}`);
677
+
678
+ const isCodex = isOpenAICodexModel(model);
679
+ if (isCodex) {
680
+ const authorization = headers.get("Authorization");
681
+ const hasBearerAuth = typeof authorization === "string" && /^Bearer\s+\S+/i.test(authorization);
682
+ if (!auth.apiKey && !hasBearerAuth) {
683
+ throw new Error("No OAuth credential configured for openai-codex model");
684
+ }
685
+ if (!headers.has("chatgpt-account-id")) {
686
+ if (!auth.apiKey) {
687
+ throw new Error("No ChatGPT account ID configured for openai-codex model");
688
+ }
689
+ headers.set("chatgpt-account-id", extractOpenAICodexAccountId(auth.apiKey));
690
+ }
691
+ if (!headers.has("originator")) headers.set("originator", "codex_cli_rs");
692
+ }
693
+ const requestHeaders = Object.fromEntries(headers.entries());
694
+
695
+ const requestBody: any = {
696
+ model: model.id,
697
+ input: isCodex
698
+ ? [{ role: "user", content: [{ type: "input_text", text: prompt }] }]
699
+ : prompt,
700
+ tools: [{ type: "web_search" }],
701
+ include: isCodex
702
+ ? ["web_search_call.action.sources"]
703
+ : ["web_search_call.action.sources", "web_search_call.results"],
704
+ stream: true,
705
+ store: false,
706
+ };
707
+ if (model.reasoning) {
708
+ requestBody.reasoning = { effort: "none" };
709
+ }
710
+ if (isCodex) {
711
+ requestBody.instructions = "Answer the user's request using web search when needed.";
712
+ requestBody.text = { verbosity: "low" };
713
+ requestBody.tool_choice = "required";
714
+ requestBody.parallel_tool_calls = true;
715
+ }
716
+
717
+ const response = await fetch(resolveOpenAIResponsesUrl(model), {
718
+ method: "POST",
719
+ headers: requestHeaders,
720
+ body: JSON.stringify(requestBody),
721
+ signal
722
+ });
723
+
724
+ if (!response.ok) {
725
+ throw new Error(`OpenAI API error (${response.status})`);
726
+ }
727
+
728
+ let accumulatedText = "";
729
+ const citations: Array<{ endIndex?: number; title: string; url: string }> = [];
730
+ const nativeSearchEvents: string[] = [];
731
+ const nativeSearchCalls: NativeSearchCallDetail[] = [];
732
+ const searchQueries: string[] = [];
733
+ const searchResults: SearchResultDetail[] = [];
734
+
735
+ const collectAnnotation = (annotation: any) => {
736
+ if (annotation?.type !== "url_citation") return;
737
+ const citation = extractOpenAIUrlCitation(annotation);
738
+ if (!citation) return;
739
+ citations.push(citation);
740
+ };
741
+
742
+ const collectWebSearchCall = (item: any) => {
743
+ if (item?.type !== "web_search_call") return;
744
+ const action = item.action || {};
745
+ const call: NativeSearchCallDetail = {
746
+ id: item.id,
747
+ provider: "openai",
748
+ status: item.status,
749
+ actionType: action.type,
750
+ raw: item,
751
+ };
752
+ if (Array.isArray(action.queries)) {
753
+ const queries = action.queries.filter((query: any): query is string => typeof query === "string");
754
+ call.queries = queries;
755
+ for (const query of queries) pushUniqueString(searchQueries, query);
756
+ } else if (typeof action.query === "string") {
757
+ call.queries = [action.query];
758
+ pushUniqueString(searchQueries, action.query);
759
+ }
760
+ if (Array.isArray(action.sources)) {
761
+ call.urls = action.sources.map((source: any) => source?.url).filter((url: any): url is string => typeof url === "string");
762
+ for (const source of action.sources) {
763
+ if (!source?.url) continue;
764
+ pushUniqueSearchResult(searchResults, {
765
+ title: source.title || source.display_name || source.name || titleFromUrl(source.url),
766
+ url: source.url,
767
+ source: "openai.web_search_call.action.sources",
768
+ type: source.type || "url",
769
+ raw: source,
770
+ });
771
+ }
772
+ }
773
+ if (action.url) {
774
+ call.urls = [...(call.urls || []), action.url];
775
+ pushUniqueSearchResult(searchResults, {
776
+ title: titleFromUrl(action.url),
777
+ url: action.url,
778
+ source: `openai.web_search_call.action.${action.type}`,
779
+ type: action.type,
780
+ raw: action,
781
+ });
782
+ }
783
+ const existingCall = call.id ? nativeSearchCalls.find((existing) => existing.id === call.id) : undefined;
784
+ if (existingCall) {
785
+ Object.assign(existingCall, Object.fromEntries(
786
+ Object.entries(call).filter(([, value]) => value !== undefined)
787
+ ));
788
+ } else {
789
+ nativeSearchCalls.push(call);
790
+ }
791
+ };
792
+
793
+ const collectFromResponse = (response: any) => {
794
+ for (const item of response?.output || []) {
795
+ collectWebSearchCall(item);
796
+ if (item?.type !== "message") continue;
797
+ for (const content of item.content || []) {
798
+ if (content?.type !== "output_text") continue;
799
+ for (const annotation of content.annotations || []) collectAnnotation(annotation);
800
+ }
801
+ }
802
+ };
803
+
804
+ await readSseEvents(response, signal, ({ data: event }) => {
805
+ if (event.type === "error" || event.type === "response.failed") {
806
+ throw new Error(event.type === "response.failed"
807
+ ? "OpenAI response failed"
808
+ : "OpenAI stream error");
809
+ } else if (event.type === "response.output_text.delta") {
810
+ accumulatedText += event.delta || "";
811
+ onUpdate?.(accumulatedText);
812
+ } else if (event.type === "response.output_text.annotation.added") {
813
+ collectAnnotation(event.annotation);
814
+ } else if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
815
+ collectWebSearchCall(event.item);
816
+ } else if (event.type === "response.incomplete" || event.response?.status === "incomplete") {
817
+ collectFromResponse(event.response);
818
+ if (isCodex) return true;
819
+ } else if (event.type === "response.completed" || event.type === "response.done") {
820
+ collectFromResponse(event.response);
821
+ if (isCodex) return true;
822
+ } else if (event.type === "response.web_search_call.in_progress" || event.type === "response.web_search_call.searching" || event.type === "response.web_search_call.completed") {
823
+ pushNativeSearchEvent(nativeSearchEvents, event.type);
824
+ const call = nativeSearchCalls.find((item) => item.id === event.item_id);
825
+ if (call) call.status = event.type.replace("response.web_search_call.", "");
826
+ else nativeSearchCalls.push({ id: event.item_id, provider: "openai", status: event.type.replace("response.web_search_call.", ""), raw: event });
827
+ if (event.type === "response.web_search_call.searching") {
828
+ onUpdate?.(accumulatedText || "Searching the web with OpenAI...");
829
+ }
830
+ }
831
+ });
832
+
833
+ const cited = applyIndexCitations(accumulatedText || "No answer available.", citations);
834
+ const citationDetails = citations.map((citation) => ({
835
+ title: citation.title,
836
+ url: citation.url,
837
+ source: "openai.url_citation",
838
+ type: "citation",
839
+ raw: citation,
840
+ }));
841
+ for (const citation of citationDetails) pushUniqueSearchResult(searchResults, citation);
842
+ mergeSearchResultMetadata(searchResults, citationDetails);
843
+ const sanitizedSearchResults = sanitizeSearchResults(searchResults);
844
+ const sanitizedCitations = sanitizeSearchResults(citationDetails);
845
+ mergeSearchResultMetadata(sanitizedSearchResults, sanitizedCitations);
846
+ const derivedSources = deriveSources(sanitizedSearchResults, sanitizedCitations);
847
+
848
+ return {
849
+ text: cited.text,
850
+ sources: cited.sources.length ? cited.sources.map((source) => ({ ...source, url: normalizeSearchUrl(source.url) })).filter((source) => !isLikelyJunkSearchUrl(source.url)) : derivedSources,
851
+ providerKind: "openai",
852
+ nativeSearchUsed: nativeSearchEvents.length > 0 || nativeSearchCalls.length > 0 || sanitizedSearchResults.length > 0,
853
+ nativeSearchEvents,
854
+ nativeSearchCalls,
855
+ searchQueries,
856
+ searchResults: sanitizedSearchResults,
857
+ citations: sanitizedCitations,
858
+ };
859
+ }
860
+
861
+ async function callAnthropicStream(
862
+ ctx: NativeWebSearchContext,
863
+ model: NativeWebSearchModel,
864
+ prompt: string,
865
+ onUpdate?: NativeProgress,
866
+ signal?: AbortSignal
867
+ ): Promise<StreamResult> {
868
+ const auth = await getAuth(ctx, model);
869
+ if (!auth.ok) {
870
+ throw new Error(auth.error || "Failed to get API key and headers");
871
+ }
872
+
873
+ const isOAuth = !!auth.apiKey && auth.apiKey.includes("sk-ant-oat");
874
+ const headers: Record<string, string> = {
875
+ "Content-Type": "application/json",
876
+ "Accept": "text/event-stream",
877
+ "anthropic-version": "2023-06-01",
878
+ ...(model.headers || {}),
879
+ ...(auth.headers || {}),
880
+ };
881
+
882
+ if (auth.apiKey) {
883
+ if (isOAuth) {
884
+ if (!headers.Authorization && !headers.authorization) headers.Authorization = `Bearer ${auth.apiKey}`;
885
+ headers["anthropic-beta"] = headers["anthropic-beta"]
886
+ ? `${headers["anthropic-beta"]},claude-code-20250219,oauth-2025-04-20`
887
+ : "claude-code-20250219,oauth-2025-04-20";
888
+ headers["user-agent"] = headers["user-agent"] || "claude-cli/2.1.75";
889
+ headers["x-app"] = headers["x-app"] || "cli";
890
+ } else if (!headers["x-api-key"] && !headers["X-Api-Key"]) {
891
+ headers["x-api-key"] = auth.apiKey;
892
+ }
893
+ }
894
+
895
+ const maxTokens = Math.min(Math.max(1024, Math.floor(model.maxTokens / 3) || 4096), 8192);
896
+ const requestBody = {
897
+ model: model.id,
898
+ max_tokens: maxTokens,
899
+ messages: [{ role: "user", content: prompt }],
900
+ tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 10 }],
901
+ stream: true,
902
+ };
903
+
904
+ const response = await fetch(resolveAnthropicMessagesUrl(model.baseUrl), {
905
+ method: "POST",
906
+ headers,
907
+ body: JSON.stringify(requestBody),
908
+ signal
909
+ });
910
+
911
+ if (!response.ok) {
912
+ throw new Error(`Anthropic API error (${response.status})`);
913
+ }
914
+
915
+ let accumulatedText = "";
916
+ const citations: Array<{ citedText?: string; title: string; url: string }> = [];
917
+ const nativeSearchEvents: string[] = [];
918
+ const nativeSearchCalls: NativeSearchCallDetail[] = [];
919
+ const searchResults: SearchResultDetail[] = [];
920
+
921
+ const collectSource = (source: any, toolUseId?: string) => {
922
+ if (!source?.url) return;
923
+ const title = source.title || titleFromUrl(source.url);
924
+ citations.push({ title, url: source.url });
925
+ pushUniqueSearchResult(searchResults, {
926
+ title,
927
+ url: source.url,
928
+ pageAge: source.page_age ?? source.pageAge,
929
+ source: "anthropic.web_search_tool_result",
930
+ type: source.type || "web_search_result",
931
+ raw: { toolUseId, ...source },
932
+ });
933
+ };
934
+
935
+ await readSseEvents(response, signal, ({ data: event }) => {
936
+ if (event.type === "content_block_start") {
937
+ const block = event.content_block;
938
+ if (block?.type === "text" && block.text) {
939
+ accumulatedText += block.text;
940
+ onUpdate?.(accumulatedText);
941
+ } else if (block?.type === "server_tool_use" && block.name === "web_search") {
942
+ pushNativeSearchEvent(nativeSearchEvents, "anthropic.content_block_start.server_tool_use.web_search");
943
+ nativeSearchCalls.push({
944
+ id: block.id,
945
+ provider: "anthropic",
946
+ status: "in_progress",
947
+ actionType: block.name,
948
+ queries: typeof block.input?.query === "string" ? [block.input.query] : undefined,
949
+ raw: block,
950
+ });
951
+ onUpdate?.(accumulatedText || "Searching the web with Anthropic...");
952
+ } else if (block?.type === "web_search_tool_result") {
953
+ pushNativeSearchEvent(nativeSearchEvents, "anthropic.content_block_start.web_search_tool_result");
954
+ const call = nativeSearchCalls.find((item) => item.id === block.tool_use_id);
955
+ if (call) call.status = "completed";
956
+ else nativeSearchCalls.push({ id: block.tool_use_id, provider: "anthropic", status: "completed", actionType: "web_search", raw: block });
957
+ if (Array.isArray(block.content)) {
958
+ for (const result of block.content) collectSource(result, block.tool_use_id);
959
+ } else if (block.content?.type === "web_search_tool_result_error") {
960
+ pushUniqueSearchResult(searchResults, {
961
+ status: block.content.error_code,
962
+ source: "anthropic.web_search_tool_result_error",
963
+ type: block.content.type,
964
+ raw: block,
965
+ });
966
+ }
967
+ }
968
+ } else if (event.type === "content_block_delta") {
969
+ const delta = event.delta;
970
+ if (delta?.type === "text_delta") {
971
+ accumulatedText += delta.text || "";
972
+ onUpdate?.(accumulatedText);
973
+ } else if (delta?.type === "citations_delta") {
974
+ const citation = delta.citation;
975
+ if (citation?.type === "web_search_result_location" && citation.url) {
976
+ const detail = {
977
+ citedText: citation.cited_text,
978
+ title: citation.title || titleFromUrl(citation.url),
979
+ url: citation.url,
980
+ source: "anthropic.citations_delta",
981
+ type: citation.type,
982
+ raw: citation,
983
+ };
984
+ citations.push({ citedText: detail.citedText, title: detail.title, url: detail.url });
985
+ pushUniqueSearchResult(searchResults, detail);
986
+ }
987
+ }
988
+ } else if (event.type === "error") {
989
+ throw new Error("Anthropic stream error");
990
+ }
991
+ });
992
+
993
+ const cited = applyTextCitations(accumulatedText || "No answer available.", citations);
994
+ const citationDetails = citations.map((citation) => ({
995
+ title: citation.title || titleFromUrl(citation.url),
996
+ url: citation.url,
997
+ citedText: citation.citedText,
998
+ source: "anthropic.citation",
999
+ type: "citation",
1000
+ raw: citation,
1001
+ }));
1002
+ mergeSearchResultMetadata(searchResults, citationDetails);
1003
+ const sanitizedSearchResults = sanitizeSearchResults(searchResults);
1004
+ const sanitizedCitations = sanitizeSearchResults(citationDetails);
1005
+ mergeSearchResultMetadata(sanitizedSearchResults, sanitizedCitations);
1006
+ const derivedSources = deriveSources(sanitizedSearchResults, sanitizedCitations);
1007
+
1008
+ return {
1009
+ text: cited.text,
1010
+ sources: cited.sources.length ? cited.sources.map((source) => ({ ...source, url: normalizeSearchUrl(source.url) })).filter((source) => !isLikelyJunkSearchUrl(source.url)) : derivedSources,
1011
+ providerKind: "anthropic",
1012
+ nativeSearchUsed: nativeSearchEvents.length > 0 || nativeSearchCalls.length > 0 || sanitizedSearchResults.length > 0,
1013
+ nativeSearchEvents,
1014
+ nativeSearchCalls,
1015
+ searchQueries: nativeSearchCalls.flatMap((call) => call.queries || []),
1016
+ searchResults: sanitizedSearchResults,
1017
+ citations: sanitizedCitations,
1018
+ };
1019
+ }
1020
+
1021
+ export async function callApiStream(
1022
+ ctx: NativeWebSearchContext,
1023
+ model: NativeWebSearchModel,
1024
+ body: any,
1025
+ onUpdate?: NativeProgress,
1026
+ signal?: AbortSignal
1027
+ ): Promise<StreamResult> {
1028
+ const kind = getProviderKind(model);
1029
+ if (kind === "google") {
1030
+ return callGoogleStream(ctx, model, body, onUpdate, signal);
1031
+ }
1032
+
1033
+ const prompt = extractPromptFromGeminiBody(body);
1034
+ if (!prompt) {
1035
+ throw new Error("No prompt text found in request body");
1036
+ }
1037
+
1038
+ if (kind === "openai") {
1039
+ return callOpenAIStream(ctx, model, prompt, onUpdate, signal);
1040
+ }
1041
+ if (kind === "anthropic") {
1042
+ return callAnthropicStream(ctx, model, prompt, onUpdate, signal);
1043
+ }
1044
+
1045
+ throw new Error(`Unsupported provider for web search: ${model.provider} (${model.api})`);
1046
+ }
1047
+
1048
+ // --- Citation Processing (byte-safe) ---
1049
+
1050
+ export function applyCitations(text: string, groundingMetadata: any): { text: string; sources: Source[] } {
1051
+ const chunks = groundingMetadata?.groundingChunks || [];
1052
+ const supports = groundingMetadata?.groundingSupports || [];
1053
+
1054
+ const sources = chunks
1055
+ .filter((c: any) => c.web)
1056
+ .map((c: any) => ({ title: c.web.title || "Unknown", url: c.web.uri || "" }));
1057
+
1058
+ if (!supports.length || !sources.length) return { text, sources };
1059
+
1060
+ // Collect insertions, sort descending
1061
+ const insertions = supports
1062
+ .filter((s: any) => s.segment?.endIndex !== undefined && s.groundingChunkIndices?.length)
1063
+ .map((s: any) => ({
1064
+ index: s.segment.endIndex,
1065
+ marker: s.groundingChunkIndices.map((i: number) => `[${i + 1}]`).join("")
1066
+ }))
1067
+ .sort((a: any, b: any) => b.index - a.index);
1068
+
1069
+ // Byte-safe insertion
1070
+ const encoder = new TextEncoder();
1071
+ const decoder = new TextDecoder();
1072
+ const bytes = encoder.encode(text);
1073
+
1074
+ const parts: Uint8Array[] = [];
1075
+ let lastIndex = bytes.length;
1076
+
1077
+ for (const ins of insertions) {
1078
+ const pos = Math.min(ins.index, lastIndex);
1079
+ if (pos < lastIndex) parts.unshift(bytes.subarray(pos, lastIndex));
1080
+ parts.unshift(encoder.encode(ins.marker));
1081
+ lastIndex = pos;
1082
+ }
1083
+ if (lastIndex > 0) parts.unshift(bytes.subarray(0, lastIndex));
1084
+
1085
+ const total = parts.reduce((acc, p) => acc + p.length, 0);
1086
+ const final = new Uint8Array(total);
1087
+ let offset = 0;
1088
+ for (const part of parts) {
1089
+ final.set(part, offset);
1090
+ offset += part.length;
1091
+ }
1092
+
1093
+ return { text: decoder.decode(final), sources };
1094
+ }
1095
+
1096
+ const PROVIDER_ERROR_MAX_LENGTH = 2_048;
1097
+ const NATIVE_TIMEOUT_MS = 60_000;
1098
+
1099
+ export interface WebSearchOptions {
1100
+ endpoint: RuntimeModelEndpoint;
1101
+ model: string;
1102
+ maxTokens: number;
1103
+ reasoning: boolean;
1104
+ timeoutMs?: number;
1105
+ }
1106
+
1107
+ function apiFor(endpoint: RuntimeModelEndpoint): NativeApi {
1108
+ switch (endpoint.protocol) {
1109
+ case "google-generative-ai":
1110
+ return "google-generative-ai";
1111
+ case "openai-chat":
1112
+ return "openai-responses";
1113
+ case "openai-codex-responses":
1114
+ return "openai-codex-responses";
1115
+ case "anthropic-messages":
1116
+ return "anthropic-messages";
1117
+ }
1118
+ }
1119
+
1120
+ function providerFor(api: NativeApi): string {
1121
+ switch (api) {
1122
+ case "google-generative-ai":
1123
+ return "google";
1124
+ case "openai-responses":
1125
+ return "openai";
1126
+ case "openai-codex-responses":
1127
+ return "openai-codex";
1128
+ case "anthropic-messages":
1129
+ return "anthropic";
1130
+ }
1131
+ }
1132
+
1133
+ function cleanDetail(detail: SearchResultDetail): WebSearchResultDetail {
1134
+ const { raw: _raw, ...clean } = detail;
1135
+ return {
1136
+ ...clean,
1137
+ ...(clean.url ? { url: normalizeSearchUrl(clean.url) } : {}),
1138
+ };
1139
+ }
1140
+
1141
+ function safeProviderError(
1142
+ error: unknown,
1143
+ endpoint: RuntimeModelEndpoint,
1144
+ ): Error {
1145
+ let message = error instanceof Error ? error.message : String(error);
1146
+ const secrets = [
1147
+ endpoint.apiKey,
1148
+ endpoint.baseURL,
1149
+ ...Object.values(endpoint.headers ?? {}),
1150
+ ].filter((value) => value.length >= 4);
1151
+ for (const secret of secrets) {
1152
+ message = message.split(secret).join("[REDACTED]");
1153
+ }
1154
+ return new Error(message.slice(0, PROVIDER_ERROR_MAX_LENGTH));
1155
+ }
1156
+
1157
+ export function createWebSearch(
1158
+ options: WebSearchOptions,
1159
+ ): WebSearch {
1160
+ const api = apiFor(options.endpoint);
1161
+ const model: NativeWebSearchModel = {
1162
+ id: options.model,
1163
+ provider: providerFor(api),
1164
+ api,
1165
+ baseUrl: options.endpoint.baseURL,
1166
+ reasoning: options.reasoning,
1167
+ maxTokens: options.maxTokens,
1168
+ };
1169
+ const context: NativeWebSearchContext = {
1170
+ modelRegistry: {
1171
+ async getApiKeyAndHeaders() {
1172
+ return {
1173
+ ok: true,
1174
+ apiKey: options.endpoint.apiKey,
1175
+ ...(options.endpoint.headers
1176
+ ? { headers: { ...options.endpoint.headers } }
1177
+ : {}),
1178
+ };
1179
+ },
1180
+ },
1181
+ };
1182
+
1183
+ return async (input, execution): Promise<WebSearchResult> => {
1184
+ const timeout = AbortSignal.timeout(
1185
+ options.timeoutMs ?? NATIVE_TIMEOUT_MS,
1186
+ );
1187
+ const signal = execution.signal
1188
+ ? AbortSignal.any([execution.signal, timeout])
1189
+ : timeout;
1190
+ signal.throwIfAborted();
1191
+ const hasUrls = Boolean(input.urls?.length);
1192
+ const prompt = hasUrls
1193
+ ? `${input.query}\n\nAlso analyze these URLs:\n${input.urls!.join("\n")}`
1194
+ : input.query;
1195
+ const config = getConfig(model);
1196
+ const tools = config.kind === "google"
1197
+ ? (hasUrls
1198
+ ? [{ [config.searchTool!]: {} }, { [config.urlContextTool!]: {} }]
1199
+ : [{ [config.searchTool!]: {} }])
1200
+ : undefined;
1201
+ try {
1202
+ const result = await callApiStream(
1203
+ context,
1204
+ model,
1205
+ {
1206
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
1207
+ ...(tools ? { tools } : {}),
1208
+ },
1209
+ execution.onProgress,
1210
+ signal,
1211
+ );
1212
+ if (
1213
+ result.providerKind !== "google" &&
1214
+ result.providerKind !== "openai" &&
1215
+ result.providerKind !== "anthropic"
1216
+ ) {
1217
+ throw new Error("Native web search returned no provider kind");
1218
+ }
1219
+ const cited = applyCitations(result.text, result.groundingMetadata);
1220
+ const sources = (result.sources?.length
1221
+ ? result.sources
1222
+ : cited.sources).map((source) => ({
1223
+ title: source.title,
1224
+ url: normalizeSearchUrl(source.url),
1225
+ }));
1226
+ const metadata = result.urlContextMetadata?.urlMetadata
1227
+ ?? result.urlContextMetadata?.url_metadata
1228
+ ?? [];
1229
+ const retrieved = metadata
1230
+ .filter((item: any) =>
1231
+ (item.urlRetrievalStatus ?? item.url_retrieval_status) ===
1232
+ "URL_RETRIEVAL_STATUS_SUCCESS"
1233
+ )
1234
+ .map((item: any) =>
1235
+ item.retrievedUrl ?? item.retrieved_url ?? item.url
1236
+ )
1237
+ .filter((url: unknown): url is string => typeof url === "string");
1238
+ const failed = metadata
1239
+ .filter((item: any) =>
1240
+ (item.urlRetrievalStatus ?? item.url_retrieval_status) !==
1241
+ "URL_RETRIEVAL_STATUS_SUCCESS"
1242
+ )
1243
+ .map((item: any) => ({
1244
+ url: item.retrievedUrl ?? item.retrieved_url ?? item.url,
1245
+ status: item.urlRetrievalStatus ?? item.url_retrieval_status,
1246
+ }));
1247
+
1248
+ return {
1249
+ text: cited.text,
1250
+ sources,
1251
+ providerKind: result.providerKind,
1252
+ nativeSearchUsed: result.nativeSearchUsed ?? false,
1253
+ nativeSearchEvents: result.nativeSearchEvents ?? [],
1254
+ nativeSearchCalls: (result.nativeSearchCalls ?? []).map(
1255
+ ({ raw: _raw, provider, ...call }) => ({
1256
+ ...call,
1257
+ provider,
1258
+ ...(call.urls
1259
+ ? { urls: call.urls.map(normalizeSearchUrl) }
1260
+ : {}),
1261
+ }),
1262
+ ),
1263
+ searchQueries: result.searchQueries ?? [],
1264
+ searchResults: (result.searchResults ?? []).map(cleanDetail),
1265
+ citations: (result.citations ?? []).map(cleanDetail),
1266
+ ...(retrieved.length
1267
+ ? { retrieved: retrieved.map(normalizeSearchUrl) }
1268
+ : {}),
1269
+ ...(failed.length
1270
+ ? { failed: failed.map((item: {
1271
+ url?: string;
1272
+ status?: string;
1273
+ }) => ({
1274
+ ...item,
1275
+ ...(item.url
1276
+ ? { url: normalizeSearchUrl(item.url) }
1277
+ : {}),
1278
+ })) }
1279
+ : {}),
1280
+ model: options.model,
1281
+ grounded:
1282
+ sources.length > 0 || (result.searchResults?.length ?? 0) > 0,
1283
+ };
1284
+ } catch (error) {
1285
+ if (execution.signal?.aborted) {
1286
+ execution.signal.throwIfAborted();
1287
+ }
1288
+ if (signal.aborted) signal.throwIfAborted();
1289
+ throw safeProviderError(error, options.endpoint);
1290
+ }
1291
+ };
1292
+ }