pi-web-search 1.5.0 → 1.7.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,1046 +1,24 @@
1
1
  import type { ExtensionContext, AgentToolUpdateCallback } from "@earendil-works/pi-coding-agent";
2
- import type { Api, Model } from "@earendil-works/pi-ai";
3
- import { getEnvApiKey } from "@earendil-works/pi-ai/compat";
4
- import { TextEncoder, TextDecoder } from "util";
5
-
6
- // --- Provider Configuration ---
7
-
8
- type ProviderKind = "google" | "openai" | "xai" | "anthropic" | "unsupported";
9
-
10
- type GoogleRequestBuilder = (model: Model<Api>, body: any) => { url: string; headers: Record<string, string>; body: any };
11
-
12
- type ProviderConfig = {
13
- kind: ProviderKind;
14
- searchTool?: string;
15
- urlContextTool?: string;
16
- buildRequest?: GoogleRequestBuilder;
17
- };
18
-
19
- const GOOGLE_PROVIDERS: Record<string, ProviderConfig> = {
20
- "google-generative-ai": {
21
- kind: "google",
22
- searchTool: "google_search",
23
- urlContextTool: "url_context",
24
- buildRequest: (model, body) => ({
25
- url: `${model.baseUrl}/models/${model.id}:streamGenerateContent?alt=sse`,
26
- headers: {
27
- "Content-Type": "application/json",
28
- "Accept": "text/event-stream",
29
- },
30
- body
31
- })
32
- }
33
- };
34
-
35
- export function getProviderKind(model: Model<Api>): ProviderKind {
36
- if (GOOGLE_PROVIDERS[model.provider] || GOOGLE_PROVIDERS[model.api]) return "google";
37
- if (model.provider === "xai" && model.api === "openai-responses") return "xai";
38
- if (
39
- model.api === "openai-responses"
40
- || model.api === "azure-openai-responses"
41
- || model.api === "openai-codex-responses"
42
- ) return "openai";
43
- if (model.api === "anthropic-messages") return "anthropic";
44
- return "unsupported";
45
- }
46
-
47
- export function getConfig(model: Model<Api>): ProviderConfig {
48
- const googleConfig = GOOGLE_PROVIDERS[model.provider] || GOOGLE_PROVIDERS[model.api];
49
- if (googleConfig) return googleConfig;
50
- const kind = getProviderKind(model);
51
- return { kind };
52
- }
53
-
54
- // --- Auth Compatibility Layer ---
55
-
56
- type ResolvedAuth =
57
- | { ok: true; apiKey?: string; headers?: Record<string, string>; baseUrl?: string; }
58
- | { ok: false; error: string; };
59
-
60
- function getEnvAuth(model: Model<Api>): Extract<ResolvedAuth, { ok: true }> | undefined {
61
- const apiKey = getEnvApiKey(model.provider);
62
- return apiKey ? { ok: true, apiKey } : undefined;
63
- }
64
-
65
- /**
66
- * Get API key and headers for a model.
67
- */
68
- async function getAuth(ctx: ExtensionContext, model: Model<Api>): Promise<ResolvedAuth> {
69
- const resolved = await ctx.modelRegistry.getApiKeyAndHeaders(model);
70
- if (!resolved.ok) return resolved;
71
-
72
- // pi-coding-agent 0.80.1+ returns { ok: true } from getApiKeyAndHeaders()
73
- // when auth only comes from provider env vars such as ANTHROPIC_API_KEY.
74
- // The main agent still works because pi-ai's streamSimple() performs its own
75
- // getEnvApiKey() fallback, but this extension calls fetch() directly, so it
76
- // must mirror that fallback while preserving explicit model/auth headers.
77
- const envAuth = !resolved.apiKey && !hasAuthHeader(resolved.headers) ? getEnvAuth(model) : undefined;
78
- return envAuth ? { ...resolved, apiKey: envAuth.apiKey } : resolved;
79
- }
80
-
81
- function hasAuthHeader(headers?: Record<string, string>): boolean {
82
- if (!headers) return false;
83
- return Object.entries(headers).some(([name, value]) => {
84
- if (!value) return false;
85
- const normalized = name.toLowerCase();
86
- return normalized === "authorization" || normalized === "x-api-key" || normalized === "x-goog-api-key";
87
- });
88
- }
89
-
90
- // --- Streaming API Call ---
91
-
92
- export interface Source {
93
- title: string;
94
- url: string;
95
- }
96
-
97
- export interface SearchResultDetail {
98
- title?: string;
99
- url?: string;
100
- query?: string;
101
- source?: string;
102
- pageAge?: string | null;
103
- citedText?: string;
104
- status?: string;
105
- type?: string;
106
- raw?: any;
107
- }
108
-
109
- export interface NativeSearchCallDetail {
110
- id?: string;
111
- provider: ProviderKind;
112
- status?: string;
113
- actionType?: string;
114
- queries?: string[];
115
- urls?: string[];
116
- raw?: any;
117
- }
118
-
119
- export interface StreamResult {
120
- text: string;
121
- sources?: Source[];
122
- providerKind?: ProviderKind;
123
- nativeSearchUsed?: boolean;
124
- nativeSearchEvents?: string[];
125
- nativeSearchCalls?: NativeSearchCallDetail[];
126
- searchQueries?: string[];
127
- searchResults?: SearchResultDetail[];
128
- citations?: SearchResultDetail[];
129
- groundingMetadata?: any;
130
- urlContextMetadata?: any;
131
- }
132
-
133
- type SseEvent = {
134
- event: string;
135
- data: any;
136
- };
137
-
138
- async function readSseEvents(
139
- response: Response,
140
- signal: AbortSignal | undefined,
141
- onEvent: (event: SseEvent) => boolean | void | Promise<boolean | void>
142
- ): Promise<void> {
143
- if (!response.body) {
144
- throw new Error("No response body");
145
- }
146
-
147
- const reader = response.body.getReader();
148
- const decoder = new TextDecoder();
149
- let buffer = "";
150
- let currentEventData = "";
151
- let currentEventName = "";
152
- let stopRequested = false;
153
- let reachedEof = false;
154
-
155
- const flushEvent = async (): Promise<boolean> => {
156
- if (!currentEventData) return false;
157
- const raw = currentEventData.trim();
158
- currentEventData = "";
159
- const eventName = currentEventName;
160
- currentEventName = "";
161
- if (!raw || raw === "[DONE]") return false;
162
-
163
- let data: any;
164
- try {
165
- data = JSON.parse(raw);
166
- } catch {
167
- return false;
168
- }
169
- return await onEvent({ event: eventName, data }) === true;
170
- };
171
-
172
- try {
173
- readLoop: while (true) {
174
- if (signal?.aborted) {
175
- throw new Error("Request was aborted");
176
- }
177
-
178
- const { done, value } = await reader.read();
179
- if (done) {
180
- reachedEof = true;
181
- break;
182
- }
183
-
184
- buffer += decoder.decode(value, { stream: true });
185
- const lines = buffer.split("\n");
186
- buffer = lines.pop() || "";
187
-
188
- for (const line of lines) {
189
- if (line === "" || line === "\r") {
190
- if (await flushEvent()) {
191
- stopRequested = true;
192
- break readLoop;
193
- }
194
- continue;
195
- }
196
-
197
- if (line.startsWith("data:")) {
198
- const data = line.slice(5).trim();
199
- currentEventData = currentEventData ? currentEventData + "\n" + data : data;
200
- } else if (line.startsWith("event:")) {
201
- currentEventName = line.slice(6).trim();
202
- }
203
- }
204
- }
205
-
206
- if (!stopRequested && buffer.trim()) {
207
- const line = buffer.trim();
208
- if (line.startsWith("data:")) {
209
- const data = line.slice(5).trim();
210
- currentEventData = currentEventData ? currentEventData + "\n" + data : data;
211
- }
212
- }
213
- if (!stopRequested) stopRequested = await flushEvent();
214
- } finally {
215
- if (!reachedEof) {
216
- try {
217
- await reader.cancel();
218
- } catch {
219
- // Ignore cancellation failures while cleaning up an interrupted stream.
220
- }
221
- }
222
- reader.releaseLock();
223
- }
224
- }
225
-
226
- function extractPromptFromGeminiBody(body: any): string {
227
- const parts: string[] = [];
228
- for (const content of body?.contents || []) {
229
- for (const part of content?.parts || []) {
230
- if (typeof part?.text === "string") {
231
- parts.push(part.text);
232
- } else if (part?.file_data?.file_uri) {
233
- parts.push(String(part.file_data.file_uri));
234
- }
235
- }
236
- }
237
- return parts.join("\n\n").trim();
238
- }
239
-
240
- function trimTrailingSlash(value: string): string {
241
- return value.replace(/\/+$/, "");
242
- }
243
-
244
- function isOpenAICodexModel(model: Model<Api>): boolean {
245
- return model.api === "openai-codex-responses";
246
- }
247
-
248
- function resolveGitHubCopilotBaseUrl(
249
- model: Model<Api>,
250
- auth: Extract<ResolvedAuth, { ok: true }>,
251
- ): string {
252
- if (model.provider !== "github-copilot") return model.baseUrl;
253
-
254
- // Modern pi versions expose the credential-specific Copilot endpoint
255
- // resolved by the provider. Prefer it so GitHub Enterprise Server and any
256
- // future provider-owned routing continue to work without token parsing here.
257
- if (typeof auth.baseUrl === "string" && auth.baseUrl.trim()) {
258
- return auth.baseUrl;
259
- }
260
-
261
- // Compatibility fallback for older pi versions whose extension auth API
262
- // returned the Copilot token but not its resolved base URL.
263
- if (!auth.apiKey) return model.baseUrl;
264
- const proxyEndpoints = auth.apiKey
265
- .split(";")
266
- .filter((field) => field.startsWith("proxy-ep="))
267
- .map((field) => field.slice("proxy-ep=".length));
268
- if (proxyEndpoints.length !== 1) return model.baseUrl;
269
-
270
- const proxyHost = proxyEndpoints[0].toLowerCase();
271
- const labels = proxyHost.split(".");
272
- const isValidLabel = (label: string) =>
273
- label.length > 0
274
- && label.length <= 63
275
- && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label);
276
- const isCopilotProxyHost = proxyHost.length <= 253
277
- && labels.length >= 4
278
- && labels[0] === "proxy"
279
- && labels.at(-2) === "githubcopilot"
280
- && labels.at(-1) === "com"
281
- && labels.every(isValidLabel);
282
- if (!isCopilotProxyHost) return model.baseUrl;
283
-
284
- return `https://api.${labels.slice(1).join(".")}`;
285
- }
286
-
287
- function resolveOpenAIResponsesUrl(model: Model<Api>, baseUrl = model.baseUrl): string {
288
- const base = trimTrailingSlash(baseUrl);
289
- if (!isOpenAICodexModel(model)) return `${base}/responses`;
290
- if (base.endsWith("/codex/responses")) return base;
291
- if (base.endsWith("/codex")) return `${base}/responses`;
292
- return `${base}/codex/responses`;
293
- }
294
-
295
- function extractOpenAICodexAccountId(token: string): string {
296
- try {
297
- const parts = token.split(".");
298
- if (parts.length !== 3) throw new Error("Invalid token");
299
- const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
300
- const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
301
- const bytes = Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
302
- const payload = JSON.parse(new TextDecoder().decode(bytes));
303
- const accountId = payload?.["https://api.openai.com/auth"]?.chatgpt_account_id;
304
- if (typeof accountId !== "string" || !accountId) throw new Error("Missing account ID");
305
- return accountId;
306
- } catch {
307
- throw new Error("Failed to extract ChatGPT account ID from openai-codex credentials");
308
- }
309
- }
310
-
311
- function resolveAnthropicMessagesUrl(baseUrl: string): string {
312
- const base = trimTrailingSlash(baseUrl);
313
- return base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
314
- }
315
-
316
- function pushUniqueSource(sources: Source[], source: Source): number {
317
- const url = source.url || "";
318
- const title = source.title || "Unknown";
319
- const existingIndex = sources.findIndex((s) => s.url === url && s.title === title);
320
- if (existingIndex >= 0) return existingIndex;
321
- sources.push({ title, url });
322
- return sources.length - 1;
323
- }
324
-
325
- function pushUniqueString(values: string[], value: string | undefined | null) {
326
- if (!value || values.includes(value)) return;
327
- values.push(value);
328
- }
329
-
330
- function pushUniqueSearchResult(results: SearchResultDetail[], result: SearchResultDetail) {
331
- const key = `${result.url || ""}\t${result.title || ""}\t${result.query || ""}\t${result.citedText || ""}\t${result.type || ""}`;
332
- const exists = results.some((item) => `${item.url || ""}\t${item.title || ""}\t${item.query || ""}\t${item.citedText || ""}\t${item.type || ""}` === key);
333
- if (!exists) results.push(result);
334
- }
335
-
336
- function pushNativeSearchEvent(events: string[], event: string) {
337
- if (!events.includes(event)) events.push(event);
338
- }
339
-
340
- function normalizeSearchUrl(url: string): string {
341
- try {
342
- const parsed = new URL(url);
343
- parsed.hash = "";
344
- if (!/(^|\.)youtube\.com$/i.test(parsed.hostname) && !/(^|\.)youtu\.be$/i.test(parsed.hostname)) {
345
- const removableParams = ["ref", "referral_type", "openLinerExtension", "_clear", "lang", "api-mode"];
346
- for (const name of removableParams) parsed.searchParams.delete(name);
347
- for (const name of [...parsed.searchParams.keys()]) {
348
- if (name.toLowerCase().startsWith("utm_")) parsed.searchParams.delete(name);
349
- }
350
- }
351
- const query = parsed.searchParams.toString();
352
- parsed.search = query ? `?${query}` : "";
353
- return parsed.toString();
354
- } catch {
355
- return url;
356
- }
357
- }
358
-
359
- function titleFromUrl(url: string): string {
360
- try {
361
- const parsed = new URL(url);
362
- const lastSegment = parsed.pathname.split("/").filter(Boolean).pop();
363
- return lastSegment || parsed.hostname || url;
364
- } catch {
365
- return url;
366
- }
367
- }
368
-
369
- function extractOpenAIUrlCitation(annotation: any): { endIndex?: number; title: string; url: string } | undefined {
370
- const nested = annotation?.url_citation || annotation?.urlCitation;
371
- const url = annotation?.url || nested?.url;
372
- if (!url || typeof url !== "string") return undefined;
373
-
374
- const title = annotation?.title || nested?.title || titleFromUrl(url);
375
- const endIndexValue = annotation?.end_index ?? annotation?.endIndex ?? nested?.end_index ?? nested?.endIndex;
376
- return {
377
- endIndex: typeof endIndexValue === "number" ? endIndexValue : undefined,
378
- title,
379
- url,
380
- };
381
- }
382
-
383
- function mergeSearchResultMetadata(results: SearchResultDetail[], extras: SearchResultDetail[]) {
384
- for (const extra of extras) {
385
- if (!extra.url) continue;
386
- const existing = results.find((item) => item.url === extra.url);
387
- if (!existing) continue;
388
- if (!existing.title && extra.title) existing.title = extra.title;
389
- if (!existing.query && extra.query) existing.query = extra.query;
390
- if (!existing.citedText && extra.citedText) existing.citedText = extra.citedText;
391
- if (!existing.status && extra.status) existing.status = extra.status;
392
- if (!existing.type && extra.type) existing.type = extra.type;
393
- if (!existing.source && extra.source) existing.source = extra.source;
394
- }
395
- }
396
-
397
- function isLikelyJunkSearchUrl(url: string | undefined): boolean {
398
- if (!url) return true;
399
- try {
400
- const parsed = new URL(url);
401
- const decodedPath = decodeURIComponent(parsed.pathname).toLowerCase();
402
- const suspiciousSuffixes = [
403
- ".gz", ".zip", ".tgz", ".tar", ".woff", ".woff2", ".ttf", ".otf", ".eot",
404
- ".webm", ".mp4", ".mp3", ".wav", ".eps", ".sql", ".csv", ".xls", ".xlsx", ".ppt", ".pptx"
405
- ];
406
- if (suspiciousSuffixes.some((suffix) => decodedPath.endsWith(suffix))) return true;
407
- if (decodedPath === "/%" || decodedPath.endsWith("/%")) return true;
408
- return false;
409
- } catch {
410
- return false;
411
- }
412
- }
413
-
414
- function sanitizeSearchResults(results: SearchResultDetail[]): SearchResultDetail[] {
415
- const sanitized: SearchResultDetail[] = [];
416
- for (const result of results) {
417
- const normalizedUrl = result.url ? normalizeSearchUrl(result.url) : result.url;
418
- const normalized = { ...result, url: normalizedUrl };
419
- if (normalized.url && isLikelyJunkSearchUrl(normalized.url)) continue;
420
- pushUniqueSearchResult(sanitized, normalized);
421
- }
422
- return sanitized;
423
- }
424
-
425
- function deriveSources(searchResults: SearchResultDetail[], citations: SearchResultDetail[] = []): Source[] {
426
- const sources: Source[] = [];
427
- for (const item of [...citations, ...searchResults]) {
428
- if (!item.url) continue;
429
- const url = normalizeSearchUrl(item.url);
430
- if (isLikelyJunkSearchUrl(url)) continue;
431
- pushUniqueSource(sources, {
432
- title: item.title || titleFromUrl(url),
433
- url,
434
- });
435
- }
436
- return sources;
437
- }
438
-
439
- function isGoogleGroundingRedirect(url: string | undefined): boolean {
440
- return !!url && /^https:\/\/vertexaisearch\.cloud\.google\.com\/grounding-api-redirect\//.test(url);
441
- }
442
-
443
- async function resolveGoogleGroundingRedirectUrls(searchResults: SearchResultDetail[], citations: SearchResultDetail[], signal?: AbortSignal) {
444
- const redirectUrls = [...new Set([...searchResults, ...citations].map((item) => item.url).filter((url): url is string => isGoogleGroundingRedirect(url)))];
445
- if (redirectUrls.length === 0) return;
446
-
447
- const resolved = new Map<string, string>();
448
- await Promise.all(redirectUrls.slice(0, 20).map(async (url) => {
449
- try {
450
- const response = await fetch(url, { method: "HEAD", redirect: "manual", signal });
451
- const location = response.headers.get("location");
452
- if (location) resolved.set(url, location);
453
- } catch {
454
- // Ignore redirect resolution failures and keep the original URL.
455
- }
456
- }));
457
-
458
- if (resolved.size === 0) return;
459
- for (const item of [...searchResults, ...citations]) {
460
- if (!item.url) continue;
461
- const canonicalUrl = resolved.get(item.url);
462
- if (!canonicalUrl) continue;
463
- item.url = canonicalUrl;
464
- if (!item.title || item.title === "Unknown") item.title = titleFromUrl(canonicalUrl);
465
- }
466
- }
467
-
468
- function preserveInlineCitations(text: string, citations: Array<{ endIndex?: number; title: string; url: string }>): { text: string; sources: Source[] } {
469
- const sources: Source[] = [];
470
- for (const citation of citations) {
471
- if (citation.url) pushUniqueSource(sources, { title: citation.title, url: citation.url });
472
- }
473
- return { text, sources };
474
- }
475
-
476
- function applyIndexCitations(text: string, citations: Array<{ endIndex?: number; title: string; url: string }>): { text: string; sources: Source[] } {
477
- const sources: Source[] = [];
478
- const insertions = citations
479
- .filter((c) => c.url && c.endIndex !== undefined)
480
- .map((c) => ({
481
- index: Math.max(0, Math.min(c.endIndex!, text.length)),
482
- marker: `[${pushUniqueSource(sources, { title: c.title, url: c.url }) + 1}]`
483
- }))
484
- .sort((a, b) => b.index - a.index);
485
-
486
- let result = text;
487
- const seen = new Set<string>();
488
- for (const insertion of insertions) {
489
- const key = `${insertion.index}:${insertion.marker}`;
490
- if (seen.has(key)) continue;
491
- seen.add(key);
492
- result = result.slice(0, insertion.index) + insertion.marker + result.slice(insertion.index);
493
- }
494
-
495
- // Preserve sources that had no end index.
496
- for (const citation of citations) {
497
- if (citation.url) pushUniqueSource(sources, { title: citation.title, url: citation.url });
498
- }
499
-
500
- return { text: result, sources };
501
- }
502
-
503
- function applyTextCitations(text: string, citations: Array<{ citedText?: string; title: string; url: string }>): { text: string; sources: Source[] } {
504
- const sources: Source[] = [];
505
- const insertions: Array<{ index: number; marker: string }> = [];
506
- const usedRanges = new Set<string>();
507
-
508
- for (const citation of citations) {
509
- if (!citation.url) continue;
510
- const marker = `[${pushUniqueSource(sources, { title: citation.title, url: citation.url }) + 1}]`;
511
- const citedText = citation.citedText?.trim();
512
- if (!citedText) continue;
513
- const index = text.indexOf(citedText);
514
- if (index < 0) continue;
515
- const end = index + citedText.length;
516
- const key = `${end}:${marker}`;
517
- if (usedRanges.has(key)) continue;
518
- usedRanges.add(key);
519
- insertions.push({ index: end, marker });
520
- }
521
-
522
- let result = text;
523
- for (const insertion of insertions.sort((a, b) => b.index - a.index)) {
524
- result = result.slice(0, insertion.index) + insertion.marker + result.slice(insertion.index);
525
- }
526
-
527
- return { text: result, sources };
528
- }
529
-
530
- function extractGoogleSearchDetails(groundingMetadata: any): { searchQueries: string[]; searchResults: SearchResultDetail[]; citations: SearchResultDetail[] } {
531
- const searchQueries = groundingMetadata?.webSearchQueries || [];
532
- const chunks = groundingMetadata?.groundingChunks || [];
533
- const supports = groundingMetadata?.groundingSupports || [];
534
- const searchResults: SearchResultDetail[] = [];
535
- const citations: SearchResultDetail[] = [];
536
-
537
- chunks.forEach((chunk: any, index: number) => {
538
- if (!chunk?.web) return;
539
- pushUniqueSearchResult(searchResults, {
540
- title: chunk.web.title || "Unknown",
541
- url: chunk.web.uri || "",
542
- source: "google.groundingChunks",
543
- type: "web",
544
- raw: { index, ...chunk.web },
545
- });
546
- });
547
-
548
- supports.forEach((support: any) => {
549
- for (const index of support?.groundingChunkIndices || []) {
550
- const web = chunks[index]?.web;
551
- if (!web) continue;
552
- pushUniqueSearchResult(citations, {
553
- title: web.title || "Unknown",
554
- url: web.uri || "",
555
- citedText: support?.segment?.text,
556
- source: "google.groundingSupports",
557
- type: "citation",
558
- raw: support,
559
- });
560
- }
561
- });
562
-
563
- return { searchQueries, searchResults, citations };
564
- }
565
-
566
- async function callGoogleStream(
567
- ctx: ExtensionContext,
568
- model: Model<Api>,
569
- body: any,
570
- onUpdate?: AgentToolUpdateCallback,
571
- signal?: AbortSignal
572
- ): Promise<StreamResult> {
573
- const config = getConfig(model);
574
- if (!config.buildRequest) {
575
- throw new Error(`Unsupported Google provider: ${model.provider}`);
576
- }
577
-
578
- const auth = await getAuth(ctx, model);
579
- if (!auth.ok) {
580
- throw new Error(auth.error || "Failed to get API key and headers");
581
- }
582
-
583
- const req = config.buildRequest(model, body);
584
-
585
- // Handle auth
586
- if (auth.headers) {
587
- Object.assign(req.headers, auth.headers);
588
- }
589
- if (auth.apiKey) {
590
- req.headers["x-goog-api-key"] = auth.apiKey;
591
- }
592
-
593
- const response = await fetch(req.url, {
594
- method: "POST",
595
- headers: req.headers,
596
- body: JSON.stringify(req.body),
597
- signal
598
- });
599
-
600
- if (!response.ok) {
601
- throw new Error(`API error (${response.status}): ${await response.text()}`);
602
- }
603
-
604
- let accumulatedText = "";
605
- let groundingMetadata: any;
606
- let urlContextMetadata: any;
607
-
608
- await readSseEvents(response, signal, ({ data: chunk }) => {
609
- if (chunk.error) {
610
- const errorMsg = chunk.error.message || JSON.stringify(chunk.error);
611
- throw new Error(`API error (${chunk.error.code || chunk.error.status || 'unknown'}): ${errorMsg}`);
612
- }
613
-
614
- // Unwrap response for internal APIs
615
- const data = chunk.response || chunk;
616
- const candidate = data.candidates?.[0];
617
-
618
- if (candidate?.content?.parts) {
619
- for (const part of candidate.content.parts) {
620
- if (part.text) {
621
- accumulatedText += part.text;
622
- onUpdate?.({
623
- content: [{ type: "text", text: accumulatedText }],
624
- details: { streaming: true }
625
- });
626
- }
627
- }
628
- }
629
-
630
- // Capture metadata from final chunk
631
- if (candidate?.groundingMetadata) {
632
- groundingMetadata = candidate.groundingMetadata;
633
- }
634
- // Handle both camelCase and snake_case
635
- if (candidate?.urlContextMetadata || candidate?.url_context_metadata) {
636
- urlContextMetadata = candidate.urlContextMetadata || candidate.url_context_metadata;
637
- }
638
- });
639
-
640
- const searchDetails = extractGoogleSearchDetails(groundingMetadata);
641
- await resolveGoogleGroundingRedirectUrls(searchDetails.searchResults, searchDetails.citations, signal);
642
- const searchResults = sanitizeSearchResults(searchDetails.searchResults);
643
- const citations = sanitizeSearchResults(searchDetails.citations);
644
- return {
645
- text: accumulatedText || "No answer available.",
646
- sources: deriveSources(searchResults, citations),
647
- providerKind: "google",
648
- nativeSearchUsed: searchDetails.searchQueries.length > 0 || searchResults.length > 0,
649
- nativeSearchEvents: searchDetails.searchQueries.length > 0 ? ["google.groundingMetadata.webSearchQueries"] : [],
650
- searchQueries: searchDetails.searchQueries,
651
- searchResults,
652
- citations,
653
- groundingMetadata,
654
- urlContextMetadata
655
- };
656
- }
657
-
658
- async function callOpenAIStream(
659
- ctx: ExtensionContext,
660
- model: Model<Api>,
661
- prompt: string,
662
- onUpdate?: AgentToolUpdateCallback,
663
- signal?: AbortSignal
664
- ): Promise<StreamResult> {
665
- const auth = await getAuth(ctx, model);
666
- if (!auth.ok) {
667
- throw new Error(auth.error || "Failed to get API key and headers");
668
- }
669
-
670
- const headers = new Headers();
671
- for (const [name, value] of Object.entries(model.headers || {})) headers.set(name, value);
672
- for (const [name, value] of Object.entries(auth.headers || {})) headers.set(name, value);
673
- if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
674
- if (!headers.has("Accept")) headers.set("Accept", "text/event-stream");
675
- if (auth.apiKey && !headers.has("Authorization")) headers.set("Authorization", `Bearer ${auth.apiKey}`);
676
-
677
- const isCodex = isOpenAICodexModel(model);
678
- if (isCodex) {
679
- const authorization = headers.get("Authorization");
680
- const hasBearerAuth = typeof authorization === "string" && /^Bearer\s+\S+/i.test(authorization);
681
- if (!auth.apiKey && !hasBearerAuth) {
682
- throw new Error("No OAuth credential configured for openai-codex model");
683
- }
684
- if (!headers.has("chatgpt-account-id")) {
685
- if (!auth.apiKey) {
686
- throw new Error("No ChatGPT account ID configured for openai-codex model");
687
- }
688
- headers.set("chatgpt-account-id", extractOpenAICodexAccountId(auth.apiKey));
689
- }
690
- if (!headers.has("originator")) headers.set("originator", "codex_cli_rs");
691
- }
692
- const requestHeaders = Object.fromEntries(headers.entries());
693
-
694
- const isXai = getProviderKind(model) === "xai";
695
- const searchProvider: ProviderKind = isXai ? "xai" : "openai";
696
- const providerSourceName = isXai ? "xai" : "openai";
697
- const requestBody: any = {
698
- model: model.id,
699
- input: isCodex
700
- ? [{ role: "user", content: [{ type: "input_text", text: prompt }] }]
701
- : isXai
702
- ? [{ role: "user", content: prompt }]
703
- : prompt,
704
- tools: [{ type: "web_search" }],
705
- ...(isCodex || isXai
706
- ? { include: ["web_search_call.action.sources"] }
707
- : { include: ["web_search_call.action.sources", "web_search_call.results"] }),
708
- stream: true,
709
- store: false,
710
- };
711
- // Let the provider choose its supported reasoning default. Some reasoning
712
- // models reject "none", and supported effort levels vary between models.
713
- if (isCodex) {
714
- requestBody.instructions = "Answer the user's request using web search when needed.";
715
- requestBody.text = { verbosity: "low" };
716
- requestBody.tool_choice = "required";
717
- requestBody.parallel_tool_calls = true;
718
- }
719
-
720
- const baseUrl = resolveGitHubCopilotBaseUrl(model, auth);
721
- const response = await fetch(resolveOpenAIResponsesUrl(model, baseUrl), {
722
- method: "POST",
723
- headers: requestHeaders,
724
- body: JSON.stringify(requestBody),
725
- signal
726
- });
727
-
728
- if (!response.ok) {
729
- throw new Error(`${isXai ? "xAI" : "OpenAI"} API error (${response.status}): ${await response.text()}`);
730
- }
731
-
732
- let accumulatedText = "";
733
- const citations: Array<{ endIndex?: number; title: string; url: string }> = [];
734
- const nativeSearchEvents: string[] = [];
735
- const nativeSearchCalls: NativeSearchCallDetail[] = [];
736
- const searchQueries: string[] = [];
737
- const searchResults: SearchResultDetail[] = [];
738
-
739
- const collectAnnotation = (annotation: any) => {
740
- if (annotation?.type !== "url_citation") return;
741
- const citation = extractOpenAIUrlCitation(annotation);
742
- if (!citation) return;
743
- citations.push(citation);
744
- };
745
-
746
- const collectWebSearchCall = (item: any) => {
747
- if (item?.type !== "web_search_call") return;
748
- const action = item.action || {};
749
- const call: NativeSearchCallDetail = {
750
- id: item.id,
751
- provider: searchProvider,
752
- status: item.status,
753
- actionType: action.type,
754
- raw: item,
755
- };
756
- if (Array.isArray(action.queries)) {
757
- const queries = action.queries.filter((query: any): query is string => typeof query === "string");
758
- call.queries = queries;
759
- for (const query of queries) pushUniqueString(searchQueries, query);
760
- } else if (typeof action.query === "string") {
761
- call.queries = [action.query];
762
- pushUniqueString(searchQueries, action.query);
763
- }
764
- if (Array.isArray(action.sources)) {
765
- call.urls = action.sources.map((source: any) => source?.url).filter((url: any): url is string => typeof url === "string");
766
- for (const source of action.sources) {
767
- if (!source?.url) continue;
768
- pushUniqueSearchResult(searchResults, {
769
- title: source.title || source.display_name || source.name || titleFromUrl(source.url),
770
- url: source.url,
771
- source: `${providerSourceName}.web_search_call.action.sources`,
772
- type: source.type || "url",
773
- raw: source,
774
- });
775
- }
776
- }
777
- if (action.url) {
778
- call.urls = [...(call.urls || []), action.url];
779
- pushUniqueSearchResult(searchResults, {
780
- title: titleFromUrl(action.url),
781
- url: action.url,
782
- source: `${providerSourceName}.web_search_call.action.${action.type}`,
783
- type: action.type,
784
- raw: action,
785
- });
786
- }
787
- const existingCall = call.id ? nativeSearchCalls.find((existing) => existing.id === call.id) : undefined;
788
- if (existingCall) {
789
- Object.assign(existingCall, Object.fromEntries(
790
- Object.entries(call).filter(([, value]) => value !== undefined)
791
- ));
792
- } else {
793
- nativeSearchCalls.push(call);
794
- }
795
- };
796
-
797
- const collectFromResponse = (response: any) => {
798
- for (const item of response?.output || []) {
799
- collectWebSearchCall(item);
800
- if (item?.type !== "message") continue;
801
- for (const content of item.content || []) {
802
- if (content?.type !== "output_text") continue;
803
- for (const annotation of content.annotations || []) collectAnnotation(annotation);
804
- }
805
- }
806
- };
807
-
808
- await readSseEvents(response, signal, ({ data: event }) => {
809
- if (event.type === "error" || event.type === "response.failed") {
810
- const message = event.message || event.error?.message || event.response?.error?.message;
811
- throw new Error(message || JSON.stringify(event.error || event.response?.error || event));
812
- } else if (event.type === "response.output_text.delta") {
813
- accumulatedText += event.delta || "";
814
- onUpdate?.({
815
- content: [{ type: "text", text: accumulatedText }],
816
- details: { streaming: true }
817
- });
818
- } else if (event.type === "response.output_text.annotation.added") {
819
- collectAnnotation(event.annotation);
820
- } else if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
821
- collectWebSearchCall(event.item);
822
- } else if (event.type === "response.incomplete" || event.response?.status === "incomplete") {
823
- collectFromResponse(event.response);
824
- if (isCodex) return true;
825
- } else if (event.type === "response.completed" || event.type === "response.done") {
826
- collectFromResponse(event.response);
827
- if (isCodex) return true;
828
- } else if (event.type === "response.web_search_call.in_progress" || event.type === "response.web_search_call.searching" || event.type === "response.web_search_call.completed") {
829
- pushNativeSearchEvent(nativeSearchEvents, event.type);
830
- const call = nativeSearchCalls.find((item) => item.id === event.item_id);
831
- if (call) call.status = event.type.replace("response.web_search_call.", "");
832
- else nativeSearchCalls.push({ id: event.item_id, provider: searchProvider, status: event.type.replace("response.web_search_call.", ""), raw: event });
833
- if (event.type === "response.web_search_call.searching") {
834
- onUpdate?.({
835
- content: [{ type: "text", text: accumulatedText || `Searching the web with ${isXai ? "xAI" : "OpenAI"}...` }],
836
- details: { streaming: true, searching: true }
837
- });
838
- }
839
- }
840
- });
841
-
842
- const cited = isXai
843
- ? preserveInlineCitations(accumulatedText || "No answer available.", citations)
844
- : applyIndexCitations(accumulatedText || "No answer available.", citations);
845
- const citationDetails = citations.map((citation) => ({
846
- title: citation.title,
847
- url: citation.url,
848
- source: `${providerSourceName}.url_citation`,
849
- type: "citation",
850
- raw: citation,
851
- }));
852
- for (const citation of citationDetails) pushUniqueSearchResult(searchResults, citation);
853
- mergeSearchResultMetadata(searchResults, citationDetails);
854
- const sanitizedSearchResults = sanitizeSearchResults(searchResults);
855
- const sanitizedCitations = sanitizeSearchResults(citationDetails);
856
- mergeSearchResultMetadata(sanitizedSearchResults, sanitizedCitations);
857
- const derivedSources = deriveSources(sanitizedSearchResults, sanitizedCitations);
858
-
859
- return {
860
- text: cited.text,
861
- sources: cited.sources.length ? cited.sources.map((source) => ({ ...source, url: normalizeSearchUrl(source.url) })).filter((source) => !isLikelyJunkSearchUrl(source.url)) : derivedSources,
862
- providerKind: searchProvider,
863
- nativeSearchUsed: nativeSearchEvents.length > 0 || nativeSearchCalls.length > 0 || sanitizedSearchResults.length > 0,
864
- nativeSearchEvents,
865
- nativeSearchCalls,
866
- searchQueries,
867
- searchResults: sanitizedSearchResults,
868
- citations: sanitizedCitations,
869
- };
870
- }
871
-
872
- async function callAnthropicStream(
873
- ctx: ExtensionContext,
874
- model: Model<Api>,
875
- prompt: string,
876
- onUpdate?: AgentToolUpdateCallback,
877
- signal?: AbortSignal
878
- ): Promise<StreamResult> {
879
- const auth = await getAuth(ctx, model);
880
- if (!auth.ok) {
881
- throw new Error(auth.error || "Failed to get API key and headers");
882
- }
883
-
884
- const isOAuth = !!auth.apiKey && auth.apiKey.includes("sk-ant-oat");
885
- const headers: Record<string, string> = {
886
- "Content-Type": "application/json",
887
- "Accept": "text/event-stream",
888
- "anthropic-version": "2023-06-01",
889
- ...(model.headers || {}),
890
- ...(auth.headers || {}),
891
- };
892
-
893
- if (auth.apiKey) {
894
- if (isOAuth) {
895
- if (!headers.Authorization && !headers.authorization) headers.Authorization = `Bearer ${auth.apiKey}`;
896
- headers["anthropic-beta"] = headers["anthropic-beta"]
897
- ? `${headers["anthropic-beta"]},claude-code-20250219,oauth-2025-04-20`
898
- : "claude-code-20250219,oauth-2025-04-20";
899
- headers["user-agent"] = headers["user-agent"] || "claude-cli/2.1.75";
900
- headers["x-app"] = headers["x-app"] || "cli";
901
- } else if (!headers["x-api-key"] && !headers["X-Api-Key"]) {
902
- headers["x-api-key"] = auth.apiKey;
903
- }
904
- }
905
-
906
- const maxTokens = Math.min(Math.max(1024, Math.floor(model.maxTokens / 3) || 4096), 8192);
907
- const requestBody = {
908
- model: model.id,
909
- max_tokens: maxTokens,
910
- messages: [{ role: "user", content: prompt }],
911
- tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 10 }],
912
- stream: true,
913
- };
914
-
915
- const response = await fetch(resolveAnthropicMessagesUrl(model.baseUrl), {
916
- method: "POST",
917
- headers,
918
- body: JSON.stringify(requestBody),
919
- signal
920
- });
921
-
922
- if (!response.ok) {
923
- throw new Error(`Anthropic API error (${response.status}): ${await response.text()}`);
924
- }
925
-
926
- let accumulatedText = "";
927
- const citations: Array<{ citedText?: string; title: string; url: string }> = [];
928
- const nativeSearchEvents: string[] = [];
929
- const nativeSearchCalls: NativeSearchCallDetail[] = [];
930
- const searchResults: SearchResultDetail[] = [];
931
-
932
- const collectSource = (source: any, toolUseId?: string) => {
933
- if (!source?.url) return;
934
- const title = source.title || titleFromUrl(source.url);
935
- citations.push({ title, url: source.url });
936
- pushUniqueSearchResult(searchResults, {
937
- title,
938
- url: source.url,
939
- pageAge: source.page_age ?? source.pageAge,
940
- source: "anthropic.web_search_tool_result",
941
- type: source.type || "web_search_result",
942
- raw: { toolUseId, ...source },
943
- });
944
- };
945
-
946
- await readSseEvents(response, signal, ({ data: event }) => {
947
- if (event.type === "content_block_start") {
948
- const block = event.content_block;
949
- if (block?.type === "text" && block.text) {
950
- accumulatedText += block.text;
951
- onUpdate?.({ content: [{ type: "text", text: accumulatedText }], details: { streaming: true } });
952
- } else if (block?.type === "server_tool_use" && block.name === "web_search") {
953
- pushNativeSearchEvent(nativeSearchEvents, "anthropic.content_block_start.server_tool_use.web_search");
954
- nativeSearchCalls.push({
955
- id: block.id,
956
- provider: "anthropic",
957
- status: "in_progress",
958
- actionType: block.name,
959
- queries: typeof block.input?.query === "string" ? [block.input.query] : undefined,
960
- raw: block,
961
- });
962
- onUpdate?.({
963
- content: [{ type: "text", text: accumulatedText || "Searching the web with Anthropic..." }],
964
- details: { streaming: true, searching: true }
965
- });
966
- } else if (block?.type === "web_search_tool_result") {
967
- pushNativeSearchEvent(nativeSearchEvents, "anthropic.content_block_start.web_search_tool_result");
968
- const call = nativeSearchCalls.find((item) => item.id === block.tool_use_id);
969
- if (call) call.status = "completed";
970
- else nativeSearchCalls.push({ id: block.tool_use_id, provider: "anthropic", status: "completed", actionType: "web_search", raw: block });
971
- if (Array.isArray(block.content)) {
972
- for (const result of block.content) collectSource(result, block.tool_use_id);
973
- } else if (block.content?.type === "web_search_tool_result_error") {
974
- pushUniqueSearchResult(searchResults, {
975
- status: block.content.error_code,
976
- source: "anthropic.web_search_tool_result_error",
977
- type: block.content.type,
978
- raw: block,
979
- });
980
- }
981
- }
982
- } else if (event.type === "content_block_delta") {
983
- const delta = event.delta;
984
- if (delta?.type === "text_delta") {
985
- accumulatedText += delta.text || "";
986
- onUpdate?.({
987
- content: [{ type: "text", text: accumulatedText }],
988
- details: { streaming: true }
989
- });
990
- } else if (delta?.type === "citations_delta") {
991
- const citation = delta.citation;
992
- if (citation?.type === "web_search_result_location" && citation.url) {
993
- const detail = {
994
- citedText: citation.cited_text,
995
- title: citation.title || titleFromUrl(citation.url),
996
- url: citation.url,
997
- source: "anthropic.citations_delta",
998
- type: citation.type,
999
- raw: citation,
1000
- };
1001
- citations.push({ citedText: detail.citedText, title: detail.title, url: detail.url });
1002
- pushUniqueSearchResult(searchResults, detail);
1003
- }
1004
- }
1005
- } else if (event.type === "error") {
1006
- throw new Error(event.error?.message || JSON.stringify(event.error || event));
1007
- }
1008
- });
1009
-
1010
- const cited = applyTextCitations(accumulatedText || "No answer available.", citations);
1011
- const citationDetails = citations.map((citation) => ({
1012
- title: citation.title || titleFromUrl(citation.url),
1013
- url: citation.url,
1014
- citedText: citation.citedText,
1015
- source: "anthropic.citation",
1016
- type: "citation",
1017
- raw: citation,
1018
- }));
1019
- mergeSearchResultMetadata(searchResults, citationDetails);
1020
- const sanitizedSearchResults = sanitizeSearchResults(searchResults);
1021
- const sanitizedCitations = sanitizeSearchResults(citationDetails);
1022
- mergeSearchResultMetadata(sanitizedSearchResults, sanitizedCitations);
1023
- const derivedSources = deriveSources(sanitizedSearchResults, sanitizedCitations);
1024
-
1025
- return {
1026
- text: cited.text,
1027
- sources: cited.sources.length ? cited.sources.map((source) => ({ ...source, url: normalizeSearchUrl(source.url) })).filter((source) => !isLikelyJunkSearchUrl(source.url)) : derivedSources,
1028
- providerKind: "anthropic",
1029
- nativeSearchUsed: nativeSearchEvents.length > 0 || nativeSearchCalls.length > 0 || sanitizedSearchResults.length > 0,
1030
- nativeSearchEvents,
1031
- nativeSearchCalls,
1032
- searchQueries: nativeSearchCalls.flatMap((call) => call.queries || []),
1033
- searchResults: sanitizedSearchResults,
1034
- citations: sanitizedCitations,
1035
- };
1036
- }
2
+ import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
3
+ import { getProviderKind } from "./providers/config.ts";
4
+ import { callGoogleStream, extractPromptFromGeminiBody } from "./providers/google.ts";
5
+ import { callOpenAIStream } from "./providers/openai.ts";
6
+ import { callAnthropicStream } from "./providers/anthropic.ts";
7
+ import { callOllamaSearch } from "./providers/ollama.ts";
8
+ import type { StreamResult } from "./providers/types.ts";
9
+
10
+ export { getProviderKind, getConfig } from "./providers/config.ts";
11
+ export { applyCitations } from "./providers/google.ts";
12
+ export type { Source, SearchResultDetail, NativeSearchCallDetail, StreamResult } from "./providers/types.ts";
1037
13
 
