pi-web-search 1.3.0 → 1.3.1

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
@@ -12,6 +12,7 @@ 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
+ | OpenAI Codex | Codex Responses API web search |
15
16
  | Anthropic | Messages API web search |
16
17
 
17
18
  Supports passing up to 20 additional URLs to analyze alongside the query.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-search",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Provider-native web search for pi across Google Gemini, OpenAI, and Anthropic, plus Gemini URL Context",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -37,8 +37,8 @@
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
42
  "@types/node": "^25.9.1",
43
43
  "typebox": "^1.1.38",
44
44
  "typescript": "^6.0.3"
@@ -49,8 +49,8 @@
49
49
  ]
50
50
  },
51
51
  "peerDependencies": {
52
- "@earendil-works/pi-ai": "*",
53
- "@earendil-works/pi-coding-agent": "*",
52
+ "@earendil-works/pi-ai": ">=0.80.3",
53
+ "@earendil-works/pi-coding-agent": ">=0.80.3",
54
54
  "typebox": "*"
55
55
  }
56
56
  }
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,14 @@ 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 (model.api === "openai-responses" || model.api === "openai-codex-responses") return "openai";
37
38
  if (model.api === "anthropic-messages") return "anthropic";
38
39
  return "unsupported";
39
40
  }
40
41
 
