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