pi-web-search 1.0.2 → 1.2.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.
package/src/api.ts CHANGED
@@ -1,17 +1,23 @@
1
- import type { ExtensionContext, AgentToolUpdateCallback } from "@mariozechner/pi-coding-agent";
2
- import { type Model } from "@mariozechner/pi-ai";
1
+ import type { ExtensionContext, AgentToolUpdateCallback } from "@earendil-works/pi-coding-agent";
2
+ import { type Model } from "@earendil-works/pi-ai";
3
3
  import { TextEncoder, TextDecoder } from "util";
4
4
 
5
5
  // --- Provider Configuration ---
6
6
 
7
+ type ProviderKind = "google" | "openai" | "anthropic" | "unsupported";
8
+
9
+ type GoogleRequestBuilder = (model: Model<any>, body: any) => { url: string; headers: Record<string, string>; body: any };
10
+
7
11
  type ProviderConfig = {
8
- searchTool: string;
9
- urlContextTool: string;
10
- buildRequest: (model: Model<any>, body: any, projectId?: string) => { url: string; headers: Record<string, string>; body: any };
12
+ kind: ProviderKind;
13
+ searchTool?: string;
14
+ urlContextTool?: string;
15
+ buildRequest?: GoogleRequestBuilder;
11
16
  };
12
17
 