1038
14
  export async function callApiStream(
1039
15
  ctx: ExtensionContext,
1040
16
  model: Model<Api>,
1041
17
  body: any,
1042
18
  onUpdate?: AgentToolUpdateCallback,
1043
- signal?: AbortSignal
19
+ signal?: AbortSignal,
20
+ thinkingLevel?: ModelThinkingLevel,
21
+ urls?: string[]
1044
22
  ): Promise<StreamResult> {
1045
23
  const kind = getProviderKind(model);
1046
24
  if (kind === "google") {
@@ -1052,60 +30,18 @@ export async function callApiStream(
1052
30
  throw new Error("No prompt text found in request body");
1053
31
  }
1054
32
 
33
+ // Ollama search is a standalone REST call, not a model tool — the prompt
34
+ // carries the embedded URL list only for other providers, so strip it here.
35
+ if (kind === "ollama") {
36
+ return callOllamaSearch(ctx, model, prompt, urls, onUpdate, signal);
37
+ }
38
+
1055
39
  if (kind === "openai" || kind === "xai") {
1056
- return callOpenAIStream(ctx, model, prompt, onUpdate, signal);
40
+ return callOpenAIStream(ctx, model, prompt, onUpdate, signal, thinkingLevel);
1057
41
  }
1058
- if (kind === "anthropic") {
42
+ if (kind === "anthropic" || kind === "deepseek") {
1059
43
  return callAnthropicStream(ctx, model, prompt, onUpdate, signal);
1060
44
  }
1061
45
 
1062
46
  throw new Error(`Unsupported provider for web search: ${model.provider} (${model.api})`);
1063
47
  }
1064
-
1065
- // --- Citation Processing (byte-safe) ---
1066
-
1067
- export function applyCitations(text: string, groundingMetadata: any): { text: string; sources: Source[] } {
1068
- const chunks = groundingMetadata?.groundingChunks || [];
1069
- const supports = groundingMetadata?.groundingSupports || [];
1070
-
1071
- const sources = chunks
1072
- .filter((c: any) => c.web)
1073
- .map((c: any) => ({ title: c.web.title || "Unknown", url: c.web.uri || "" }));
1074
-
1075
- if (!supports.length || !sources.length) return { text, sources };
1076
-
1077
- // Collect insertions, sort descending
1078
- const insertions = supports
1079
- .filter((s: any) => s.segment?.endIndex !== undefined && s.groundingChunkIndices?.length)
1080
- .map((s: any) => ({
1081
- index: s.segment.endIndex,
1082
- marker: s.groundingChunkIndices.map((i: number) => `[${i + 1}]`).join("")
1083
- }))
1084
- .sort((a: any, b: any) => b.index - a.index);
1085
-
1086
- // Byte-safe insertion
1087
- const encoder = new TextEncoder();
1088
- const decoder = new TextDecoder();
1089
- const bytes = encoder.encode(text);
1090
-
1091
- const parts: Uint8Array[] = [];
1092
- let lastIndex = bytes.length;
1093
-
1094
- for (const ins of insertions) {
1095
- const pos = Math.min(ins.index, lastIndex);
1096
- if (pos < lastIndex) parts.unshift(bytes.subarray(pos, lastIndex));
1097
- parts.unshift(encoder.encode(ins.marker));
1098
- lastIndex = pos;
1099
- }
1100
- if (lastIndex > 0) parts.unshift(bytes.subarray(0, lastIndex));
1101
-
1102
- const total = parts.reduce((acc, p) => acc + p.length, 0);
1103
- const final = new Uint8Array(total);
1104
- let offset = 0;
1105
- for (const part of parts) {
1106
- final.set(part, offset);
1107
- offset += part.length;
1108
- }
1109
-
1110
- return { text: decoder.decode(final), sources };
1111
- }