pi-web-search 1.4.0 → 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://pi.dev) with Gemini + URL Context, OpenAI Responses variants, and Anthropic.
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,6 +11,7 @@ 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
16
  | Azure OpenAI | Responses API web search (`azure-openai-responses`) |
16
17
  | OpenAI Codex | Codex Responses API web search (`openai-codex-responses`) |
@@ -37,7 +38,7 @@ pi install npm:pi-web-search
37
38
 
38
39
  No extra config needed. Select a supported current model in pi and the tools auto-detect the matching provider API.
39
40
 
40
- `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`):
41
42
 
42
43
  ```json
43
44
  {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-web-search",
3
- "version": "1.4.0",
4
- "description": "Provider-native web search for pi: Gemini + URL Context, OpenAI Responses (Azure/Codex/Copilot), and Anthropic",
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",
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,6 +34,7 @@ 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.provider === "xai" && model.api === "openai-responses") return "xai";
37
38
  if (
38
39
  model.api === "openai-responses"
39
40
  || model.api === "azure-openai-responses"
@@ -464,6 +465,14 @@ async function resolveGoogleGroundingRedirectUrls(searchResults: SearchResultDet
464
465
  }
465
466
  }
466
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
+
467
476
  function applyIndexCitations(text: string, citations: Array<{ endIndex?: number; title: string; url: string }>): { text: string; sources: Source[] } {
468
477
  const sources: Source[] = [];
469
478
  const insertions = citations
@@ -682,21 +691,25 @@ async function callOpenAIStream(
682
691
  }
683
692
  const requestHeaders = Object.fromEntries(headers.entries());
684
693
 
694
+ const isXai = getProviderKind(model) === "xai";
695
+ const searchProvider: ProviderKind = isXai ? "xai" : "openai";
696
+ const providerSourceName = isXai ? "xai" : "openai";
685
697
  const requestBody: any = {
686
698
  model: model.id,
687
699
  input: isCodex
688
700
  ? [{ role: "user", content: [{ type: "input_text", text: prompt }] }]
689
- : prompt,
701
+ : isXai
702
+ ? [{ role: "user", content: prompt }]
703
+ : prompt,
690
704
  tools: [{ type: "web_search" }],
691
- include: isCodex
692
- ? ["web_search_call.action.sources"]
693
- : ["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"] }),
694
708
  stream: true,
695
709
  store: false,
696
710
  };
697
- if (model.reasoning) {
698
- requestBody.reasoning = { effort: "none" };
699
- }
711
+ // Let the provider choose its supported reasoning default. Some reasoning
712
+ // models reject "none", and supported effort levels vary between models.
700
713
  if (isCodex) {
701
714
  requestBody.instructions = "Answer the user's request using web search when needed.";
702
715
  requestBody.text = { verbosity: "low" };
@@ -713,7 +726,7 @@ async function callOpenAIStream(
713
726
  });
714
727
 
715
728
  if (!response.ok) {
716
- 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()}`);
717
730
  }
718
731
 
719
732
  let accumulatedText = "";
@@ -735,7 +748,7 @@ async function callOpenAIStream(
735
748
  const action = item.action || {};
736
749
  const call: NativeSearchCallDetail = {
737
750
  id: item.id,
738
- provider: "openai",
751
+ provider: searchProvider,
739
752
  status: item.status,
740
753
  actionType: action.type,
741
754
  raw: item,
@@ -755,7 +768,7 @@ async function callOpenAIStream(
755
768
  pushUniqueSearchResult(searchResults, {
756
769
  title: source.title || source.display_name || source.name || titleFromUrl(source.url),
757
770
  url: source.url,
758
- source: "openai.web_search_call.action.sources",
771
+ source: `${providerSourceName}.web_search_call.action.sources`,
759
772
  type: source.type || "url",
760
773
  raw: source,
761
774
  });
@@ -766,7 +779,7 @@ async function callOpenAIStream(
766
779
  pushUniqueSearchResult(searchResults, {
767
780
  title: titleFromUrl(action.url),
768
781
  url: action.url,
769
- source: `openai.web_search_call.action.${action.type}`,
782
+ source: `${providerSourceName}.web_search_call.action.${action.type}`,
770
783
  type: action.type,
771
784
  raw: action,
772
785
  });
@@ -816,21 +829,23 @@ async function callOpenAIStream(
816
829
  pushNativeSearchEvent(nativeSearchEvents, event.type);
817
830
  const call = nativeSearchCalls.find((item) => item.id === event.item_id);
818
831
  if (call) call.status = event.type.replace("response.web_search_call.", "");
819
- 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 });
820
833
  if (event.type === "response.web_search_call.searching") {
821
834
  onUpdate?.({
822
- content: [{ type: "text", text: accumulatedText || "Searching the web with OpenAI..." }],
835
+ content: [{ type: "text", text: accumulatedText || `Searching the web with ${isXai ? "xAI" : "OpenAI"}...` }],
823
836
  details: { streaming: true, searching: true }
824
837
  });
825
838
  }
826
839
  }
827
840
  });
828
841
 
829
- 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);
830
845
  const citationDetails = citations.map((citation) => ({
831
846
  title: citation.title,
832
847
  url: citation.url,
833
- source: "openai.url_citation",
848
+ source: `${providerSourceName}.url_citation`,
834
849
  type: "citation",
835
850
  raw: citation,
836
851
  }));
@@ -844,7 +859,7 @@ async function callOpenAIStream(
844
859
  return {
845
860
  text: cited.text,
846
861
  sources: cited.sources.length ? cited.sources.map((source) => ({ ...source, url: normalizeSearchUrl(source.url) })).filter((source) => !isLikelyJunkSearchUrl(source.url)) : derivedSources,
847
- providerKind: "openai",
862
+ providerKind: searchProvider,
848
863
  nativeSearchUsed: nativeSearchEvents.length > 0 || nativeSearchCalls.length > 0 || sanitizedSearchResults.length > 0,
849
864
  nativeSearchEvents,
850
865
  nativeSearchCalls,
@@ -1037,7 +1052,7 @@ export async function callApiStream(
1037
1052
  throw new Error("No prompt text found in request body");
1038
1053
  }
1039
1054
 
1040
- if (kind === "openai") {
1055
+ if (kind === "openai" || kind === "xai") {
1041
1056
  return callOpenAIStream(ctx, model, prompt, onUpdate, signal);
1042
1057
  }
1043
1058
  if (kind === "anthropic") {
package/src/index.ts CHANGED
@@ -63,7 +63,7 @@ export default function (pi: ExtensionAPI) {
63
63
  pi.registerTool({
64
64
  name: WEB_SEARCH_TOOL,
65
65
  label: "Web Search",
66
- 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.",
67
67
  parameters: WebSearchSchema,
68
68
  execute: webSearch,
69
69
  renderCall(args, theme) {
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", "azure-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, {