41
- export function getConfig(model: Model<any>): ProviderConfig {
42
+ export function getConfig(model: Model<Api>): ProviderConfig {
42
43
  const googleConfig = GOOGLE_PROVIDERS[model.provider] || GOOGLE_PROVIDERS[model.api];
43
44
  if (googleConfig) return googleConfig;
44
45
  const kind = getProviderKind(model);
@@ -51,28 +52,34 @@ type ResolvedAuth =
51
52
  | { ok: true; apiKey?: string; headers?: Record<string, string>; }
52
53
  | { ok: false; error: string; };
53
54
 
55
+ function getEnvAuth(model: Model<Api>): Extract<ResolvedAuth, { ok: true }> | undefined {
56
+ const apiKey = getEnvApiKey(model.provider);
57
+ return apiKey ? { ok: true, apiKey } : undefined;
58
+ }
59
+
54
60
  /**
55
61
  * Get API key and headers for a model.
56
- * Compatible with both new pi versions (getApiKeyAndHeaders) and old versions (getApiKey).
57
62
  */
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" };
63
+ async function getAuth(ctx: ExtensionContext, model: Model<Api>): Promise<ResolvedAuth> {
64
+ const resolved = await ctx.modelRegistry.getApiKeyAndHeaders(model);
65
+ if (!resolved.ok) return resolved;
66
+
67
+ // pi-coding-agent 0.80.1+ returns { ok: true } from getApiKeyAndHeaders()
68
+ // when auth only comes from provider env vars such as ANTHROPIC_API_KEY.
69
+ // The main agent still works because pi-ai's streamSimple() performs its own
70
+ // getEnvApiKey() fallback, but this extension calls fetch() directly, so it
71
+ // must mirror that fallback while preserving explicit model/auth headers.
72
+ const envAuth = !resolved.apiKey && !hasAuthHeader(resolved.headers) ? getEnvAuth(model) : undefined;
73
+ return envAuth ? { ...resolved, apiKey: envAuth.apiKey } : resolved;
74
+ }
75
+
76
+ function hasAuthHeader(headers?: Record<string, string>): boolean {
77
+ if (!headers) return false;
78
+ return Object.entries(headers).some(([name, value]) => {
79
+ if (!value) return false;
80
+ const normalized = name.toLowerCase();
81
+ return normalized === "authorization" || normalized === "x-api-key" || normalized === "x-goog-api-key";
82
+ });
76
83
  }
77
84
 
78
85
  // --- Streaming API Call ---
@@ -126,7 +133,7 @@ type SseEvent = {
126
133
  async function readSseEvents(
127
134
  response: Response,
128
135
  signal: AbortSignal | undefined,
129
- onEvent: (event: SseEvent) => void | Promise<void>
136
+ onEvent: (event: SseEvent) => boolean | void | Promise<boolean | void>
130
137
  ): Promise<void> {
131
138
  if (!response.body) {
132
139
  throw new Error("No response body");
@@ -137,59 +144,78 @@ async function readSseEvents(
137
144
  let buffer = "";
138
145
  let currentEventData = "";
139
146
  let currentEventName = "";
147
+ let stopRequested = false;
148
+ let reachedEof = false;
140
149
 
141
- const flushEvent = async () => {
142
- if (!currentEventData) return;
150
+ const flushEvent = async (): Promise<boolean> => {
151
+ if (!currentEventData) return false;
143
152
  const raw = currentEventData.trim();
144
153
  currentEventData = "";
145
154
  const eventName = currentEventName;
146
155
  currentEventName = "";
147
- if (!raw || raw === "[DONE]") return;
156
+ if (!raw || raw === "[DONE]") return false;
148
157
 
149
158
  let data: any;
150
159
  try {
151
160
  data = JSON.parse(raw);
152
161
  } catch {
153
- return;
162
+ return false;
154
163
  }
155
- await onEvent({ event: eventName, data });
164
+ return await onEvent({ event: eventName, data }) === true;
156
165
  };
157
166
 
158
- while (true) {
159
- if (signal?.aborted) {
160
- throw new Error("Request was aborted");
161
- }
167
+ try {
168
+ readLoop: while (true) {
169
+ if (signal?.aborted) {
170
+ throw new Error("Request was aborted");
171
+ }
162
172
 
163
- const { done, value } = await reader.read();
164
- if (done) break;
173
+ const { done, value } = await reader.read();
174
+ if (done) {
175
+ reachedEof = true;
176
+ break;
177
+ }
165
178
 
166
- buffer += decoder.decode(value, { stream: true });
167
- const lines = buffer.split("\n");
168
- buffer = lines.pop() || "";
179
+ buffer += decoder.decode(value, { stream: true });
180
+ const lines = buffer.split("\n");
181
+ buffer = lines.pop() || "";
182
+
183
+ for (const line of lines) {
184
+ if (line === "" || line === "\r") {
185
+ if (await flushEvent()) {
186
+ stopRequested = true;
187
+ break readLoop;
188
+ }
189
+ continue;
190
+ }
169
191
 
170
- for (const line of lines) {
171
- if (line === "" || line === "\r") {
172
- await flushEvent();
173
- continue;
192
+ if (line.startsWith("data:")) {
193
+ const data = line.slice(5).trim();
194
+ currentEventData = currentEventData ? currentEventData + "\n" + data : data;
195
+ } else if (line.startsWith("event:")) {
196
+ currentEventName = line.slice(6).trim();
197
+ }
174
198
  }
199
+ }
175
200
 
201
+ if (!stopRequested && buffer.trim()) {
202
+ const line = buffer.trim();
176
203
  if (line.startsWith("data:")) {
177
204
  const data = line.slice(5).trim();
178
205
  currentEventData = currentEventData ? currentEventData + "\n" + data : data;
179
- } else if (line.startsWith("event:")) {
180
- currentEventName = line.slice(6).trim();
181
206
  }
182
207
  }
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;
208
+ if (!stopRequested) stopRequested = await flushEvent();
209
+ } finally {
210
+ if (!reachedEof) {
211
+ try {
212
+ await reader.cancel();
213
+ } catch {
214
+ // Ignore cancellation failures while cleaning up an interrupted stream.
215
+ }
190
216
  }
217
+ reader.releaseLock();
191
218
  }
192
- await flushEvent();
193
219
  }
194
220
 
195
221
  function extractPromptFromGeminiBody(body: any): string {
@@ -210,6 +236,34 @@ function trimTrailingSlash(value: string): string {
210
236
  return value.replace(/\/+$/, "");
211
237
  }
212
238
 
239
+ function isOpenAICodexModel(model: Model<Api>): boolean {
240
+ return model.api === "openai-codex-responses";
241
+ }
242
+
243
+ function resolveOpenAIResponsesUrl(model: Model<Api>): string {
244
+ const base = trimTrailingSlash(model.baseUrl);
245
+ if (!isOpenAICodexModel(model)) return `${base}/responses`;
246
+ if (base.endsWith("/codex/responses")) return base;
247
+ if (base.endsWith("/codex")) return `${base}/responses`;
248
+ return `${base}/codex/responses`;
249
+ }
250
+
251
+ function extractOpenAICodexAccountId(token: string): string {
252
+ try {
253
+ const parts = token.split(".");
254
+ if (parts.length !== 3) throw new Error("Invalid token");
255
+ const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
256
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
257
+ const bytes = Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
258
+ const payload = JSON.parse(new TextDecoder().decode(bytes));
259
+ const accountId = payload?.["https://api.openai.com/auth"]?.chatgpt_account_id;
260
+ if (typeof accountId !== "string" || !accountId) throw new Error("Missing account ID");
261
+ return accountId;
262
+ } catch {
263
+ throw new Error("Failed to extract ChatGPT account ID from openai-codex credentials");
264
+ }
265
+ }
266
+
213
267
  function resolveAnthropicMessagesUrl(baseUrl: string): string {
214
268
  const base = trimTrailingSlash(baseUrl);
215
269
  return base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
@@ -459,7 +513,7 @@ function extractGoogleSearchDetails(groundingMetadata: any): { searchQueries: st
459
513
 
460
514
  async function callGoogleStream(
461
515
  ctx: ExtensionContext,
462
- model: Model<any>,
516
+ model: Model<Api>,
463
517
  body: any,
464
518
  onUpdate?: AgentToolUpdateCallback,
465
519
  signal?: AbortSignal
@@ -551,7 +605,7 @@ async function callGoogleStream(
551
605
 
552
606
  async function callOpenAIStream(
553
607
  ctx: ExtensionContext,
554
- model: Model<any>,
608
+ model: Model<Api>,
555
609
  prompt: string,
556
610
  onUpdate?: AgentToolUpdateCallback,
557
611
  signal?: AbortSignal
@@ -561,31 +615,55 @@ async function callOpenAIStream(
561
615
  throw new Error(auth.error || "Failed to get API key and headers");
562
616
  }
563
617
 
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}`;
618
+ const headers = new Headers();
619
+ for (const [name, value] of Object.entries(model.headers || {})) headers.set(name, value);
620
+ for (const [name, value] of Object.entries(auth.headers || {})) headers.set(name, value);
621
+ if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
622
+ if (!headers.has("Accept")) headers.set("Accept", "text/event-stream");
623
+ if (auth.apiKey && !headers.has("Authorization")) headers.set("Authorization", `Bearer ${auth.apiKey}`);
624
+
625
+ const isCodex = isOpenAICodexModel(model);
626
+ if (isCodex) {
627
+ const authorization = headers.get("Authorization");
628
+ const hasBearerAuth = typeof authorization === "string" && /^Bearer\s+\S+/i.test(authorization);
629
+ if (!auth.apiKey && !hasBearerAuth) {
630
+ throw new Error("No OAuth credential configured for openai-codex model");
631
+ }
632
+ if (!headers.has("chatgpt-account-id")) {
633
+ if (!auth.apiKey) {
634
+ throw new Error("No ChatGPT account ID configured for openai-codex model");
635
+ }
636
+ headers.set("chatgpt-account-id", extractOpenAICodexAccountId(auth.apiKey));
637
+ }
638
+ if (!headers.has("originator")) headers.set("originator", "codex_cli_rs");
572
639
  }
640
+ const requestHeaders = Object.fromEntries(headers.entries());
573
641
 
574
642
  const requestBody: any = {
575
643
  model: model.id,
576
- input: prompt,
644
+ input: isCodex
645
+ ? [{ role: "user", content: [{ type: "input_text", text: prompt }] }]
646
+ : prompt,
577
647
  tools: [{ type: "web_search" }],
578
- include: ["web_search_call.action.sources", "web_search_call.results"],
648
+ include: isCodex
649
+ ? ["web_search_call.action.sources"]
650
+ : ["web_search_call.action.sources", "web_search_call.results"],
579
651
  stream: true,
580
652
  store: false,
581
653
  };
582
654
  if (model.reasoning) {
583
655
  requestBody.reasoning = { effort: "none" };
584
656
  }
657
+ if (isCodex) {
658
+ requestBody.instructions = "Answer the user's request using web search when needed.";
659
+ requestBody.text = { verbosity: "low" };
660
+ requestBody.tool_choice = "required";
661
+ requestBody.parallel_tool_calls = true;
662
+ }
585
663
 
586
- const response = await fetch(`${trimTrailingSlash(model.baseUrl)}/responses`, {
664
+ const response = await fetch(resolveOpenAIResponsesUrl(model), {
587
665
  method: "POST",
588
- headers,
666
+ headers: requestHeaders,
589
667
  body: JSON.stringify(requestBody),
590
668
  signal
591
669
  });
@@ -649,7 +727,14 @@ async function callOpenAIStream(
649
727
  raw: action,
650
728
  });
651
729
  }
652
- nativeSearchCalls.push(call);
730
+ const existingCall = call.id ? nativeSearchCalls.find((existing) => existing.id === call.id) : undefined;
731
+ if (existingCall) {
732
+ Object.assign(existingCall, Object.fromEntries(
733
+ Object.entries(call).filter(([, value]) => value !== undefined)
734
+ ));
735
+ } else {
736
+ nativeSearchCalls.push(call);
737
+ }
653
738
  };
654
739
 
655
740
  const collectFromResponse = (response: any) => {
@@ -664,7 +749,10 @@ async function callOpenAIStream(
664
749
  };
665
750
 
666
751
  await readSseEvents(response, signal, ({ data: event }) => {
667
- if (event.type === "response.output_text.delta") {
752
+ if (event.type === "error" || event.type === "response.failed") {
753
+ const message = event.message || event.error?.message || event.response?.error?.message;
754
+ throw new Error(message || JSON.stringify(event.error || event.response?.error || event));
755
+ } else if (event.type === "response.output_text.delta") {
668
756
  accumulatedText += event.delta || "";
669
757
  onUpdate?.({
670
758
  content: [{ type: "text", text: accumulatedText }],
@@ -674,8 +762,12 @@ async function callOpenAIStream(
674
762
  collectAnnotation(event.annotation);
675
763
  } else if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
676
764
  collectWebSearchCall(event.item);
677
- } else if (event.type === "response.completed") {
765
+ } else if (event.type === "response.incomplete" || event.response?.status === "incomplete") {
678
766
  collectFromResponse(event.response);
767
+ if (isCodex) return true;
768
+ } else if (event.type === "response.completed" || event.type === "response.done") {
769
+ collectFromResponse(event.response);
770
+ if (isCodex) return true;
679
771
  } 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
772
  pushNativeSearchEvent(nativeSearchEvents, event.type);
681
773
  const call = nativeSearchCalls.find((item) => item.id === event.item_id);
@@ -687,11 +779,6 @@ async function callOpenAIStream(
687
779
  details: { streaming: true, searching: true }
688
780
  });
689
781
  }
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
782
  }
696
783
  });
697
784
 
@@ -725,7 +812,7 @@ async function callOpenAIStream(
725
812
 
726
813
  async function callAnthropicStream(
727
814
  ctx: ExtensionContext,
728
- model: Model<any>,
815
+ model: Model<Api>,
729
816
  prompt: string,
730
817
  onUpdate?: AgentToolUpdateCallback,
731
818
  signal?: AbortSignal
@@ -891,7 +978,7 @@ async function callAnthropicStream(
891
978
 
892
979
  export async function callApiStream(
893
980
  ctx: ExtensionContext,
894
- model: Model<any>,
981
+ model: Model<Api>,
895
982
  body: any,
896
983
  onUpdate?: AgentToolUpdateCallback,
897
984
  signal?: AbortSignal
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
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
3
  import { getProviderKind } from "./api.ts";
4
4
  import { webSearch, WebSearchSchema } from "./web_search.ts";
5
5
  import { urlContext, UrlContextSchema } from "./url_context.ts";
@@ -7,7 +7,7 @@ import { urlContext, UrlContextSchema } from "./url_context.ts";
7
7
  const WEB_SEARCH_TOOL = "web_search";
8
8
  const URL_CONTEXT_TOOL = "url_context";
9
9
 
10
- function supportsUrlContext(model: Model<any> | undefined) {
10
+ function supportsUrlContext(model: Model<Api> | undefined) {
11
11
  return !!model && getProviderKind(model) === "google";
12
12
  }
13
13
 
@@ -24,7 +24,7 @@ export function createModelScopedToolManager(pi: Pick<ExtensionAPI, "getActiveTo
24
24
  let lastAppliedActiveTools: Set<string> | undefined;
25
25
  let suppressedTools = new Set<string>();
26
26
 
27
- const sync = (model: Model<any> | undefined) => {
27
+ const sync = (model: Model<Api> | undefined) => {
28
28
  const currentActiveTools = new Set(pi.getActiveTools());
29
29
 
30
30
  if (!preferredActiveTools) {
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", "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