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/README.md +10 -2
- package/package.json +6 -2
- package/src/api.ts +13 -1070
- package/src/format.ts +184 -0
- package/src/index.ts +4 -3
- package/src/providers/anthropic.ts +192 -0
- package/src/providers/auth.ts +62 -0
- package/src/providers/config.ts +46 -0
- package/src/providers/google.ts +228 -0
- package/src/providers/openai.ts +323 -0
- package/src/providers/results.ts +178 -0
- package/src/providers/sse.ts +94 -0
- package/src/providers/types.ts +42 -0
- package/src/url_context.ts +4 -62
- package/src/utils.ts +3 -15
- package/src/web_search.ts +9 -66
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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
type
|
|
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
|
-
}
|