pi-web-search 1.3.0 → 1.4.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 +7 -2
- package/package.json +8 -6
- package/src/api.ts +209 -78
- package/src/index.ts +26 -4
- package/src/utils.ts +6 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-web-search
|
|
2
2
|
|
|
3
|
-
Provider-native web search for [pi](https://
|
|
3
|
+
Provider-native web search for [pi](https://pi.dev) with Gemini + URL Context, OpenAI Responses variants, and Anthropic.
|
|
4
4
|
|
|
5
5
|
## Tools
|
|
6
6
|
|
|
@@ -12,9 +12,14 @@ Search the web using your currently selected model. Automatically picks the righ
|
|
|
12
12
|
|---|---|
|
|
13
13
|
| Google Gemini | Grounding with Google Search |
|
|
14
14
|
| OpenAI | Responses API web search |
|
|
15
|
+
| Azure OpenAI | Responses API web search (`azure-openai-responses`) |
|
|
16
|
+
| OpenAI Codex | Codex Responses API web search (`openai-codex-responses`) |
|
|
17
|
+
| GitHub Copilot | OpenAI Responses API web search via Copilot credentials |
|
|
15
18
|
| Anthropic | Messages API web search |
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
GitHub Copilot OpenAI Responses models are supported, including Business and Enterprise seats whose API endpoint is resolved from their authenticated Copilot credentials. This includes models such as `gpt-5.6-sol`.
|
|
21
|
+
|
|
22
|
+
Supports passing up to 20 additional URLs to analyze alongside the query. Successful `web_search` results are collapsed by default in pi; expand the tool call to inspect the full answer and source details.
|
|
18
23
|
|
|
19
24
|
### `url_context`
|
|
20
25
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-search",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Provider-native web search for pi
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "Provider-native web search for pi: Gemini + URL Context, OpenAI Responses (Azure/Codex/Copilot), and Anthropic",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"keywords": [
|
|
@@ -37,8 +37,9 @@
|
|
|
37
37
|
"test:real:url-context": "node tests/real-url-context.mjs"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@earendil-works/pi-ai": "^0.
|
|
41
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
40
|
+
"@earendil-works/pi-ai": "^0.80.3",
|
|
41
|
+
"@earendil-works/pi-coding-agent": "^0.80.3",
|
|
42
|
+
"@earendil-works/pi-tui": "^0.80.3",
|
|
42
43
|
"@types/node": "^25.9.1",
|
|
43
44
|
"typebox": "^1.1.38",
|
|
44
45
|
"typescript": "^6.0.3"
|
|
@@ -49,8 +50,9 @@
|
|
|
49
50
|
]
|
|
50
51
|
},
|
|
51
52
|
"peerDependencies": {
|
|
52
|
-
"@earendil-works/pi-ai": "
|
|
53
|
-
"@earendil-works/pi-coding-agent": "
|
|
53
|
+
"@earendil-works/pi-ai": ">=0.80.3",
|
|
54
|
+
"@earendil-works/pi-coding-agent": ">=0.80.3",
|
|
55
|
+
"@earendil-works/pi-tui": "*",
|
|
54
56
|
"typebox": "*"
|
|
55
57
|
}
|
|
56
58
|
}
|
package/src/api.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { ExtensionContext, AgentToolUpdateCallback } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import { getEnvApiKey } from "@earendil-works/pi-ai/compat";
|
|
3
4
|
import { TextEncoder, TextDecoder } from "util";
|
|
4
5
|
|
|
5
6
|
// --- Provider Configuration ---
|
|
6
7
|
|
|
7
8
|
type ProviderKind = "google" | "openai" | "anthropic" | "unsupported";
|
|
8
9
|
|
|
9
|
-
type GoogleRequestBuilder = (model: Model<
|
|
10
|
+
type GoogleRequestBuilder = (model: Model<Api>, body: any) => { url: string; headers: Record<string, string>; body: any };
|
|
10
11
|
|
|
11
12
|
type ProviderConfig = {
|
|
12
13
|
kind: ProviderKind;
|
|
@@ -31,14 +32,18 @@ const GOOGLE_PROVIDERS: Record<string, ProviderConfig> = {
|
|
|
31
32
|
}
|
|
32
33
|
};
|
|
33
34
|
|
|
34
|
-
export function getProviderKind(model: Model<
|
|
35
|
+
export function getProviderKind(model: Model<Api>): ProviderKind {
|
|
35
36
|
if (GOOGLE_PROVIDERS[model.provider] || GOOGLE_PROVIDERS[model.api]) return "google";
|
|
36
|
-
if (
|
|
37
|
+
if (
|
|
38
|
+
model.api === "openai-responses"
|
|
39
|
+
|| model.api === "azure-openai-responses"
|
|
40
|
+
|| model.api === "openai-codex-responses"
|
|
41
|
+
) return "openai";
|
|
37
42
|
if (model.api === "anthropic-messages") return "anthropic";
|
|
38
43
|
return "unsupported";
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
export function getConfig(model: Model<
|
|
46
|
+
export function getConfig(model: Model<Api>): ProviderConfig {
|
|
42
47
|
const googleConfig = GOOGLE_PROVIDERS[model.provider] || GOOGLE_PROVIDERS[model.api];
|
|
43
48
|
if (googleConfig) return googleConfig;
|
|
44
49
|
const kind = getProviderKind(model);
|
|
@@ -48,31 +53,37 @@ export function getConfig(model: Model<any>): ProviderConfig {
|
|
|
48
53
|
// --- Auth Compatibility Layer ---
|
|
49
54
|
|
|
50
55
|
type ResolvedAuth =
|
|
51
|
-
| { ok: true; apiKey?: string; headers?: Record<string, string>; }
|
|
56
|
+
| { ok: true; apiKey?: string; headers?: Record<string, string>; baseUrl?: string; }
|
|
52
57
|
| { ok: false; error: string; };
|
|
53
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
|
+
|
|
54
64
|
/**
|
|
55
65
|
* Get API key and headers for a model.
|
|
56
|
-
* Compatible with both new pi versions (getApiKeyAndHeaders) and old versions (getApiKey).
|
|
57
66
|
*/
|
|
58
|
-
async function getAuth(ctx: ExtensionContext, model: Model<
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
});
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
// --- Streaming API Call ---
|
|
@@ -126,7 +137,7 @@ type SseEvent = {
|
|
|
126
137
|
async function readSseEvents(
|
|
127
138
|
response: Response,
|
|
128
139
|
signal: AbortSignal | undefined,
|
|
129
|
-
onEvent: (event: SseEvent) => void | Promise<void>
|
|
140
|
+
onEvent: (event: SseEvent) => boolean | void | Promise<boolean | void>
|
|
130
141
|
): Promise<void> {
|
|
131
142
|
if (!response.body) {
|
|
132
143
|
throw new Error("No response body");
|
|
@@ -137,59 +148,78 @@ async function readSseEvents(
|
|
|
137
148
|
let buffer = "";
|
|
138
149
|
let currentEventData = "";
|
|
139
150
|
let currentEventName = "";
|
|
151
|
+
let stopRequested = false;
|
|
152
|
+
let reachedEof = false;
|
|
140
153
|
|
|
141
|
-
const flushEvent = async () => {
|
|
142
|
-
if (!currentEventData) return;
|
|
154
|
+
const flushEvent = async (): Promise<boolean> => {
|
|
155
|
+
if (!currentEventData) return false;
|
|
143
156
|
const raw = currentEventData.trim();
|
|
144
157
|
currentEventData = "";
|
|
145
158
|
const eventName = currentEventName;
|
|
146
159
|
currentEventName = "";
|
|
147
|
-
if (!raw || raw === "[DONE]") return;
|
|
160
|
+
if (!raw || raw === "[DONE]") return false;
|
|
148
161
|
|
|
149
162
|
let data: any;
|
|
150
163
|
try {
|
|
151
164
|
data = JSON.parse(raw);
|
|
152
165
|
} catch {
|
|
153
|
-
return;
|
|
166
|
+
return false;
|
|
154
167
|
}
|
|
155
|
-
await onEvent({ event: eventName, data });
|
|
168
|
+
return await onEvent({ event: eventName, data }) === true;
|
|
156
169
|
};
|
|
157
170
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
171
|
+
try {
|
|
172
|
+
readLoop: while (true) {
|
|
173
|
+
if (signal?.aborted) {
|
|
174
|
+
throw new Error("Request was aborted");
|
|
175
|
+
}
|
|
162
176
|
|
|
163
|
-
|
|
164
|
-
|
|
177
|
+
const { done, value } = await reader.read();
|
|
178
|
+
if (done) {
|
|
179
|
+
reachedEof = true;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
165
182
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
}
|
|
169
195
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
+
}
|
|
174
202
|
}
|
|
203
|
+
}
|
|
175
204
|
|
|
205
|
+
if (!stopRequested && buffer.trim()) {
|
|
206
|
+
const line = buffer.trim();
|
|
176
207
|
if (line.startsWith("data:")) {
|
|
177
208
|
const data = line.slice(5).trim();
|
|
178
209
|
currentEventData = currentEventData ? currentEventData + "\n" + data : data;
|
|
179
|
-
} else if (line.startsWith("event:")) {
|
|
180
|
-
currentEventName = line.slice(6).trim();
|
|
181
210
|
}
|
|
182
211
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
}
|
|
190
220
|
}
|
|
221
|
+
reader.releaseLock();
|
|
191
222
|
}
|
|
192
|
-
await flushEvent();
|
|
193
223
|
}
|
|
194
224
|
|
|
195
225
|
function extractPromptFromGeminiBody(body: any): string {
|
|
@@ -210,6 +240,73 @@ function trimTrailingSlash(value: string): string {
|
|
|
210
240
|
return value.replace(/\/+$/, "");
|
|
211
241
|
}
|
|
212
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
|
+
|
|
213
310
|
function resolveAnthropicMessagesUrl(baseUrl: string): string {
|
|
214
311
|
const base = trimTrailingSlash(baseUrl);
|
|
215
312
|
return base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
|
|
@@ -459,7 +556,7 @@ function extractGoogleSearchDetails(groundingMetadata: any): { searchQueries: st
|
|
|
459
556
|
|
|
460
557
|
async function callGoogleStream(
|
|
461
558
|
ctx: ExtensionContext,
|
|
462
|
-
model: Model<
|
|
559
|
+
model: Model<Api>,
|
|
463
560
|
body: any,
|
|
464
561
|
onUpdate?: AgentToolUpdateCallback,
|
|
465
562
|
signal?: AbortSignal
|
|
@@ -551,7 +648,7 @@ async function callGoogleStream(
|
|
|
551
648
|
|
|
552
649
|
async function callOpenAIStream(
|
|
553
650
|
ctx: ExtensionContext,
|
|
554
|
-
model: Model<
|
|
651
|
+
model: Model<Api>,
|
|
555
652
|
prompt: string,
|
|
556
653
|
onUpdate?: AgentToolUpdateCallback,
|
|
557
654
|
signal?: AbortSignal
|
|
@@ -561,31 +658,56 @@ async function callOpenAIStream(
|
|
|
561
658
|
throw new Error(auth.error || "Failed to get API key and headers");
|
|
562
659
|
}
|
|
563
660
|
|
|
564
|
-
const headers
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
};
|
|
570
|
-
|
|
571
|
-
|
|
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");
|
|
572
682
|
}
|
|
683
|
+
const requestHeaders = Object.fromEntries(headers.entries());
|
|
573
684
|
|
|
574
685
|
const requestBody: any = {
|
|
575
686
|
model: model.id,
|
|
576
|
-
input:
|
|
687
|
+
input: isCodex
|
|
688
|
+
? [{ role: "user", content: [{ type: "input_text", text: prompt }] }]
|
|
689
|
+
: prompt,
|
|
577
690
|
tools: [{ type: "web_search" }],
|
|
578
|
-
include:
|
|
691
|
+
include: isCodex
|
|
692
|
+
? ["web_search_call.action.sources"]
|
|
693
|
+
: ["web_search_call.action.sources", "web_search_call.results"],
|
|
579
694
|
stream: true,
|
|
580
695
|
store: false,
|
|
581
696
|
};
|
|
582
697
|
if (model.reasoning) {
|
|
583
698
|
requestBody.reasoning = { effort: "none" };
|
|
584
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
|
+
}
|
|
585
706
|
|
|
586
|
-
const
|
|
707
|
+
const baseUrl = resolveGitHubCopilotBaseUrl(model, auth);
|
|
708
|
+
const response = await fetch(resolveOpenAIResponsesUrl(model, baseUrl), {
|
|
587
709
|
method: "POST",
|
|
588
|
-
headers,
|
|
710
|
+
headers: requestHeaders,
|
|
589
711
|
body: JSON.stringify(requestBody),
|
|
590
712
|
signal
|
|
591
713
|
});
|
|
@@ -649,7 +771,14 @@ async function callOpenAIStream(
|
|
|
649
771
|
raw: action,
|
|
650
772
|
});
|
|
651
773
|
}
|
|
652
|
-
nativeSearchCalls.
|
|
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
|
+
}
|
|
653
782
|
};
|
|
654
783
|
|
|
655
784
|
const collectFromResponse = (response: any) => {
|
|
@@ -664,7 +793,10 @@ async function callOpenAIStream(
|
|
|
664
793
|
};
|
|
665
794
|
|
|
666
795
|
await readSseEvents(response, signal, ({ data: event }) => {
|
|
667
|
-
if (event.type === "response.
|
|
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") {
|
|
668
800
|
accumulatedText += event.delta || "";
|
|
669
801
|
onUpdate?.({
|
|
670
802
|
content: [{ type: "text", text: accumulatedText }],
|
|
@@ -674,8 +806,12 @@ async function callOpenAIStream(
|
|
|
674
806
|
collectAnnotation(event.annotation);
|
|
675
807
|
} else if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
|
|
676
808
|
collectWebSearchCall(event.item);
|
|
677
|
-
} else if (event.type === "response.
|
|
809
|
+
} else if (event.type === "response.incomplete" || event.response?.status === "incomplete") {
|
|
678
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;
|
|
679
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") {
|
|
680
816
|
pushNativeSearchEvent(nativeSearchEvents, event.type);
|
|
681
817
|
const call = nativeSearchCalls.find((item) => item.id === event.item_id);
|
|
@@ -687,11 +823,6 @@ async function callOpenAIStream(
|
|
|
687
823
|
details: { streaming: true, searching: true }
|
|
688
824
|
});
|
|
689
825
|
}
|
|
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
826
|
}
|
|
696
827
|
});
|
|
697
828
|
|
|
@@ -725,7 +856,7 @@ async function callOpenAIStream(
|
|
|
725
856
|
|
|
726
857
|
async function callAnthropicStream(
|
|
727
858
|
ctx: ExtensionContext,
|
|
728
|
-
model: Model<
|
|
859
|
+
model: Model<Api>,
|
|
729
860
|
prompt: string,
|
|
730
861
|
onUpdate?: AgentToolUpdateCallback,
|
|
731
862
|
signal?: AbortSignal
|
|
@@ -891,7 +1022,7 @@ async function callAnthropicStream(
|
|
|
891
1022
|
|
|
892
1023
|
export async function callApiStream(
|
|
893
1024
|
ctx: ExtensionContext,
|
|
894
|
-
model: Model<
|
|
1025
|
+
model: Model<Api>,
|
|
895
1026
|
body: any,
|
|
896
1027
|
onUpdate?: AgentToolUpdateCallback,
|
|
897
1028
|
signal?: AbortSignal
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
4
|
import { getProviderKind } from "./api.ts";
|
|
4
5
|
import { webSearch, WebSearchSchema } from "./web_search.ts";
|
|
5
6
|
import { urlContext, UrlContextSchema } from "./url_context.ts";
|
|
@@ -7,7 +8,7 @@ import { urlContext, UrlContextSchema } from "./url_context.ts";
|
|
|
7
8
|
const WEB_SEARCH_TOOL = "web_search";
|
|
8
9
|
const URL_CONTEXT_TOOL = "url_context";
|
|
9
10
|
|
|
10
|
-
function supportsUrlContext(model: Model<
|
|
11
|
+
function supportsUrlContext(model: Model<Api> | undefined) {
|
|
11
12
|
return !!model && getProviderKind(model) === "google";
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -24,7 +25,7 @@ export function createModelScopedToolManager(pi: Pick<ExtensionAPI, "getActiveTo
|
|
|
24
25
|
let lastAppliedActiveTools: Set<string> | undefined;
|
|
25
26
|
let suppressedTools = new Set<string>();
|
|
26
27
|
|
|
27
|
-
const sync = (model: Model<
|
|
28
|
+
const sync = (model: Model<Api> | undefined) => {
|
|
28
29
|
const currentActiveTools = new Set(pi.getActiveTools());
|
|
29
30
|
|
|
30
31
|
if (!preferredActiveTools) {
|
|
@@ -64,7 +65,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
64
65
|
label: "Web Search",
|
|
65
66
|
description: "Search the web using the current supported provider (Google Gemini, OpenAI, or Anthropic). Optionally include URLs to analyze alongside search results.",
|
|
66
67
|
parameters: WebSearchSchema,
|
|
67
|
-
execute: webSearch
|
|
68
|
+
execute: webSearch,
|
|
69
|
+
renderCall(args, theme) {
|
|
70
|
+
const query = args.query || "…";
|
|
71
|
+
const urlCount = args.urls?.length ?? 0;
|
|
72
|
+
const urls = urlCount > 0 ? theme.fg("muted", ` + ${urlCount} URL${urlCount === 1 ? "" : "s"}`) : "";
|
|
73
|
+
return new Text(
|
|
74
|
+
`${theme.fg("toolTitle", theme.bold("web_search"))} ${theme.fg("accent", query)}${urls}`,
|
|
75
|
+
0,
|
|
76
|
+
0,
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
renderResult(result, { expanded }, theme) {
|
|
80
|
+
const output = result.content
|
|
81
|
+
.filter((part) => part.type === "text")
|
|
82
|
+
.map((part) => part.text)
|
|
83
|
+
.join("\n");
|
|
84
|
+
|
|
85
|
+
const isError = Boolean(result.details?.error);
|
|
86
|
+
if (!expanded && !isError) return new Text("", 0, 0);
|
|
87
|
+
|
|
88
|
+
return new Text(theme.fg(isError ? "error" : "toolOutput", output), 0, 0);
|
|
89
|
+
}
|
|
68
90
|
});
|
|
69
91
|
|
|
70
92
|
pi.registerTool({
|
package/src/utils.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext, AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
3
|
import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
5
|
import { homedir } from "node:os";
|
|
@@ -18,7 +18,7 @@ export function formatResult(text: string, details: any): AgentToolResult<any> {
|
|
|
18
18
|
|
|
19
19
|
// --- Model Selection ---
|
|
20
20
|
|
|
21
|
-
const SUPPORTED_PROVIDERS = ["google-generative-ai", "openai-responses", "anthropic-messages"];
|
|
21
|
+
const SUPPORTED_PROVIDERS = ["google-generative-ai", "openai-responses", "azure-openai-responses", "openai-codex-responses", "anthropic-messages"];
|
|
22
22
|
const WEB_SEARCH_CONFIG_PATH = join(homedir(), ".pi", "agent", "web-search.json");
|
|
23
23
|
|
|
24
24
|
type WebSearchModelConfig =
|
|
@@ -26,12 +26,12 @@ type WebSearchModelConfig =
|
|
|
26
26
|
| { status: "configured"; path: string; provider: string; modelId: string; }
|
|
27
27
|
| { status: "invalid"; path: string; error: string; };
|
|
28
28
|
|
|
29
|
-
function isSupportedSearchModel(model: Model<
|
|
29
|
+
function isSupportedSearchModel(model: Model<Api> | undefined): model is Model<Api> {
|
|
30
30
|
if (!model) return false;
|
|
31
31
|
return getProviderKind(model) !== "unsupported";
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
function describeModel(model: Model<
|
|
34
|
+
function describeModel(model: Model<Api>): string {
|
|
35
35
|
return `${model.id} (${model.provider}/${model.api})`;
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -82,13 +82,13 @@ function getAvailableSupportedModels(ctx: ExtensionContext): string[] {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
export async function getModel(ctx: ExtensionContext): Promise<Model<
|
|
85
|
+
export async function getModel(ctx: ExtensionContext): Promise<Model<Api> | undefined> {
|
|
86
86
|
// Only use the currently selected model. Do not silently fall back to another
|
|
87
87
|
// configured model, because that can surprise users with unexpected API costs.
|
|
88
88
|
return isSupportedSearchModel(ctx.model) ? ctx.model : undefined;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
export async function getWebSearchModel(ctx: ExtensionContext): Promise<Model<
|
|
91
|
+
export async function getWebSearchModel(ctx: ExtensionContext): Promise<Model<Api> | undefined> {
|
|
92
92
|
const config = readWebSearchModelConfig();
|
|
93
93
|
if (config.status === "invalid") return undefined;
|
|
94
94
|
|