pi-web-search 1.1.0 → 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,48 +28,21 @@ 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 };
67
46
  }
68
47
 
69
48
  // --- Auth Compatibility Layer ---
@@ -98,13 +77,387 @@ async function getAuth(ctx: ExtensionContext, model: Model<any>): Promise<Resolv
98
77
 
99
78
  // --- Streaming API Call ---
100
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
+
101
107
  export interface StreamResult {
102
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[];
103
117
  groundingMetadata?: any;
104
118
  urlContextMetadata?: any;
105
119
  }
106
120
 
107
- 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(
108
461
  ctx: ExtensionContext,
109
462
  model: Model<any>,
110
463
  body: any,
@@ -112,36 +465,23 @@ export async function callApiStream(
112
465
  signal?: AbortSignal
113
466
  ): Promise<StreamResult> {
114
467
  const config = getConfig(model);
468
+ if (!config.buildRequest) {
469
+ throw new Error(`Unsupported Google provider: ${model.provider}`);
470
+ }
471
+
115
472
  const auth = await getAuth(ctx, model);
116
473
  if (!auth.ok) {
117
474
  throw new Error(auth.error || "Failed to get API key and headers");
118
475
  }
119
476
 
120
- // Extract projectId from apiKey for internal Google APIs (gemini-cli, antigravity)
121
- // These providers return apiKey as JSON: {projectId, token}
122
- let projectId: string | undefined;
123
- if (model.api !== "google-generative-ai" && auth.apiKey) {
124
- try {
125
- const parsed = JSON.parse(auth.apiKey);
126
- projectId = parsed.projectId;
127
- } catch {
128
- // Not a JSON string, ignore
129
- }
130
- }
131
-
132
- const req = config.buildRequest(model, body, projectId);
477
+ const req = config.buildRequest(model, body);
133
478
 
134
479
  // Handle auth
135
480
  if (auth.headers) {
136
481
  Object.assign(req.headers, auth.headers);
137
482
  }
138
483
  if (auth.apiKey) {
139
- if (model.api === "google-generative-ai") {
140
- req.headers["x-goog-api-key"] = auth.apiKey;
141
- } else {
142
- const parsed = JSON.parse(auth.apiKey);
143
- req.headers["Authorization"] = `Bearer ${parsed.token}`;
144
- }
484
+ req.headers["x-goog-api-key"] = auth.apiKey;
145
485
  }
146
486
 
147
487
  const response = await fetch(req.url, {
@@ -155,111 +495,430 @@ export async function callApiStream(
155
495
  throw new Error(`API error (${response.status}): ${await response.text()}`);
156
496
  }
157
497
 
158
- if (!response.body) {
159
- throw new Error("No response body");
160
- }
161
-
162
- // Parse SSE stream
163
- const reader = response.body.getReader();
164
- const decoder = new TextDecoder();
165
- let buffer = "";
166
498
  let accumulatedText = "";
167
499
  let groundingMetadata: any;
168
500
  let urlContextMetadata: any;
169
- let currentEventData = "";
170
- let currentEventName = "";
171
501
 
172
- while (true) {
173
- if (signal?.aborted) {
174
- throw new Error("Request was aborted");
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}`);
175
506
  }
176
507
 
177
- const { done, value } = await reader.read();
178
- if (done) break;
179
-
180
- buffer += decoder.decode(value, { stream: true });
181
- const lines = buffer.split("\n");
182
- buffer = lines.pop() || "";
183
-
184
- for (const line of lines) {
185
- // Empty line indicates end of an event
186
- if (line === "" || line === "\r") {
187
- if (currentEventData) {
188
- // Process the complete event
189
- let chunk: any;
190
- try {
191
- chunk = JSON.parse(currentEventData);
192
- } catch {
193
- currentEventData = "";
194
- currentEventName = "";
195
- continue;
196
- }
197
-
198
- // Check for error in chunk (API errors are sent via SSE stream)
199
- if (chunk.error) {
200
- const errorMsg = chunk.error.message || JSON.stringify(chunk.error);
201
- throw new Error(`API error (${chunk.error.code || chunk.error.status || 'unknown'}): ${errorMsg}`);
202
- }
203
-
204
- // Unwrap response for internal APIs
205
- const data = chunk.response || chunk;
206
- const candidate = data.candidates?.[0];
207
-
208
- if (candidate?.content?.parts) {
209
- for (const part of candidate.content.parts) {
210
- if (part.text) {
211
- accumulatedText += part.text;
212
- // Stream update
213
- onUpdate?.({
214
- content: [{ type: "text", text: accumulatedText }],
215
- details: { streaming: true }
216
- });
217
- }
218
- }
219
- }
220
-
221
- // Capture metadata from final chunk
222
- if (candidate?.groundingMetadata) {
223
- groundingMetadata = candidate.groundingMetadata;
224
- }
225
- // Handle both camelCase and snake_case
226
- if (candidate?.urlContextMetadata || candidate?.url_context_metadata) {
227
- urlContextMetadata = candidate.urlContextMetadata || candidate.url_context_metadata;
228
- }
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
+ });
229
520
  }
230
- currentEventData = "";
231
- currentEventName = "";
232
- continue;
233
521
  }
522
+ }
234
523
 
235
- // Parse SSE field
236
- if (line.startsWith("data:")) {
237
- const data = line.slice(5).trim();
238
- currentEventData = currentEventData ? currentEventData + "\n" + data : data;
239
- } else if (line.startsWith("event:")) {
240
- currentEventName = line.slice(6).trim();
241
- // Check for error event type
242
- if (currentEventName === "error") {
243
- // Next data line should contain error details
244
- }
245
- } else if (line.startsWith("id:")) {
246
- // Event ID, can be ignored for now
247
- } else if (line.startsWith(":")) {
248
- // Comment line, ignore
249
- }
524
+ // Capture metadata from final chunk
525
+ if (candidate?.groundingMetadata) {
526
+ groundingMetadata = candidate.groundingMetadata;
250
527
  }
251
- }
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
+ });
252
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);
253
538
  return {
254
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,
255
547
  groundingMetadata,
256
548
  urlContextMetadata
257
549
  };
258
550
  }
259
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
+ });
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
+ };
654
+
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);
662
+ }
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
+ });
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;
757
+ }
758
+ }
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
+
879
+ return {
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,
889
+ };
890
+ }
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
+
260
919
  // --- Citation Processing (byte-safe) ---
261
920
 
262
- 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[] } {
263
922
  const chunks = groundingMetadata?.groundingChunks || [];
264
923
  const supports = groundingMetadata?.groundingSupports || [];
265
924