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