pi-web-search 1.3.1 → 1.5.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, xAI Grok, OpenAI Responses variants, and Anthropic.
4
4
 
5
5
  ## Tools
6
6
 
@@ -11,11 +11,16 @@ Search the web using your currently selected model. Automatically picks the righ
11
11
  | Provider | API |
12
12
  |---|---|
13
13
  | Google Gemini | Grounding with Google Search |
14
+ | xAI Grok | Responses API `web_search` |
14
15
  | OpenAI | Responses API web search |
15
- | OpenAI Codex | Codex Responses API web search |
16
+ | Azure OpenAI | Responses API web search (`azure-openai-responses`) |
17
+ | OpenAI Codex | Codex Responses API web search (`openai-codex-responses`) |
18
+ | GitHub Copilot | OpenAI Responses API web search via Copilot credentials |
16
19
  | Anthropic | Messages API web search |
17
20
 
18
- Supports passing up to 20 additional URLs to analyze alongside the query.
21
+ 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`.
22
+
23
+ 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.
19
24
 
20
25
  ### `url_context`
21
26
 
@@ -33,7 +38,7 @@ pi install npm:pi-web-search
33
38
 
34
39
  No extra config needed. Select a supported current model in pi and the tools auto-detect the matching provider API.
35
40
 
36
- `web_search` will not scan configured models and pick one automatically when the current model does not support native search. To use a dedicated search model, opt in explicitly with `~/.pi/agent/web-search.json`:
41
+ `web_search` will not scan configured models and pick one automatically when the current model does not support native search. To use a dedicated search model, opt in explicitly with `web-search.json` in pi's agent directory (by default `~/.pi/agent/`; respects `PI_CODING_AGENT_DIR`):
37
42
 
38
43
  ```json
