pi-web-search 1.0.2 → 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 +23 -9
- package/src/api.ts +848 -108
- package/src/index.ts +69 -7
- package/src/url_context.ts +49 -8
- package/src/utils.ts +49 -49
- package/src/web_search.ts +42 -12
- package/dist/api.d.ts +0 -26
- package/dist/api.js +0 -182
- 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,163 +28,897 @@ 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 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- Auth Compatibility Layer ---
|
|
49
|
+
|
|
50
|
+
type ResolvedAuth =
|
|
51
|
+
| { ok: true; apiKey?: string; headers?: Record<string, string>; }
|
|
52
|
+
| { ok: false; error: string; };
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get API key and headers for a model.
|
|
56
|
+
* Compatible with both new pi versions (getApiKeyAndHeaders) and old versions (getApiKey).
|
|
57
|
+
*/
|
|
58
|
+
async function getAuth(ctx: ExtensionContext, model: Model<any>): Promise<ResolvedAuth> {
|
|
59
|
+
const registry = ctx.modelRegistry as any;
|
|
60
|
+
|
|
61
|
+
// Try new API first (pi >= 0.63.0)
|
|
62
|
+
if (typeof registry.getApiKeyAndHeaders === 'function') {
|
|
63
|
+
return await registry.getApiKeyAndHeaders(model);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Fallback to old API (pi < 0.63.0)
|
|
67
|
+
if (typeof registry.getApiKey === 'function') {
|
|
68
|
+
const apiKey = await registry.getApiKey(model);
|
|
69
|
+
if (apiKey === undefined || apiKey === null) {
|
|
70
|
+
return { ok: false, error: "No API key configured for model" };
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, apiKey };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { ok: false, error: "Model registry does not support API key retrieval" };
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
// --- Streaming API Call ---
|
|
70
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
|
+
|
|
71
107
|
export interface StreamResult {
|
|
72
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[];
|
|
73
117
|
groundingMetadata?: any;
|
|
74
118
|
urlContextMetadata?: any;
|
|
75
119
|
}
|
|
76
120
|
|
|
77
|
-
|
|
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(
|
|
78
461
|
ctx: ExtensionContext,
|
|
79
462
|
model: Model<any>,
|
|
80
463
|
body: any,
|
|
81
|
-
onUpdate?: AgentToolUpdateCallback
|
|
464
|
+
onUpdate?: AgentToolUpdateCallback,
|
|
465
|
+
signal?: AbortSignal
|
|
82
466
|
): Promise<StreamResult> {
|
|
83
467
|
const config = getConfig(model);
|
|
84
|
-
|
|
468
|
+
if (!config.buildRequest) {
|
|
469
|
+
throw new Error(`Unsupported Google provider: ${model.provider}`);
|
|
470
|
+
}
|
|
85
471
|
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
projectId = parsed.projectId;
|
|
472
|
+
const auth = await getAuth(ctx, model);
|
|
473
|
+
if (!auth.ok) {
|
|
474
|
+
throw new Error(auth.error || "Failed to get API key and headers");
|
|
90
475
|
}
|
|
91
476
|
|
|
92
|
-
const req = config.buildRequest(model, body
|
|
477
|
+
const req = config.buildRequest(model, body);
|
|
93
478
|
|
|
94
479
|
// Handle auth
|
|
95
|
-
if (
|
|
96
|
-
req.headers
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
req.headers["
|
|
480
|
+
if (auth.headers) {
|
|
481
|
+
Object.assign(req.headers, auth.headers);
|
|
482
|
+
}
|
|
483
|
+
if (auth.apiKey) {
|
|
484
|
+
req.headers["x-goog-api-key"] = auth.apiKey;
|
|
100
485
|
}
|
|
101
486
|
|
|
102
487
|
const response = await fetch(req.url, {
|
|
103
488
|
method: "POST",
|
|
104
489
|
headers: req.headers,
|
|
105
|
-
body: JSON.stringify(req.body)
|
|
490
|
+
body: JSON.stringify(req.body),
|
|
491
|
+
signal
|
|
106
492
|
});
|
|
107
493
|
|
|
108
494
|
if (!response.ok) {
|
|
109
495
|
throw new Error(`API error (${response.status}): ${await response.text()}`);
|
|
110
496
|
}
|
|
111
497
|
|
|
112
|
-
if (!response.body) {
|
|
113
|
-
throw new Error("No response body");
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Parse SSE stream
|
|
117
|
-
const reader = response.body.getReader();
|
|
118
|
-
const decoder = new TextDecoder();
|
|
119
|
-
let buffer = "";
|
|
120
498
|
let accumulatedText = "";
|
|
121
499
|
let groundingMetadata: any;
|
|
122
500
|
let urlContextMetadata: any;
|
|
123
501
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const lines = buffer.split("\n");
|
|
130
|
-
buffer = lines.pop() || "";
|
|
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}`);
|
|
506
|
+
}
|
|
131
507
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
});
|
|
520
|
+
}
|
|
142
521
|
}
|
|
522
|
+
}
|
|
143
523
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
524
|
+
// Capture metadata from final chunk
|
|
525
|
+
if (candidate?.groundingMetadata) {
|
|
526
|
+
groundingMetadata = candidate.groundingMetadata;
|
|
527
|
+
}
|
|
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
|
+
});
|
|
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);
|
|
538
|
+
return {
|
|
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,
|
|
547
|
+
groundingMetadata,
|
|
548
|
+
urlContextMetadata
|
|
549
|
+
};
|
|
550
|
+
}
|
|
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
|
+
});
|
|
159
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
|
+
};
|
|
160
654
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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);
|
|
164
662
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
+
});
|
|
168
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;
|
|
169
757
|
}
|
|
170
758
|
}
|
|
171
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
|
+
|
|
172
879
|
return {
|
|
173
|
-
text:
|
|
174
|
-
|
|
175
|
-
|
|
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,
|
|
176
889
|
};
|
|
177
890
|
}
|
|
178
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
|
+
|
|
179
919
|
// --- Citation Processing (byte-safe) ---
|
|
180
920
|
|
|
181
|
-
export function applyCitations(text: string, groundingMetadata: any): { text: string; sources:
|
|
921
|
+
export function applyCitations(text: string, groundingMetadata: any): { text: string; sources: Source[] } {
|
|
182
922
|
const chunks = groundingMetadata?.groundingChunks || [];
|
|
183
923
|
const supports = groundingMetadata?.groundingSupports || [];
|
|
184
924
|
|