pi-web-search 1.2.1 → 1.3.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
@@ -30,7 +30,18 @@ pi install npm:pi-web-search
30
30
 
31
31
  ## Usage
32
32
 
33
- No extra config needed. Just configure any supported provider in pi and the tools auto-detect it.
33
+ No extra config needed. Select a supported current model in pi and the tools auto-detect the matching provider API.
34
+
35
+ `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`:
36
+
37
+ ```json
38
+ {
39
+ "provider": "openai",
40
+ "model": "gpt-5.1"
41
+ }
42
+ ```
43
+
44
+ When this file exists, `web_search` uses the configured provider/model first. If it is missing, `web_search` uses the current conversation model. If the selected model does not support native search, the tool returns an error instead of falling back.
34
45
 
35
46
  `url_context` is automatically removed from active tools when using a non-Gemini model.
36
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-search",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
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",
package/src/utils.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import type { ExtensionContext, AgentToolResult } from "@earendil-works/pi-coding-agent";
2
2
  import { type Model } from "@earendil-works/pi-ai";
3
3
  import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
4
+ import { readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
4
7
  import { getProviderKind } from "./api.ts";
5
8
 
6
9
  // --- Formatting ---
@@ -15,63 +18,170 @@ export function formatResult(text: string, details: any): AgentToolResult<any> {
15
18
 
16
19
  // --- Model Selection ---
17
20
 
21
+ const SUPPORTED_PROVIDERS = ["google-generative-ai", "openai-responses", "anthropic-messages"];
22
+ const WEB_SEARCH_CONFIG_PATH = join(homedir(), ".pi", "agent", "web-search.json");
23
+
24
+ type WebSearchModelConfig =
25
+ | { status: "missing"; path: string; }
26
+ | { status: "configured"; path: string; provider: string; modelId: string; }
27
+ | { status: "invalid"; path: string; error: string; };
28
+
18
29
  function isSupportedSearchModel(model: Model<any> | undefined): model is Model<any> {
19
30
  if (!model) return false;
20
31
  return getProviderKind(model) !== "unsupported";
21
32
  }
22
33
 
23
- function providerPriority(model: Model<any>): number {
24
- const priorities = [
25
- "google",
26
- "google-generative-ai",
27
- "openai",
28
- "anthropic",
29
- ];
30
- const providerIndex = priorities.indexOf(model.provider);
31
- if (providerIndex >= 0) return providerIndex;
32
- return priorities.length;
34
+ function describeModel(model: Model<any>): string {
35
+ return `${model.id} (${model.provider}/${model.api})`;
33
36
  }
34
37
 
35
- function modelPriority(model: Model<any>): number {
36
- const id = model.id;
37
- const patterns = [
38
- /gemini-3.*flash/i,
39
- /gemini-2\.5.*flash/i,
40
- /gemini-2\.0.*flash/i,
41
- /gemini.*flash/i,
42
- /gpt-5\..*mini/i,
43
- /gpt-4\.1.*mini/i,
44
- /gpt-4o-mini/i,
45
- /claude.*haiku/i,
46
- /claude.*sonnet/i,
47
- ];
48
- const patternIndex = patterns.findIndex((pattern) => pattern.test(id));
49
- return patternIndex >= 0 ? patternIndex : patterns.length;
38
+ function getWebSearchConfigPath(): string {
39
+ return process.env.PI_WEB_SEARCH_CONFIG || WEB_SEARCH_CONFIG_PATH;
50
40
  }
51
41
 
52
- export async function getModel(ctx: ExtensionContext): Promise<Model<any> | undefined> {
53
- // Use the current model first so the tool follows the user's selected provider.
54
- // This lets pi --provider openai/anthropic/google automatically pick the matching API.
55
- if (isSupportedSearchModel(ctx.model)) {
56
- return ctx.model;
42
+ function readWebSearchModelConfig(): WebSearchModelConfig {
43
+ const path = getWebSearchConfigPath();
44
+ let raw: string;
45
+ try {
46
+ raw = readFileSync(path, "utf8");
47
+ } catch (error: any) {
48
+ if (error?.code === "ENOENT") return { status: "missing", path };
49
+ return { status: "invalid", path, error: error?.message || String(error) };
50
+ }
51
+
52
+ let parsed: any;
53
+ try {
54
+ parsed = JSON.parse(raw);
55
+ } catch (error: any) {
56
+ return { status: "invalid", path, error: `Invalid JSON: ${error?.message || String(error)}` };
57
+ }
58
+
59
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
60
+ return { status: "invalid", path, error: "Expected a JSON object with provider and model fields" };
61
+ }
62
+
63
+ const provider = parsed.provider;
64
+ const modelId = parsed.model ?? parsed.modelId;
65
+ if (typeof provider !== "string" || provider.trim() === "") {
66
+ return { status: "invalid", path, error: "Missing required string field: provider" };
67
+ }
68
+ if (typeof modelId !== "string" || modelId.trim() === "") {
69
+ return { status: "invalid", path, error: "Missing required string field: model" };
70
+ }
71
+
72
+ return { status: "configured", path, provider: provider.trim(), modelId: modelId.trim() };
73
+ }
74
+
75
+ function getAvailableSupportedModels(ctx: ExtensionContext): string[] {
76
+ try {
77
+ return ctx.modelRegistry.getAvailable()
78
+ .filter(isSupportedSearchModel)
79
+ .map(describeModel);
80
+ } catch {
81
+ return [];
57
82
  }
83
+ }
84
+
85
+ export async function getModel(ctx: ExtensionContext): Promise<Model<any> | undefined> {
86
+ // Only use the currently selected model. Do not silently fall back to another
87
+ // configured model, because that can surprise users with unexpected API costs.
88
+ return isSupportedSearchModel(ctx.model) ? ctx.model : undefined;
89
+ }
58
90
 
59
- const models = ctx.modelRegistry.getAvailable().filter(isSupportedSearchModel);
60
- if (models.length === 0) return undefined;
91
+ export async function getWebSearchModel(ctx: ExtensionContext): Promise<Model<any> | undefined> {
92
+ const config = readWebSearchModelConfig();
93
+ if (config.status === "invalid") return undefined;
94
+
95
+ if (config.status === "configured") {
96
+ const model = ctx.modelRegistry.find(config.provider, config.modelId);
97
+ return isSupportedSearchModel(model) ? model : undefined;
98
+ }
61
99
 
62
- return models.sort((a, b) => {
63
- const byProvider = providerPriority(a) - providerPriority(b);
64
- if (byProvider !== 0) return byProvider;
65
- return modelPriority(a) - modelPriority(b);
66
- })[0];
100
+ return getModel(ctx);
67
101
  }
68
102
 
69
103
  // --- Error Results ---
70
104
 
71
105
  export function missingConfigResult(ctx: ExtensionContext): AgentToolResult<any> {
72
- const current = ctx.model ? `${ctx.model.provider} (${ctx.model.api})` : "none";
73
- const msg = `No supported web-search model configuration found. Current model: ${current}. Configure or select a supported provider: google-generative-ai, openai, or anthropic.`;
74
- return { content: [{ type: "text", text: `Failed: ${msg}` }], details: { error: "missing_config" } };
106
+ const availableSupportedModels = getAvailableSupportedModels(ctx);
107
+ const supportedList = SUPPORTED_PROVIDERS.join(", ");
108
+
109
+ if (ctx.model) {
110
+ const availableHint = availableSupportedModels.length > 0
111
+ ? ` Select one of these configured supported models manually: ${availableSupportedModels.join(", ")}.`
112
+ : ` Configure and select a supported provider: ${supportedList}.`;
113
+ const msg = `The current model ${describeModel(ctx.model)} does not support native web search. pi-web-search will not switch to another configured model automatically to avoid unexpected API costs.${availableHint}`;
114
+ return {
115
+ content: [{ type: "text", text: `Failed: ${msg}` }],
116
+ details: {
117
+ error: "unsupported_model",
118
+ currentModel: describeModel(ctx.model),
119
+ availableSupportedModels,
120
+ supportedProviders: SUPPORTED_PROVIDERS,
121
+ }
122
+ };
123
+ }
124
+
125
+ const availableHint = availableSupportedModels.length > 0
126
+ ? ` Select one of these configured supported models manually: ${availableSupportedModels.join(", ")}.`
127
+ : ` Configure and select a supported provider: ${supportedList}.`;
128
+ const msg = `No current model selected for web search.${availableHint}`;
129
+ return {
130
+ content: [{ type: "text", text: `Failed: ${msg}` }],
131
+ details: {
132
+ error: "missing_config",
133
+ availableSupportedModels,
134
+ supportedProviders: SUPPORTED_PROVIDERS,
135
+ }
136
+ };
137
+ }
138
+
139
+ export function missingWebSearchConfigResult(ctx: ExtensionContext): AgentToolResult<any> {
140
+ const config = readWebSearchModelConfig();
141
+ const availableSupportedModels = getAvailableSupportedModels(ctx);
142
+ const supportedList = SUPPORTED_PROVIDERS.join(", ");
143
+
144
+ if (config.status === "invalid") {
145
+ return {
146
+ content: [{ type: "text", text: `Failed: Invalid web search model config at ${config.path}: ${config.error}. Fix the file or remove it to use the current conversation model.` }],
147
+ details: {
148
+ error: "invalid_config",
149
+ configPath: config.path,
150
+ configError: config.error,
151
+ supportedProviders: SUPPORTED_PROVIDERS,
152
+ }
153
+ };
154
+ }
155
+
156
+ if (config.status === "configured") {
157
+ const model = ctx.modelRegistry.find(config.provider, config.modelId);
158
+ if (!model) {
159
+ return {
160
+ content: [{ type: "text", text: `Failed: Configured web search model ${config.provider}/${config.modelId} from ${config.path} was not found. Fix the file or remove it to use the current conversation model.` }],
161
+ details: {
162
+ error: "configured_model_not_found",
163
+ configPath: config.path,
164
+ configuredProvider: config.provider,
165
+ configuredModel: config.modelId,
166
+ availableSupportedModels,
167
+ supportedProviders: SUPPORTED_PROVIDERS,
168
+ }
169
+ };
170
+ }
171
+ return {
172
+ content: [{ type: "text", text: `Failed: Configured web search model ${describeModel(model)} from ${config.path} does not support native web search. Configure a model backed by ${supportedList}, or remove the file to use the current conversation model.` }],
173
+ details: {
174
+ error: "unsupported_model",
175
+ configPath: config.path,
176
+ configuredModel: describeModel(model),
177
+ configuredProvider: config.provider,
178
+ availableSupportedModels,
179
+ supportedProviders: SUPPORTED_PROVIDERS,
180
+ }
181
+ };
182
+ }
183
+
184
+ return missingConfigResult(ctx);
75
185
  }
76
186
 
77
187
  export function errorResult(e: Error): AgentToolResult<any> {
package/src/web_search.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext, AgentToolUpdateCallback } from "@earendil-works/pi-coding-agent";
2
2
  import { Type, type Static } from "typebox";
3
3
  import { callApiStream, getConfig, applyCitations } from "./api.ts";
4
- import { getModel, missingConfigResult, errorResult, formatResult } from "./utils.ts";
4
+ import { getWebSearchModel, missingWebSearchConfigResult, errorResult, formatResult } from "./utils.ts";
5
5
 
6
6
  export const WebSearchSchema = Type.Object({
7
7
  query: Type.String({ description: "The search query or question to answer" }),
@@ -19,8 +19,8 @@ export async function webSearch(
19
19
  onUpdate: AgentToolUpdateCallback | undefined,
20
20
  ctx: ExtensionContext
21
21
  ) {
22
- const model = await getModel(ctx);
23
- if (!model) return missingConfigResult(ctx);
22
+ const model = await getWebSearchModel(ctx);
23
+ if (!model) return missingWebSearchConfigResult(ctx);
24
24
 
25
25
  const hasUrls = params.urls && params.urls.length > 0;
26
26
  const urlCount = hasUrls ? params.urls!.length : 0;
@@ -84,10 +84,6 @@ export async function webSearch(
84
84
  failed.forEach((f: any) => { summary += `\n- ${f.url}: ${f.status}`; });
85
85
  }
86
86
 
87
- if (result.nativeSearchUsed === false) {
88
- summary += `\n\n## Search Verification\n⚠️ No verified native search metadata was returned by provider ${result.providerKind || "unknown"}. Treat the answer as ungrounded unless sources/searchResults are present in tool details.`;
89
- }
90
-
91
87
  // Add sources
92
88
  if (sources.length > 0) {
93
89
  summary += `\n\n## Sources\n${sources.map((s, i) => `${i + 1}. [${s.title}](${s.url})`).join("\n")}`;