39
44
  {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-web-search",
3
- "version": "1.3.1",
4
- "description": "Provider-native web search for pi across Google Gemini, OpenAI, and Anthropic, plus Gemini URL Context",
3
+ "version": "1.5.0",
4
+ "description": "Provider-native web search for pi: Gemini + URL Context, xAI Grok, OpenAI Responses (Azure/Codex/Copilot), and Anthropic",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "keywords": [
@@ -11,6 +11,8 @@
11
11
  "web-search",
12
12
  "gemini",
13
13
  "google",
14
+ "xai",
15
+ "grok",
14
16
  "openai",
15
17
  "anthropic",
16
18
  "url-context",
@@ -39,6 +41,7 @@
39
41
  "devDependencies": {
40
42
  "@earendil-works/pi-ai": "^0.80.3",
41
43
  "@earendil-works/pi-coding-agent": "^0.80.3",
44
+ "@earendil-works/pi-tui": "^0.80.3",
42
45
  "@types/node": "^25.9.1",
43
46
  "typebox": "^1.1.38",
44
47
  "typescript": "^6.0.3"
@@ -51,6 +54,7 @@
51
54
  "peerDependencies": {
52
55
  "@earendil-works/pi-ai": ">=0.80.3",
53
56
  "@earendil-works/pi-coding-agent": ">=0.80.3",
57
+ "@earendil-works/pi-tui": "*",
54
58
  "typebox": "*"
55
59
  }
56
60
  }
package/src/api.ts CHANGED
@@ -5,7 +5,7 @@ import { TextEncoder, TextDecoder } from "util";
5
5
 
6
6
  // --- Provider Configuration ---
7
7
 
8
- type ProviderKind = "google" | "openai" | "anthropic" | "unsupported";
8
+ type ProviderKind = "google" | "openai" | "xai" | "anthropic" | "unsupported";
9
9
 
10
10
  type GoogleRequestBuilder = (model: Model<Api>, body: any) => { url: string; headers: Record<string, string>; body: any };
11
11
 
@@ -34,7 +34,12 @@ const GOOGLE_PROVIDERS: Record<string, ProviderConfig> = {
34
34
 
35
35
  export function getProviderKind(model: Model<Api>): ProviderKind {
36
36
  if (GOOGLE_PROVIDERS[model.provider] || GOOGLE_PROVIDERS[model.api]) return "google";
37
- if (model.api === "openai-responses" || model.api === "openai-codex-responses") return "openai";
37
+ if (model.provider === "xai" && model.api === "openai-responses") return "xai";
38
+ if (
39
+ model.api === "openai-responses"
40
+ || model.api === "azure-openai-responses"
41
+ || model.api === "openai-codex-responses"
42
+ ) return "openai";
38
43
  if (model.api === "anthropic-messages") return "anthropic";
39
44
  return "unsupported";
40
45
  }
@@ -49,7 +54,7 @@ export function getConfig(model: Model<Api>): ProviderConfig {
49
54
  // --- Auth Compatibility Layer ---
50
55
 
51
56
  type ResolvedAuth =
52
- | { ok: true; apiKey?: string; headers?: Record<string, string>; }
57
+ | { ok: true; apiKey?: string; headers?: Record<string, string>; baseUrl?: string; }
53
58
  | { ok: false; error: string; };
54
59
 
55
60
  function getEnvAuth(model: Model<Api>): Extract<ResolvedAuth, { ok: true }> | undefined {
@@ -240,8 +245,47 @@ function isOpenAICodexModel(model: Model<Api>): boolean {
240
245
  return model.api === "openai-codex-responses";
241
246
  }
242
247
 
243
- function resolveOpenAIResponsesUrl(model: Model<Api>): string {
244
- const base = trimTrailingSlash(model.baseUrl);
248
+ function resolveGitHubCopilotBaseUrl(
249
+ model: Model<Api>,
250
+ auth: Extract<ResolvedAuth, { ok: true }>,
251
+ ): string {
252
+ if (model.provider !== "github-copilot") return model.baseUrl;
253
+
254
+ // Modern pi versions expose the credential-specific Copilot endpoint
255
+ // resolved by the provider. Prefer it so GitHub Enterprise Server and any
256
+ // future provider-owned routing continue to work without token parsing here.
257
+ if (typeof auth.baseUrl === "string" && auth.baseUrl.trim()) {
258
+ return auth.baseUrl;
259
+ }
260
+
261
+ // Compatibility fallback for older pi versions whose extension auth API
262
+ // returned the Copilot token but not its resolved base URL.
263
+ if (!auth.apiKey) return model.baseUrl;
264
+ const proxyEndpoints = auth.apiKey
265
+ .split(";")
266
+ .filter((field) => field.startsWith("proxy-ep="))
267
+ .map((field) => field.slice("proxy-ep=".length));
268
+ if (proxyEndpoints.length !== 1) return model.baseUrl;
269
+
270
+ const proxyHost = proxyEndpoints[0].toLowerCase();
271
+ const labels = proxyHost.split(".");
272
+ const isValidLabel = (label: string) =>
273
+ label.length > 0
274
+ && label.length <= 63
275
+ && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label);
276
+ const isCopilotProxyHost = proxyHost.length <= 253
277
+ && labels.length >= 4
278
+ && labels[0] === "proxy"
279
+ && labels.at(-2) === "githubcopilot"
280
+ && labels.at(-1) === "com"
281
+ && labels.every(isValidLabel);
282
+ if (!isCopilotProxyHost) return model.baseUrl;
283
+
284
+ return `https://api.${labels.slice(1).join(".")}`;
285
+ }
286
+
287
+ function resolveOpenAIResponsesUrl(model: Model<Api>, baseUrl = model.baseUrl): string {
288
+ const base = trimTrailingSlash(baseUrl);
245
289
  if (!isOpenAICodexModel(model)) return `${base}/responses`;
246
290
  if (base.endsWith("/codex/responses")) return base;
247
291
  if (base.endsWith("/codex")) return `${base}/responses`;
@@ -421,6 +465,14 @@ async function resolveGoogleGroundingRedirectUrls(searchResults: SearchResultDet
421
465
  }
422
466
  }
423
467
 
468
+ function preserveInlineCitations(text: string, citations: Array<{ endIndex?: number; title: string; url: string }>): { text: string; sources: Source[] } {
469
+ const sources: Source[] = [];
470
+ for (const citation of citations) {
471
+ if (citation.url) pushUniqueSource(sources, { title: citation.title, url: citation.url });
472
+ }
473
+ return { text, sources };
474
+ }
475
+
424
476
  function applyIndexCitations(text: string, citations: Array<{ endIndex?: number; title: string; url: string }>): { text: string; sources: Source[] } {
425
477
  const sources: Source[] = [];
426
478
  const insertions = citations
@@ -639,21 +691,25 @@ async function callOpenAIStream(
639
691
  }
640
692
  const requestHeaders = Object.fromEntries(headers.entries());
641
693
 
694
+ const isXai = getProviderKind(model) === "xai";
695
+ const searchProvider: ProviderKind = isXai ? "xai" : "openai";
696
+ const providerSourceName = isXai ? "xai" : "openai";
642
697
  const requestBody: any = {
643
698
  model: model.id,
644
699
  input: isCodex
645
700
  ? [{ role: "user", content: [{ type: "input_text", text: prompt }] }]
646
- : prompt,
701
+ : isXai
702
+ ? [{ role: "user", content: prompt }]
703
+ : prompt,
647
704
  tools: [{ type: "web_search" }],
648
- include: isCodex
649
- ? ["web_search_call.action.sources"]
650
- : ["web_search_call.action.sources", "web_search_call.results"],
705
+ ...(isCodex || isXai
706
+ ? { include: ["web_search_call.action.sources"] }
707
+ : { include: ["web_search_call.action.sources", "web_search_call.results"] }),
651
708
  stream: true,
652
709
  store: false,
653
710
  };
654
- if (model.reasoning) {
655
- requestBody.reasoning = { effort: "none" };
656
- }
711
+ // Let the provider choose its supported reasoning default. Some reasoning
712
+ // models reject "none", and supported effort levels vary between models.
657
713
  if (isCodex) {
658
714
  requestBody.instructions = "Answer the user's request using web search when needed.";
659
715
  requestBody.text = { verbosity: "low" };
@@ -661,7 +717,8 @@ async function callOpenAIStream(
661
717
  requestBody.parallel_tool_calls = true;
662
718
  }
663
719
 
664
- const response = await fetch(resolveOpenAIResponsesUrl(model), {
720
+ const baseUrl = resolveGitHubCopilotBaseUrl(model, auth);
721
+ const response = await fetch(resolveOpenAIResponsesUrl(model, baseUrl), {
665
722
  method: "POST",
666
723
  headers: requestHeaders,
667
724
  body: JSON.stringify(requestBody),
@@ -669,7 +726,7 @@ async function callOpenAIStream(
669
726
  });
670
727
 
671
728
  if (!response.ok) {
672
- throw new Error(`OpenAI API error (${response.status}): ${await response.text()}`);
729
+ throw new Error(`${isXai ? "xAI" : "OpenAI"} API error (${response.status}): ${await response.text()}`);
673
730
  }
674
731
 
675
732
  let accumulatedText = "";
@@ -691,7 +748,7 @@ async function callOpenAIStream(
691
748
  const action = item.action || {};
692
749
  const call: NativeSearchCallDetail = {
693
750
  id: item.id,
694
- provider: "openai",
751
+ provider: searchProvider,
695
752
  status: item.status,
696
753
  actionType: action.type,
697
754
  raw: item,
@@ -711,7 +768,7 @@ async function callOpenAIStream(
711
768
  pushUniqueSearchResult(searchResults, {
712
769
  title: source.title || source.display_name || source.name || titleFromUrl(source.url),
713
770
  url: source.url,
714
- source: "openai.web_search_call.action.sources",
771
+ source: `${providerSourceName}.web_search_call.action.sources`,
715
772
  type: source.type || "url",
716
773
  raw: source,
717
774
  });
@@ -722,7 +779,7 @@ async function callOpenAIStream(
722
779
  pushUniqueSearchResult(searchResults, {
723
780
  title: titleFromUrl(action.url),
724
781
  url: action.url,
725
- source: `openai.web_search_call.action.${action.type}`,
782
+ source: `${providerSourceName}.web_search_call.action.${action.type}`,
726
783
  type: action.type,
727
784
  raw: action,
728
785
  });
@@ -772,21 +829,23 @@ async function callOpenAIStream(
772
829
  pushNativeSearchEvent(nativeSearchEvents, event.type);
773
830
  const call = nativeSearchCalls.find((item) => item.id === event.item_id);
774
831
  if (call) call.status = event.type.replace("response.web_search_call.", "");
775
- else nativeSearchCalls.push({ id: event.item_id, provider: "openai", status: event.type.replace("response.web_search_call.", ""), raw: event });
832
+ else nativeSearchCalls.push({ id: event.item_id, provider: searchProvider, status: event.type.replace("response.web_search_call.", ""), raw: event });
776
833
  if (event.type === "response.web_search_call.searching") {
777
834
  onUpdate?.({
778
- content: [{ type: "text", text: accumulatedText || "Searching the web with OpenAI..." }],
835
+ content: [{ type: "text", text: accumulatedText || `Searching the web with ${isXai ? "xAI" : "OpenAI"}...` }],
779
836
  details: { streaming: true, searching: true }
780
837
  });
781
838
  }
782
839
  }
783
840
  });
784
841
 
785
- const cited = applyIndexCitations(accumulatedText || "No answer available.", citations);
842
+ const cited = isXai
843
+ ? preserveInlineCitations(accumulatedText || "No answer available.", citations)
844
+ : applyIndexCitations(accumulatedText || "No answer available.", citations);
786
845
  const citationDetails = citations.map((citation) => ({
787
846
  title: citation.title,
788
847
  url: citation.url,
789
- source: "openai.url_citation",
848
+ source: `${providerSourceName}.url_citation`,
790
849
  type: "citation",
791
850
  raw: citation,
792
851
  }));
@@ -800,7 +859,7 @@ async function callOpenAIStream(
800
859
  return {
801
860
  text: cited.text,
802
861
  sources: cited.sources.length ? cited.sources.map((source) => ({ ...source, url: normalizeSearchUrl(source.url) })).filter((source) => !isLikelyJunkSearchUrl(source.url)) : derivedSources,
803
- providerKind: "openai",
862
+ providerKind: searchProvider,
804
863
  nativeSearchUsed: nativeSearchEvents.length > 0 || nativeSearchCalls.length > 0 || sanitizedSearchResults.length > 0,
805
864
  nativeSearchEvents,
806
865
  nativeSearchCalls,
@@ -993,7 +1052,7 @@ export async function callApiStream(
993
1052
  throw new Error("No prompt text found in request body");
994
1053
  }
995
1054
 
996
- if (kind === "openai") {
1055
+ if (kind === "openai" || kind === "xai") {
997
1056
  return callOpenAIStream(ctx, model, prompt, onUpdate, signal);
998
1057
  }
999
1058
  if (kind === "anthropic") {
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
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";
@@ -62,9 +63,30 @@ export default function (pi: ExtensionAPI) {
62
63
  pi.registerTool({
63
64
  name: WEB_SEARCH_TOOL,
64
65
  label: "Web Search",
65
- description: "Search the web using the current supported provider (Google Gemini, OpenAI, or Anthropic). Optionally include URLs to analyze alongside search results.",
66
+ description: "Search the web using the current supported provider (Google Gemini, xAI Grok, 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,8 +1,7 @@
1
1
  import type { ExtensionContext, AgentToolResult } from "@earendil-works/pi-coding-agent";
2
2
  import type { Api, Model } from "@earendil-works/pi-ai";
3
- import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
3
+ import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
  import { readFileSync } from "node:fs";
5
- import { homedir } from "node:os";
6
5
  import { join } from "node:path";
7
6
  import { getProviderKind } from "./api.ts";
8
7
 
@@ -18,8 +17,7 @@ export function formatResult(text: string, details: any): AgentToolResult<any> {
18
17
 
19
18
  // --- Model Selection ---
20
19
 
21
- const SUPPORTED_PROVIDERS = ["google-generative-ai", "openai-responses", "openai-codex-responses", "anthropic-messages"];
22
- const WEB_SEARCH_CONFIG_PATH = join(homedir(), ".pi", "agent", "web-search.json");
20
+ const SUPPORTED_PROVIDERS = ["google-generative-ai", "xai", "openai-responses", "azure-openai-responses", "openai-codex-responses", "anthropic-messages"];
23
21
 
24
22
  type WebSearchModelConfig =
25
23
  | { status: "missing"; path: string; }
@@ -36,7 +34,7 @@ function describeModel(model: Model<Api>): string {
36
34
  }
37
35
 
38
36
  function getWebSearchConfigPath(): string {
39
- return process.env.PI_WEB_SEARCH_CONFIG || WEB_SEARCH_CONFIG_PATH;
37
+ return process.env.PI_WEB_SEARCH_CONFIG || join(getAgentDir(), "web-search.json");
40
38
  }
41
39
 
42
40
  function readWebSearchModelConfig(): WebSearchModelConfig {
package/src/web_search.ts CHANGED
@@ -59,7 +59,14 @@ export async function webSearch(
59
59
  const cited = applyCitations(result.text, result.groundingMetadata);
60
60
  const text = cited.text;
61
61
  const sources = result.sources?.length ? result.sources : cited.sources;
62
- const extraSearchResults = (result.searchResults || []).filter((item) => item.url && !sources.some((source) => source.url === item.url));
62
+ const seenAdditionalResults = new Set<string>();
63
+ const extraSearchResults = (result.searchResults || []).filter((item) => {
64
+ if (!item.url || sources.some((source) => source.url === item.url)) return false;
65
+ const key = `${item.title || ""}\t${item.url}`;
66
+ if (seenAdditionalResults.has(key)) return false;
67
+ seenAdditionalResults.add(key);
68
+ return true;
69
+ });
63
70
 
64
71
  // Handle URL context metadata
65
72
  const urlMeta = result.urlContextMetadata?.urlMetadata
@@ -90,16 +97,11 @@ export async function webSearch(
90
97
  }
91
98
 
92
99
  if (extraSearchResults.length) {
93
- const visibleResults = extraSearchResults.slice(0, 8);
94
- summary += `\n\n## Additional Search Results\n${visibleResults.map((r, i) => {
100
+ summary += `\n\n## Additional Search Results\n${extraSearchResults.map((r, i) => {
95
101
  const label = r.title || r.url || `Result ${i + 1}`;
96
102
  const url = r.url ? ` - ${r.url}` : "";
97
- const meta = [r.source, r.type, r.status, r.query ? `query=${r.query}` : undefined].filter(Boolean).join(", ");
98
- return `${i + 1}. ${label}${url}${meta ? ` (${meta})` : ""}`;
103
+ return `${i + 1}. ${label}${url}`;
99
104
  }).join("\n")}`;
100
- if (extraSearchResults.length > visibleResults.length) {
101
- summary += `\n... and ${extraSearchResults.length - visibleResults.length} more results in tool details.`;
102
- }
103
105
  }
104
106
 
105
107
  return formatResult(summary, {