13
- const PROVIDERS: Record<string, ProviderConfig> = {
18
+ const GOOGLE_PROVIDERS: Record<string, ProviderConfig> = {
14
19
  "google-generative-ai": {
20
+ kind: "google",
15
21
  searchTool: "google_search",
16
22
  urlContextTool: "url_context",
17
23
  buildRequest: (model, body) => ({
@@ -22,163 +28,897 @@ const PROVIDERS: Record<string, ProviderConfig> = {
22
28
  },
23
29
  body
24
30
  })
25
- },
26
- "google-gemini-cli": {
27
- searchTool: "googleSearch",
28
- urlContextTool: "urlContext",
29
- buildRequest: (model, body, projectId) => ({
30
- url: `${model.baseUrl}/v1internal:streamGenerateContent?alt=sse`,
31
- headers: {
32
- "Content-Type": "application/json",
33
- "Accept": "text/event-stream",
34
- "User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
35
- "X-Goog-Api-Client": "gl-node/22.17.0",
36
- "Client-Metadata": JSON.stringify({ ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }),
37
- },
38
- body: { project: projectId, model: model.id, request: body }
39
- })
40
- },
41
- "google-antigravity": {
42
- searchTool: "googleSearch",
43
- urlContextTool: "urlContext",
44
- buildRequest: (model, body, projectId) => ({
45
- url: `${model.baseUrl}/v1internal:streamGenerateContent?alt=sse`,
46
- headers: {
47
- "Content-Type": "application/json",
48
- "Accept": "text/event-stream",
49
- "User-Agent": "antigravity/1.15.8 darwin/arm64",
50
- "X-Goog-Api-Client": "gl-node/22.17.0",
51
- "Client-Metadata": JSON.stringify({ ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }),
52
- },
53
- body: {
54
- project: projectId,
55
- model: model.id,
56
- request: body,
57
- requestType: "agent",
58
- userAgent: "antigravity",
59
- requestId: `agent-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
60
- }
61
- })
62
31
  }
63
32
  };
64
33
 
34
+ export function getProviderKind(model: Model<any>): ProviderKind {
35
+ if (GOOGLE_PROVIDERS[model.provider] || GOOGLE_PROVIDERS[model.api]) return "google";
36
+ if (model.api === "openai-responses") return "openai";
37
+ if (model.api === "anthropic-messages") return "anthropic";
38
+ return "unsupported";
39
+ }
40
+
65
41
  export function getConfig(model: Model<any>): ProviderConfig {
66
- return PROVIDERS[model.provider] || PROVIDERS[model.api] || PROVIDERS["google-generative-ai"];
42
+ const googleConfig = GOOGLE_PROVIDERS[model.provider] || GOOGLE_PROVIDERS[model.api];
43
+ if (googleConfig) return googleConfig;
44
+ const kind = getProviderKind(model);
45
+ return { kind };
46
+ }
47
+
48
+ // --- Auth Compatibility Layer ---
49
+
50
+ type ResolvedAuth =
51
+ | { ok: true; apiKey?: string; headers?: Record<string, string>; }
52
+ | { ok: false; error: string; };
53
+
54
+ /**
55
+ * Get API key and headers for a model.
56
+ * Compatible with both new pi versions (getApiKeyAndHeaders) and old versions (getApiKey).
57
+ */
58
+ async function getAuth(ctx: ExtensionContext, model: Model<any>): Promise<ResolvedAuth> {
59
+ const registry = ctx.modelRegistry as any;
60
+
61
+ // Try new API first (pi >= 0.63.0)
62
+ if (typeof registry.getApiKeyAndHeaders === 'function') {
63
+ return await registry.getApiKeyAndHeaders(model);
64
+ }
65
+
66
+ // Fallback to old API (pi < 0.63.0)
67
+ if (typeof registry.getApiKey === 'function') {
68
+ const apiKey = await registry.getApiKey(model);
69
+ if (apiKey === undefined || apiKey === null) {
70
+ return { ok: false, error: "No API key configured for model" };
71
+ }
72
+ return { ok: true, apiKey };
73
+ }
74
+
75
+ return { ok: false, error: "Model registry does not support API key retrieval" };
67
76
  }
68
77
 
69
78
  // --- Streaming API Call ---
70
79
 
80
+ export interface Source {
81
+ title: string;
82
+ url: string;
83
+ }
84
+
85
+ export interface SearchResultDetail {
86
+ title?: string;
87
+ url?: string;
88
+ query?: string;
89
+ source?: string;
90
+ pageAge?: string | null;
91
+ citedText?: string;
92
+ status?: string;
93
+ type?: string;
94
+ raw?: any;
95
+ }
96
+
97
+ export interface NativeSearchCallDetail {
98
+ id?: string;
99
+ provider: ProviderKind;
100
+ status?: string;
101
+ actionType?: string;
102
+ queries?: string[];
103
+ urls?: string[];
104
+ raw?: any;
105
+ }
106
+
71
107
  export interface StreamResult {
72
108
  text: string;
109
+ sources?: Source[];
110
+ providerKind?: ProviderKind;
111
+ nativeSearchUsed?: boolean;
112
+ nativeSearchEvents?: string[];
113
+ nativeSearchCalls?: NativeSearchCallDetail[];
114
+ searchQueries?: string[];
115
+ searchResults?: SearchResultDetail[];
116
+ citations?: SearchResultDetail[];
73
117
  groundingMetadata?: any;
74
118
  urlContextMetadata?: any;
75
119
  }
76
120
 
77
- export async function callApiStream(
121
+ type SseEvent = {
122
+ event: string;
123
+ data: any;
124
+ };
125
+
126
+ async function readSseEvents(
127
+ response: Response,
128
+ signal: AbortSignal | undefined,
129
+ onEvent: (event: SseEvent) => void | Promise<void>
130
+ ): Promise<void> {
131
+ if (!response.body) {
132
+ throw new Error("No response body");
133
+ }
134
+
135
+ const reader = response.body.getReader();
136
+ const decoder = new TextDecoder();
137
+ let buffer = "";
138
+ let currentEventData = "";
139
+ let currentEventName = "";
140
+
141
+ const flushEvent = async () => {
142
+ if (!currentEventData) return;
143
+ const raw = currentEventData.trim();
144
+ currentEventData = "";
145
+ const eventName = currentEventName;
146
+ currentEventName = "";
147
+ if (!raw || raw === "[DONE]") return;
148
+
149
+ let data: any;
150
+ try {
151
+ data = JSON.parse(raw);
152
+ } catch {
153
+ return;
154
+ }
155
+ await onEvent({ event: eventName, data });
156
+ };
157
+
158
+ while (true) {
159
+ if (signal?.aborted) {
160
+ throw new Error("Request was aborted");
161
+ }
162
+
163
+ const { done, value } = await reader.read();
164
+ if (done) break;
165
+
166
+ buffer += decoder.decode(value, { stream: true });
167
+ const lines = buffer.split("\n");
168
+ buffer = lines.pop() || "";
169
+
170
+ for (const line of lines) {
171
+ if (line === "" || line === "\r") {
172
+ await flushEvent();
173
+ continue;
174
+ }
175
+
176
+ if (line.startsWith("data:")) {
177
+ const data = line.slice(5).trim();
178
+ currentEventData = currentEventData ? currentEventData + "\n" + data : data;
179
+ } else if (line.startsWith("event:")) {
180
+ currentEventName = line.slice(6).trim();
181
+ }
182
+ }
183
+ }
184
+
185
+ if (buffer.trim()) {
186
+ const line = buffer.trim();
187
+ if (line.startsWith("data:")) {
188
+ const data = line.slice(5).trim();
189
+ currentEventData = currentEventData ? currentEventData + "\n" + data : data;
190
+ }
191
+ }
192
+ await flushEvent();
193
+ }
194
+
195
+ function extractPromptFromGeminiBody(body: any): string {
196
+ const parts: string[] = [];
197
+ for (const content of body?.contents || []) {
198
+ for (const part of content?.parts || []) {
199
+ if (typeof part?.text === "string") {
200
+ parts.push(part.text);
201
+ } else if (part?.file_data?.file_uri) {
202
+ parts.push(String(part.file_data.file_uri));
203
+ }
204
+ }
205
+ }
206
+ return parts.join("\n\n").trim();
207
+ }
208
+
209
+ function trimTrailingSlash(value: string): string {
210
+ return value.replace(/\/+$/, "");
211
+ }
212
+
213
+ function resolveAnthropicMessagesUrl(baseUrl: string): string {
214
+ const base = trimTrailingSlash(baseUrl);
215
+ return base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
216
+ }
217
+
218
+ function pushUniqueSource(sources: Source[], source: Source): number {
219
+ const url = source.url || "";
220
+ const title = source.title || "Unknown";
221
+ const existingIndex = sources.findIndex((s) => s.url === url && s.title === title);
222
+ if (existingIndex >= 0) return existingIndex;
223
+ sources.push({ title, url });
224
+ return sources.length - 1;
225
+ }
226
+
227
+ function pushUniqueString(values: string[], value: string | undefined | null) {
228
+ if (!value || values.includes(value)) return;
229
+ values.push(value);
230
+ }
231
+
232
+ function pushUniqueSearchResult(results: SearchResultDetail[], result: SearchResultDetail) {
233
+ const key = `${result.url || ""}\t${result.title || ""}\t${result.query || ""}\t${result.citedText || ""}\t${result.type || ""}`;
234
+ const exists = results.some((item) => `${item.url || ""}\t${item.title || ""}\t${item.query || ""}\t${item.citedText || ""}\t${item.type || ""}` === key);
235
+ if (!exists) results.push(result);
236
+ }
237
+
238
+ function pushNativeSearchEvent(events: string[], event: string) {
239
+ if (!events.includes(event)) events.push(event);
240
+ }
241
+
242
+ function normalizeSearchUrl(url: string): string {
243
+ try {
244
+ const parsed = new URL(url);
245
+ parsed.hash = "";
246
+ if (!/(^|\.)youtube\.com$/i.test(parsed.hostname) && !/(^|\.)youtu\.be$/i.test(parsed.hostname)) {
247
+ const removableParams = ["ref", "referral_type", "openLinerExtension", "_clear", "lang", "api-mode"];
248
+ for (const name of removableParams) parsed.searchParams.delete(name);
249
+ for (const name of [...parsed.searchParams.keys()]) {
250
+ if (name.toLowerCase().startsWith("utm_")) parsed.searchParams.delete(name);
251
+ }
252
+ }
253
+ const query = parsed.searchParams.toString();
254
+ parsed.search = query ? `?${query}` : "";
255
+ return parsed.toString();
256
+ } catch {
257
+ return url;
258
+ }
259
+ }
260
+
261
+ function titleFromUrl(url: string): string {
262
+ try {
263
+ const parsed = new URL(url);
264
+ const lastSegment = parsed.pathname.split("/").filter(Boolean).pop();
265
+ return lastSegment || parsed.hostname || url;
266
+ } catch {
267
+ return url;
268
+ }
269
+ }
270
+
271
+ function extractOpenAIUrlCitation(annotation: any): { endIndex?: number; title: string; url: string } | undefined {
272
+ const nested = annotation?.url_citation || annotation?.urlCitation;
273
+ const url = annotation?.url || nested?.url;
274
+ if (!url || typeof url !== "string") return undefined;
275
+
276
+ const title = annotation?.title || nested?.title || titleFromUrl(url);
277
+ const endIndexValue = annotation?.end_index ?? annotation?.endIndex ?? nested?.end_index ?? nested?.endIndex;
278
+ return {
279
+ endIndex: typeof endIndexValue === "number" ? endIndexValue : undefined,
280
+ title,
281
+ url,
282
+ };
283
+ }
284
+
285
+ function mergeSearchResultMetadata(results: SearchResultDetail[], extras: SearchResultDetail[]) {
286
+ for (const extra of extras) {
287
+ if (!extra.url) continue;
288
+ const existing = results.find((item) => item.url === extra.url);
289
+ if (!existing) continue;
290
+ if (!existing.title && extra.title) existing.title = extra.title;
291
+ if (!existing.query && extra.query) existing.query = extra.query;
292
+ if (!existing.citedText && extra.citedText) existing.citedText = extra.citedText;
293
+ if (!existing.status && extra.status) existing.status = extra.status;
294
+ if (!existing.type && extra.type) existing.type = extra.type;
295
+ if (!existing.source && extra.source) existing.source = extra.source;
296
+ }
297
+ }
298
+
299
+ function isLikelyJunkSearchUrl(url: string | undefined): boolean {
300
+ if (!url) return true;
301
+ try {
302
+ const parsed = new URL(url);
303
+ const decodedPath = decodeURIComponent(parsed.pathname).toLowerCase();
304
+ const suspiciousSuffixes = [
305
+ ".gz", ".zip", ".tgz", ".tar", ".woff", ".woff2", ".ttf", ".otf", ".eot",
306
+ ".webm", ".mp4", ".mp3", ".wav", ".eps", ".sql", ".csv", ".xls", ".xlsx", ".ppt", ".pptx"
307
+ ];
308
+ if (suspiciousSuffixes.some((suffix) => decodedPath.endsWith(suffix))) return true;
309
+ if (decodedPath === "/%" || decodedPath.endsWith("/%")) return true;
310
+ return false;
311
+ } catch {
312
+ return false;
313
+ }
314
+ }
315
+
316
+ function sanitizeSearchResults(results: SearchResultDetail[]): SearchResultDetail[] {
317
+ const sanitized: SearchResultDetail[] = [];
318
+ for (const result of results) {
319
+ const normalizedUrl = result.url ? normalizeSearchUrl(result.url) : result.url;
320
+ const normalized = { ...result, url: normalizedUrl };
321
+ if (normalized.url && isLikelyJunkSearchUrl(normalized.url)) continue;
322
+ pushUniqueSearchResult(sanitized, normalized);
323
+ }
324
+ return sanitized;
325
+ }
326
+
327
+ function deriveSources(searchResults: SearchResultDetail[], citations: SearchResultDetail[] = []): Source[] {
328
+ const sources: Source[] = [];
329
+ for (const item of [...citations, ...searchResults]) {
330
+ if (!item.url) continue;
331
+ const url = normalizeSearchUrl(item.url);
332
+ if (isLikelyJunkSearchUrl(url)) continue;
333
+ pushUniqueSource(sources, {
334
+ title: item.title || titleFromUrl(url),
335
+ url,
336
+ });
337
+ }
338
+ return sources;
339
+ }
340
+
341
+ function isGoogleGroundingRedirect(url: string | undefined): boolean {
342
+ return !!url && /^https:\/\/vertexaisearch\.cloud\.google\.com\/grounding-api-redirect\//.test(url);
343
+ }
344
+
345
+ async function resolveGoogleGroundingRedirectUrls(searchResults: SearchResultDetail[], citations: SearchResultDetail[], signal?: AbortSignal) {
346
+ const redirectUrls = [...new Set([...searchResults, ...citations].map((item) => item.url).filter((url): url is string => isGoogleGroundingRedirect(url)))];
347
+ if (redirectUrls.length === 0) return;
348
+
349
+ const resolved = new Map<string, string>();
350
+ await Promise.all(redirectUrls.slice(0, 20).map(async (url) => {
351
+ try {
352
+ const response = await fetch(url, { method: "HEAD", redirect: "manual", signal });
353
+ const location = response.headers.get("location");
354
+ if (location) resolved.set(url, location);
355
+ } catch {
356
+ // Ignore redirect resolution failures and keep the original URL.
357
+ }
358
+ }));
359
+
360
+ if (resolved.size === 0) return;
361
+ for (const item of [...searchResults, ...citations]) {
362
+ if (!item.url) continue;
363
+ const canonicalUrl = resolved.get(item.url);
364
+ if (!canonicalUrl) continue;
365
+ item.url = canonicalUrl;
366
+ if (!item.title || item.title === "Unknown") item.title = titleFromUrl(canonicalUrl);
367
+ }
368
+ }
369
+
370
+ function applyIndexCitations(text: string, citations: Array<{ endIndex?: number; title: string; url: string }>): { text: string; sources: Source[] } {
371
+ const sources: Source[] = [];
372
+ const insertions = citations
373
+ .filter((c) => c.url && c.endIndex !== undefined)
374
+ .map((c) => ({
375
+ index: Math.max(0, Math.min(c.endIndex!, text.length)),
376
+ marker: `[${pushUniqueSource(sources, { title: c.title, url: c.url }) + 1}]`
377
+ }))
378
+ .sort((a, b) => b.index - a.index);
379
+
380
+ let result = text;
381
+ const seen = new Set<string>();
382
+ for (const insertion of insertions) {
383
+ const key = `${insertion.index}:${insertion.marker}`;
384
+ if (seen.has(key)) continue;
385
+ seen.add(key);
386
+ result = result.slice(0, insertion.index) + insertion.marker + result.slice(insertion.index);
387
+ }
388
+
389
+ // Preserve sources that had no end index.
390
+ for (const citation of citations) {
391
+ if (citation.url) pushUniqueSource(sources, { title: citation.title, url: citation.url });
392
+ }
393
+
394
+ return { text: result, sources };
395
+ }
396
+
397
+ function applyTextCitations(text: string, citations: Array<{ citedText?: string; title: string; url: string }>): { text: string; sources: Source[] } {
398
+ const sources: Source[] = [];
399
+ const insertions: Array<{ index: number; marker: string }> = [];
400
+ const usedRanges = new Set<string>();
401
+
402
+ for (const citation of citations) {
403
+ if (!citation.url) continue;
404
+ const marker = `[${pushUniqueSource(sources, { title: citation.title, url: citation.url }) + 1}]`;
405
+ const citedText = citation.citedText?.trim();
406
+ if (!citedText) continue;
407
+ const index = text.indexOf(citedText);
408
+ if (index < 0) continue;
409
+ const end = index + citedText.length;
410
+ const key = `${end}:${marker}`;
411
+ if (usedRanges.has(key)) continue;
412
+ usedRanges.add(key);
413
+ insertions.push({ index: end, marker });
414
+ }
415
+
416
+ let result = text;
417
+ for (const insertion of insertions.sort((a, b) => b.index - a.index)) {
418
+ result = result.slice(0, insertion.index) + insertion.marker + result.slice(insertion.index);
419
+ }
420
+
421
+ return { text: result, sources };
422
+ }
423
+
424
+ function extractGoogleSearchDetails(groundingMetadata: any): { searchQueries: string[]; searchResults: SearchResultDetail[]; citations: SearchResultDetail[] } {
425
+ const searchQueries = groundingMetadata?.webSearchQueries || [];
426
+ const chunks = groundingMetadata?.groundingChunks || [];
427
+ const supports = groundingMetadata?.groundingSupports || [];
428
+ const searchResults: SearchResultDetail[] = [];
429
+ const citations: SearchResultDetail[] = [];
430
+
431
+ chunks.forEach((chunk: any, index: number) => {
432
+ if (!chunk?.web) return;
433
+ pushUniqueSearchResult(searchResults, {
434
+ title: chunk.web.title || "Unknown",
435
+ url: chunk.web.uri || "",
436
+ source: "google.groundingChunks",
437
+ type: "web",
438
+ raw: { index, ...chunk.web },
439
+ });
440
+ });
441
+
442
+ supports.forEach((support: any) => {
443
+ for (const index of support?.groundingChunkIndices || []) {
444
+ const web = chunks[index]?.web;
445
+ if (!web) continue;
446
+ pushUniqueSearchResult(citations, {
447
+ title: web.title || "Unknown",
448
+ url: web.uri || "",
449
+ citedText: support?.segment?.text,
450
+ source: "google.groundingSupports",
451
+ type: "citation",
452
+ raw: support,
453
+ });
454
+ }
455
+ });
456
+
457
+ return { searchQueries, searchResults, citations };
458
+ }
459
+
460
+ async function callGoogleStream(
78
461
  ctx: ExtensionContext,
79
462
  model: Model<any>,
80
463
  body: any,
81
- onUpdate?: AgentToolUpdateCallback
464
+ onUpdate?: AgentToolUpdateCallback,
465
+ signal?: AbortSignal
82
466
  ): Promise<StreamResult> {
83
467
  const config = getConfig(model);
84
- const apiKey = await ctx.modelRegistry.getApiKey(model) || "";
468
+ if (!config.buildRequest) {
469
+ throw new Error(`Unsupported Google provider: ${model.provider}`);
470
+ }
85
471
 
86
- let projectId: string | undefined;
87
- if (model.api !== "google-generative-ai") {
88
- const parsed = JSON.parse(apiKey);
89
- projectId = parsed.projectId;
472
+ const auth = await getAuth(ctx, model);
473
+ if (!auth.ok) {
474
+ throw new Error(auth.error || "Failed to get API key and headers");
90
475
  }
91
476
 
92
- const req = config.buildRequest(model, body, projectId);
477
+ const req = config.buildRequest(model, body);
93
478
 
94
479
  // Handle auth
95
- if (model.api === "google-generative-ai") {
96
- req.headers["x-goog-api-key"] = apiKey;
97
- } else {
98
- const parsed = JSON.parse(apiKey);
99
- req.headers["Authorization"] = `Bearer ${parsed.token}`;
480
+ if (auth.headers) {
481
+ Object.assign(req.headers, auth.headers);
482
+ }
483
+ if (auth.apiKey) {
484
+ req.headers["x-goog-api-key"] = auth.apiKey;
100
485
  }
101
486
 
102
487
  const response = await fetch(req.url, {
103
488
  method: "POST",
104
489
  headers: req.headers,
105
- body: JSON.stringify(req.body)
490
+ body: JSON.stringify(req.body),
491
+ signal
106
492
  });
107
493
 
108
494
  if (!response.ok) {
109
495
  throw new Error(`API error (${response.status}): ${await response.text()}`);
110
496
  }
111
497
 
112
- if (!response.body) {
113
- throw new Error("No response body");
114
- }
115
-
116
- // Parse SSE stream
117
- const reader = response.body.getReader();
118
- const decoder = new TextDecoder();
119
- let buffer = "";
120
498
  let accumulatedText = "";
121
499
  let groundingMetadata: any;
122
500
  let urlContextMetadata: any;
123
501
 
124
- while (true) {
125
- const { done, value } = await reader.read();
126
- if (done) break;
127
-
128
- buffer += decoder.decode(value, { stream: true });
129
- const lines = buffer.split("\n");
130
- buffer = lines.pop() || "";
502
+ await readSseEvents(response, signal, ({ data: chunk }) => {
503
+ if (chunk.error) {
504
+ const errorMsg = chunk.error.message || JSON.stringify(chunk.error);
505
+ throw new Error(`API error (${chunk.error.code || chunk.error.status || 'unknown'}): ${errorMsg}`);
506
+ }
131
507
 
132
- for (const line of lines) {
133
- if (!line.startsWith("data:")) continue;
134
- const jsonStr = line.slice(5).trim();
135
- if (!jsonStr) continue;
136
-
137
- let chunk: any;
138
- try {
139
- chunk = JSON.parse(jsonStr);
140
- } catch {
141
- continue;
508
+ // Unwrap response for internal APIs
509
+ const data = chunk.response || chunk;
510
+ const candidate = data.candidates?.[0];
511
+
512
+ if (candidate?.content?.parts) {
513
+ for (const part of candidate.content.parts) {
514
+ if (part.text) {
515
+ accumulatedText += part.text;
516
+ onUpdate?.({
517
+ content: [{ type: "text", text: accumulatedText }],
518
+ details: { streaming: true }
519
+ });
520
+ }
142
521
  }
522
+ }
143
523
 
144
- // Unwrap response for internal APIs
145
- const data = chunk.response || chunk;
146
- const candidate = data.candidates?.[0];
147
-
148
- if (candidate?.content?.parts) {
149
- for (const part of candidate.content.parts) {
150
- if (part.text) {
151
- accumulatedText += part.text;
152
- // Stream update
153
- onUpdate?.({
154
- content: [{ type: "text", text: accumulatedText }],
155
- details: { streaming: true }
156
- });
157
- }
158
- }
524
+ // Capture metadata from final chunk
525
+ if (candidate?.groundingMetadata) {
526
+ groundingMetadata = candidate.groundingMetadata;
527
+ }
528
+ // Handle both camelCase and snake_case
529
+ if (candidate?.urlContextMetadata || candidate?.url_context_metadata) {
530
+ urlContextMetadata = candidate.urlContextMetadata || candidate.url_context_metadata;
531
+ }
532
+ });
533
+
534
+ const searchDetails = extractGoogleSearchDetails(groundingMetadata);
535
+ await resolveGoogleGroundingRedirectUrls(searchDetails.searchResults, searchDetails.citations, signal);
536
+ const searchResults = sanitizeSearchResults(searchDetails.searchResults);
537
+ const citations = sanitizeSearchResults(searchDetails.citations);
538
+ return {
539
+ text: accumulatedText || "No answer available.",
540
+ sources: deriveSources(searchResults, citations),
541
+ providerKind: "google",
542
+ nativeSearchUsed: searchDetails.searchQueries.length > 0 || searchResults.length > 0,
543
+ nativeSearchEvents: searchDetails.searchQueries.length > 0 ? ["google.groundingMetadata.webSearchQueries"] : [],
544
+ searchQueries: searchDetails.searchQueries,
545
+ searchResults,
546
+ citations,
547
+ groundingMetadata,
548
+ urlContextMetadata
549
+ };
550
+ }
551
+
552
+ async function callOpenAIStream(
553
+ ctx: ExtensionContext,
554
+ model: Model<any>,
555
+ prompt: string,
556
+ onUpdate?: AgentToolUpdateCallback,
557
+ signal?: AbortSignal
558
+ ): Promise<StreamResult> {
559
+ const auth = await getAuth(ctx, model);
560
+ if (!auth.ok) {
561
+ throw new Error(auth.error || "Failed to get API key and headers");
562
+ }
563
+
564
+ const headers: Record<string, string> = {
565
+ "Content-Type": "application/json",
566
+ "Accept": "text/event-stream",
567
+ ...(model.headers || {}),
568
+ ...(auth.headers || {}),
569
+ };
570
+ if (auth.apiKey && !headers.Authorization && !headers.authorization) {
571
+ headers.Authorization = `Bearer ${auth.apiKey}`;
572
+ }
573
+
574
+ const requestBody: any = {
575
+ model: model.id,
576
+ input: prompt,
577
+ tools: [{ type: "web_search" }],
578
+ include: ["web_search_call.action.sources", "web_search_call.results"],
579
+ stream: true,
580
+ store: false,
581
+ };
582
+ if (model.reasoning) {
583
+ requestBody.reasoning = { effort: "none" };
584
+ }
585
+
586
+ const response = await fetch(`${trimTrailingSlash(model.baseUrl)}/responses`, {
587
+ method: "POST",
588
+ headers,
589
+ body: JSON.stringify(requestBody),
590
+ signal
591
+ });
592
+
593
+ if (!response.ok) {
594
+ throw new Error(`OpenAI API error (${response.status}): ${await response.text()}`);
595
+ }
596
+
597
+ let accumulatedText = "";
598
+ const citations: Array<{ endIndex?: number; title: string; url: string }> = [];
599
+ const nativeSearchEvents: string[] = [];
600
+ const nativeSearchCalls: NativeSearchCallDetail[] = [];
601
+ const searchQueries: string[] = [];
602
+ const searchResults: SearchResultDetail[] = [];
603
+
604
+ const collectAnnotation = (annotation: any) => {
605
+ if (annotation?.type !== "url_citation") return;
606
+ const citation = extractOpenAIUrlCitation(annotation);
607
+ if (!citation) return;
608
+ citations.push(citation);
609
+ };
610
+
611
+ const collectWebSearchCall = (item: any) => {
612
+ if (item?.type !== "web_search_call") return;
613
+ const action = item.action || {};
614
+ const call: NativeSearchCallDetail = {
615
+ id: item.id,
616
+ provider: "openai",
617
+ status: item.status,
618
+ actionType: action.type,
619
+ raw: item,
620
+ };
621
+ if (Array.isArray(action.queries)) {
622
+ const queries = action.queries.filter((query: any): query is string => typeof query === "string");
623
+ call.queries = queries;
624
+ for (const query of queries) pushUniqueString(searchQueries, query);
625
+ } else if (typeof action.query === "string") {
626
+ call.queries = [action.query];
627
+ pushUniqueString(searchQueries, action.query);
628
+ }
629
+ if (Array.isArray(action.sources)) {
630
+ call.urls = action.sources.map((source: any) => source?.url).filter((url: any): url is string => typeof url === "string");
631
+ for (const source of action.sources) {
632
+ if (!source?.url) continue;
633
+ pushUniqueSearchResult(searchResults, {
634
+ title: source.title || source.display_name || source.name || titleFromUrl(source.url),
635
+ url: source.url,
636
+ source: "openai.web_search_call.action.sources",
637
+ type: source.type || "url",
638
+ raw: source,
639
+ });
159
640
  }
641
+ }
642
+ if (action.url) {
643
+ call.urls = [...(call.urls || []), action.url];
644
+ pushUniqueSearchResult(searchResults, {
645
+ title: titleFromUrl(action.url),
646
+ url: action.url,
647
+ source: `openai.web_search_call.action.${action.type}`,
648
+ type: action.type,
649
+ raw: action,
650
+ });
651
+ }
652
+ nativeSearchCalls.push(call);
653
+ };
160
654
 
161
- // Capture metadata from final chunk
162
- if (candidate?.groundingMetadata) {
163
- groundingMetadata = candidate.groundingMetadata;
655
+ const collectFromResponse = (response: any) => {
656
+ for (const item of response?.output || []) {
657
+ collectWebSearchCall(item);
658
+ if (item?.type !== "message") continue;
659
+ for (const content of item.content || []) {
660
+ if (content?.type !== "output_text") continue;
661
+ for (const annotation of content.annotations || []) collectAnnotation(annotation);
164
662
  }
165
- // Handle both camelCase and snake_case
166
- if (candidate?.urlContextMetadata || candidate?.url_context_metadata) {
167
- urlContextMetadata = candidate.urlContextMetadata || candidate.url_context_metadata;
663
+ }
664
+ };
665
+
666
+ await readSseEvents(response, signal, ({ data: event }) => {
667
+ if (event.type === "response.output_text.delta") {
668
+ accumulatedText += event.delta || "";
669
+ onUpdate?.({
670
+ content: [{ type: "text", text: accumulatedText }],
671
+ details: { streaming: true }
672
+ });
673
+ } else if (event.type === "response.output_text.annotation.added") {
674
+ collectAnnotation(event.annotation);
675
+ } else if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
676
+ collectWebSearchCall(event.item);
677
+ } else if (event.type === "response.completed") {
678
+ collectFromResponse(event.response);
679
+ } else if (event.type === "response.web_search_call.in_progress" || event.type === "response.web_search_call.searching" || event.type === "response.web_search_call.completed") {
680
+ pushNativeSearchEvent(nativeSearchEvents, event.type);
681
+ const call = nativeSearchCalls.find((item) => item.id === event.item_id);
682
+ if (call) call.status = event.type.replace("response.web_search_call.", "");
683
+ else nativeSearchCalls.push({ id: event.item_id, provider: "openai", status: event.type.replace("response.web_search_call.", ""), raw: event });
684
+ if (event.type === "response.web_search_call.searching") {
685
+ onUpdate?.({
686
+ content: [{ type: "text", text: accumulatedText || "Searching the web with OpenAI..." }],
687
+ details: { streaming: true, searching: true }
688
+ });
168
689
  }
690
+ } else if (event.type === "response.failed") {
691
+ const error = event.response?.error;
692
+ throw new Error(error?.message || JSON.stringify(error || event.response || event));
693
+ } else if (event.type === "error") {
694
+ throw new Error(event.message || JSON.stringify(event));
695
+ }
696
+ });
697
+
698
+ const cited = applyIndexCitations(accumulatedText || "No answer available.", citations);
699
+ const citationDetails = citations.map((citation) => ({
700
+ title: citation.title,
701
+ url: citation.url,
702
+ source: "openai.url_citation",
703
+ type: "citation",
704
+ raw: citation,
705
+ }));
706
+ for (const citation of citationDetails) pushUniqueSearchResult(searchResults, citation);
707
+ mergeSearchResultMetadata(searchResults, citationDetails);
708
+ const sanitizedSearchResults = sanitizeSearchResults(searchResults);
709
+ const sanitizedCitations = sanitizeSearchResults(citationDetails);
710
+ mergeSearchResultMetadata(sanitizedSearchResults, sanitizedCitations);
711
+ const derivedSources = deriveSources(sanitizedSearchResults, sanitizedCitations);
712
+
713
+ return {
714
+ text: cited.text,
715
+ sources: cited.sources.length ? cited.sources.map((source) => ({ ...source, url: normalizeSearchUrl(source.url) })).filter((source) => !isLikelyJunkSearchUrl(source.url)) : derivedSources,
716
+ providerKind: "openai",
717
+ nativeSearchUsed: nativeSearchEvents.length > 0 || nativeSearchCalls.length > 0 || sanitizedSearchResults.length > 0,
718
+ nativeSearchEvents,
719
+ nativeSearchCalls,
720
+ searchQueries,
721
+ searchResults: sanitizedSearchResults,
722
+ citations: sanitizedCitations,
723
+ };
724
+ }
725
+
726
+ async function callAnthropicStream(
727
+ ctx: ExtensionContext,
728
+ model: Model<any>,
729
+ prompt: string,
730
+ onUpdate?: AgentToolUpdateCallback,
731
+ signal?: AbortSignal
732
+ ): Promise<StreamResult> {
733
+ const auth = await getAuth(ctx, model);
734
+ if (!auth.ok) {
735
+ throw new Error(auth.error || "Failed to get API key and headers");
736
+ }
737
+
738
+ const isOAuth = !!auth.apiKey && auth.apiKey.includes("sk-ant-oat");
739
+ const headers: Record<string, string> = {
740
+ "Content-Type": "application/json",
741
+ "Accept": "text/event-stream",
742
+ "anthropic-version": "2023-06-01",
743
+ ...(model.headers || {}),
744
+ ...(auth.headers || {}),
745
+ };
746
+
747
+ if (auth.apiKey) {
748
+ if (isOAuth) {
749
+ if (!headers.Authorization && !headers.authorization) headers.Authorization = `Bearer ${auth.apiKey}`;
750
+ headers["anthropic-beta"] = headers["anthropic-beta"]
751
+ ? `${headers["anthropic-beta"]},claude-code-20250219,oauth-2025-04-20`
752
+ : "claude-code-20250219,oauth-2025-04-20";
753
+ headers["user-agent"] = headers["user-agent"] || "claude-cli/2.1.75";
754
+ headers["x-app"] = headers["x-app"] || "cli";
755
+ } else if (!headers["x-api-key"] && !headers["X-Api-Key"]) {
756
+ headers["x-api-key"] = auth.apiKey;
169
757
  }
170
758
  }
171
759
 
760
+ const maxTokens = Math.min(Math.max(1024, Math.floor(model.maxTokens / 3) || 4096), 8192);
761
+ const requestBody = {
762
+ model: model.id,
763
+ max_tokens: maxTokens,
764
+ messages: [{ role: "user", content: prompt }],
765
+ tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 10 }],
766
+ stream: true,
767
+ };
768
+
769
+ const response = await fetch(resolveAnthropicMessagesUrl(model.baseUrl), {
770
+ method: "POST",
771
+ headers,
772
+ body: JSON.stringify(requestBody),
773
+ signal
774
+ });
775
+
776
+ if (!response.ok) {
777
+ throw new Error(`Anthropic API error (${response.status}): ${await response.text()}`);
778
+ }
779
+
780
+ let accumulatedText = "";
781
+ const citations: Array<{ citedText?: string; title: string; url: string }> = [];
782
+ const nativeSearchEvents: string[] = [];
783
+ const nativeSearchCalls: NativeSearchCallDetail[] = [];
784
+ const searchResults: SearchResultDetail[] = [];
785
+
786
+ const collectSource = (source: any, toolUseId?: string) => {
787
+ if (!source?.url) return;
788
+ const title = source.title || titleFromUrl(source.url);
789
+ citations.push({ title, url: source.url });
790
+ pushUniqueSearchResult(searchResults, {
791
+ title,
792
+ url: source.url,
793
+ pageAge: source.page_age ?? source.pageAge,
794
+ source: "anthropic.web_search_tool_result",
795
+ type: source.type || "web_search_result",
796
+ raw: { toolUseId, ...source },
797
+ });
798
+ };
799
+
800
+ await readSseEvents(response, signal, ({ data: event }) => {
801
+ if (event.type === "content_block_start") {
802
+ const block = event.content_block;
803
+ if (block?.type === "text" && block.text) {
804
+ accumulatedText += block.text;
805
+ onUpdate?.({ content: [{ type: "text", text: accumulatedText }], details: { streaming: true } });
806
+ } else if (block?.type === "server_tool_use" && block.name === "web_search") {
807
+ pushNativeSearchEvent(nativeSearchEvents, "anthropic.content_block_start.server_tool_use.web_search");
808
+ nativeSearchCalls.push({
809
+ id: block.id,
810
+ provider: "anthropic",
811
+ status: "in_progress",
812
+ actionType: block.name,
813
+ queries: typeof block.input?.query === "string" ? [block.input.query] : undefined,
814
+ raw: block,
815
+ });
816
+ onUpdate?.({
817
+ content: [{ type: "text", text: accumulatedText || "Searching the web with Anthropic..." }],
818
+ details: { streaming: true, searching: true }
819
+ });
820
+ } else if (block?.type === "web_search_tool_result") {
821
+ pushNativeSearchEvent(nativeSearchEvents, "anthropic.content_block_start.web_search_tool_result");
822
+ const call = nativeSearchCalls.find((item) => item.id === block.tool_use_id);
823
+ if (call) call.status = "completed";
824
+ else nativeSearchCalls.push({ id: block.tool_use_id, provider: "anthropic", status: "completed", actionType: "web_search", raw: block });
825
+ if (Array.isArray(block.content)) {
826
+ for (const result of block.content) collectSource(result, block.tool_use_id);
827
+ } else if (block.content?.type === "web_search_tool_result_error") {
828
+ pushUniqueSearchResult(searchResults, {
829
+ status: block.content.error_code,
830
+ source: "anthropic.web_search_tool_result_error",
831
+ type: block.content.type,
832
+ raw: block,
833
+ });
834
+ }
835
+ }
836
+ } else if (event.type === "content_block_delta") {
837
+ const delta = event.delta;
838
+ if (delta?.type === "text_delta") {
839
+ accumulatedText += delta.text || "";
840
+ onUpdate?.({
841
+ content: [{ type: "text", text: accumulatedText }],
842
+ details: { streaming: true }
843
+ });
844
+ } else if (delta?.type === "citations_delta") {
845
+ const citation = delta.citation;
846
+ if (citation?.type === "web_search_result_location" && citation.url) {
847
+ const detail = {
848
+ citedText: citation.cited_text,
849
+ title: citation.title || titleFromUrl(citation.url),
850
+ url: citation.url,
851
+ source: "anthropic.citations_delta",
852
+ type: citation.type,
853
+ raw: citation,
854
+ };
855
+ citations.push({ citedText: detail.citedText, title: detail.title, url: detail.url });
856
+ pushUniqueSearchResult(searchResults, detail);
857
+ }
858
+ }
859
+ } else if (event.type === "error") {
860
+ throw new Error(event.error?.message || JSON.stringify(event.error || event));
861
+ }
862
+ });
863
+
864
+ const cited = applyTextCitations(accumulatedText || "No answer available.", citations);
865
+ const citationDetails = citations.map((citation) => ({
866
+ title: citation.title || titleFromUrl(citation.url),
867
+ url: citation.url,
868
+ citedText: citation.citedText,
869
+ source: "anthropic.citation",
870
+ type: "citation",
871
+ raw: citation,
872
+ }));
873
+ mergeSearchResultMetadata(searchResults, citationDetails);
874
+ const sanitizedSearchResults = sanitizeSearchResults(searchResults);
875
+ const sanitizedCitations = sanitizeSearchResults(citationDetails);
876
+ mergeSearchResultMetadata(sanitizedSearchResults, sanitizedCitations);
877
+ const derivedSources = deriveSources(sanitizedSearchResults, sanitizedCitations);
878
+
172
879
  return {
173
- text: accumulatedText || "No answer available.",
174
- groundingMetadata,
175
- urlContextMetadata
880
+ text: cited.text,
881
+ sources: cited.sources.length ? cited.sources.map((source) => ({ ...source, url: normalizeSearchUrl(source.url) })).filter((source) => !isLikelyJunkSearchUrl(source.url)) : derivedSources,
882
+ providerKind: "anthropic",
883
+ nativeSearchUsed: nativeSearchEvents.length > 0 || nativeSearchCalls.length > 0 || sanitizedSearchResults.length > 0,
884
+ nativeSearchEvents,
885
+ nativeSearchCalls,
886
+ searchQueries: nativeSearchCalls.flatMap((call) => call.queries || []),
887
+ searchResults: sanitizedSearchResults,
888
+ citations: sanitizedCitations,
176
889
  };
177
890
  }
178
891
 
892
+ export async function callApiStream(
893
+ ctx: ExtensionContext,
894
+ model: Model<any>,
895
+ body: any,
896
+ onUpdate?: AgentToolUpdateCallback,
897
+ signal?: AbortSignal
898
+ ): Promise<StreamResult> {
899
+ const kind = getProviderKind(model);
900
+ if (kind === "google") {
901
+ return callGoogleStream(ctx, model, body, onUpdate, signal);
902
+ }
903
+
904
+ const prompt = extractPromptFromGeminiBody(body);
905
+ if (!prompt) {
906
+ throw new Error("No prompt text found in request body");
907
+ }
908
+
909
+ if (kind === "openai") {
910
+ return callOpenAIStream(ctx, model, prompt, onUpdate, signal);
911
+ }
912
+ if (kind === "anthropic") {
913
+ return callAnthropicStream(ctx, model, prompt, onUpdate, signal);
914
+ }
915
+
916
+ throw new Error(`Unsupported provider for web search: ${model.provider} (${model.api})`);
917
+ }
918
+
179
919
  // --- Citation Processing (byte-safe) ---
180
920
 
181
- export function applyCitations(text: string, groundingMetadata: any): { text: string; sources: { title: string; url: string }[] } {
921
+ export function applyCitations(text: string, groundingMetadata: any): { text: string; sources: Source[] } {
182
922
  const chunks = groundingMetadata?.groundingChunks || [];
183
923
  const supports = groundingMetadata?.groundingSupports || [];
184
924