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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-web-search
2
2
 
3
- Provider-native web search for [pi](https://github.com/earendil-works/pi-coding-agent), across Google Gemini, OpenAI, and Anthropic, plus Gemini-only URL Context analysis.
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
- Supports passing up to 20 additional URLs to analyze alongside the query.
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.3.0",
4
- "description": "Provider-native web search for pi across Google Gemini, OpenAI, and Anthropic, plus Gemini URL Context",
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.75.4",
41
- "@earendil-works/pi-coding-agent": "^0.75.4",
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 { type Model } from "@earendil-works/pi-ai";
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<any>, body: any) => { url: string; headers: Record<string, string>; body: any };
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<any>): ProviderKind {
35
+ export function getProviderKind(model: Model<Api>): ProviderKind {
35
36
  if (GOOGLE_PROVIDERS[model.provider] || GOOGLE_PROVIDERS[model.api]) return "google";
36
- if (model.api === "openai-responses") return "openai";
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<any>): ProviderConfig {
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<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
+ 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
- while (true) {
159
- if (signal?.aborted) {
160
- throw new Error("Request was aborted");
161
- }
171
+ try {
172
+ readLoop: while (true) {
173
+ if (signal?.aborted) {
174
+ throw new Error("Request was aborted");
175
+ }
162
176
 
163
- const { done, value } = await reader.read();
164
- if (done) break;
177
+ const { done, value } = await reader.read();
178
+ if (done) {
179
+ reachedEof = true;
180
+ break;
181
+ }
165
182
 
166
- buffer += decoder.decode(value, { stream: true });
167
- const lines = buffer.split("\n");
168
- buffer = lines.pop() || "";
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
- for (const line of lines) {
171
- if (line === "" || line === "\r") {
172
- await flushEvent();
173
- continue;
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
- 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;
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<any>,
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<any>,
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: 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}`;
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: prompt,
687
+ input: isCodex
688
+ ? [{ role: "user", content: [{ type: "input_text", text: prompt }] }]
689
+ : prompt,
577
690
  tools: [{ type: "web_search" }],
578
- include: ["web_search_call.action.sources", "web_search_call.results"],
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 response = await fetch(`${trimTrailingSlash(model.baseUrl)}/responses`, {
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.push(call);
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.output_text.delta") {
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.completed") {
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<any>,
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<any>,
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 { type Model } from "@earendil-works/pi-ai";
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<any> | undefined) {
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<any> | undefined) => {
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 { type Model } from "@earendil-works/pi-ai";
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<any> | undefined): model is Model<any> {
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<any>): string {
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<any> | undefined> {
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<any> | undefined> {
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