@herbertgao/pi-extensions 2026.9.13 → 2026.9.15

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.
Files changed (70) hide show
  1. package/README.md +3 -3
  2. package/THIRD_PARTY_NOTICES.md +2 -2
  3. package/node_modules/@herbertgao/pi-bark/package.json +2 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/package.json +4 -4
  5. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
  6. package/node_modules/@herbertgao/pi-subagents/package.json +4 -4
  7. package/node_modules/@herbertgao/resume-from/package.json +2 -2
  8. package/node_modules/@herbertgao/sol-pi/package.json +5 -5
  9. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/extension.ts +2 -0
  10. package/node_modules/pi-jev-auto-mode/CHANGELOG.md +8 -0
  11. package/node_modules/pi-jev-auto-mode/README.md +7 -0
  12. package/node_modules/pi-jev-auto-mode/package.json +1 -1
  13. package/node_modules/pi-jev-auto-mode/src/extension.ts +21 -2
  14. package/node_modules/pi-jev-auto-mode/src/records.ts +68 -2
  15. package/node_modules/pi-jev-auto-mode/src/settings.ts +22 -0
  16. package/node_modules/pi-jev-auto-mode/src/ui.ts +10 -0
  17. package/node_modules/pi-mcp-adapter/CHANGELOG.md +26 -0
  18. package/node_modules/pi-mcp-adapter/README.md +39 -9
  19. package/node_modules/pi-mcp-adapter/cli.js +33 -22
  20. package/node_modules/pi-mcp-adapter/commands.ts +3 -3
  21. package/node_modules/pi-mcp-adapter/config.ts +9 -1
  22. package/node_modules/pi-mcp-adapter/dist/config.js +9 -1
  23. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  24. package/node_modules/pi-mcp-adapter/dist/jev-client.d.ts +1 -2
  25. package/node_modules/pi-mcp-adapter/dist/jev-client.js +59 -32
  26. package/node_modules/pi-mcp-adapter/dist/jev-client.js.map +1 -1
  27. package/node_modules/pi-mcp-adapter/dist/jev-contracts.d.ts +1 -1
  28. package/node_modules/pi-mcp-adapter/dist/jev-key-store.d.ts +33 -4
  29. package/node_modules/pi-mcp-adapter/dist/jev-key-store.js +148 -28
  30. package/node_modules/pi-mcp-adapter/dist/jev-key-store.js.map +1 -1
  31. package/node_modules/pi-mcp-adapter/dist/mcp-auth-flow.js +1 -2
  32. package/node_modules/pi-mcp-adapter/dist/mcp-auth-flow.js.map +1 -1
  33. package/node_modules/pi-mcp-adapter/dist/types.d.ts +7 -1
  34. package/node_modules/pi-mcp-adapter/dist/types.js.map +1 -1
  35. package/node_modules/pi-mcp-adapter/index.ts +51 -38
  36. package/node_modules/pi-mcp-adapter/jev-client.ts +58 -31
  37. package/node_modules/pi-mcp-adapter/jev-contracts.ts +2 -2
  38. package/node_modules/pi-mcp-adapter/jev-key-store.ts +144 -31
  39. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
  40. package/node_modules/pi-mcp-adapter/mcp-code.ts +6 -6
  41. package/node_modules/pi-mcp-adapter/package.json +5 -5
  42. package/node_modules/pi-mcp-adapter/semantic-search.ts +3 -3
  43. package/node_modules/pi-mcp-adapter/types.ts +7 -1
  44. package/node_modules/pi-multi-account/CHANGELOG.md +12 -0
  45. package/node_modules/pi-multi-account/README.md +21 -6
  46. package/node_modules/pi-multi-account/index.ts +295 -41
  47. package/node_modules/pi-multi-account/package.json +3 -3
  48. package/node_modules/pi-typesafe/dist/backends.d.ts +10 -0
  49. package/node_modules/pi-typesafe/dist/backends.js +26 -0
  50. package/node_modules/pi-typesafe/dist/client.d.ts +1 -1
  51. package/node_modules/pi-typesafe/dist/client.js +10 -6
  52. package/node_modules/pi-typesafe/dist/errors.d.ts +10 -2
  53. package/node_modules/pi-typesafe/dist/errors.js +28 -7
  54. package/node_modules/pi-typesafe/dist/extension.js +2 -1
  55. package/node_modules/pi-typesafe/package.json +4 -4
  56. package/node_modules/pi-web-access/CHANGELOG.md +22 -0
  57. package/node_modules/pi-web-access/README.md +21 -8
  58. package/node_modules/pi-web-access/baizhi.ts +124 -0
  59. package/node_modules/pi-web-access/crawl4ai.ts +2 -10
  60. package/node_modules/pi-web-access/curator-page.ts +3 -1
  61. package/node_modules/pi-web-access/curator-server.ts +1 -0
  62. package/node_modules/pi-web-access/dist/index.js +540 -220
  63. package/node_modules/pi-web-access/firecrawl.ts +2 -10
  64. package/node_modules/pi-web-access/gemini-search.ts +5 -10
  65. package/node_modules/pi-web-access/index.ts +134 -23
  66. package/node_modules/pi-web-access/package.json +5 -1
  67. package/node_modules/pi-web-access/storage.ts +1 -0
  68. package/node_modules/pi-web-access/tool-activation.ts +134 -0
  69. package/node_modules/pi-web-access/utils.ts +9 -1
  70. package/package.json +14 -14
@@ -1,9 +1,17 @@
1
+ import type { BackendConfig, TypeSafeBackend } from "./backends.js";
1
2
  export type IntegrationErrorCode = "configuration" | "validation" | "budget" | "aborted" | "timeout" | "http" | "connection" | "response";
2
- /** Safe to display: never contains upstream bodies, headers, keys, or submitted state. */
3
+ /**
4
+ * Safe to display: never contains upstream bodies, keys, or submitted state. The only header value a message can
5
+ * quote is a numeric Retry-After count in seconds, which cannot carry a secret.
6
+ */
3
7
  export declare class TypeSafeIntegrationError extends Error {
4
8
  readonly code: IntegrationErrorCode;
5
9
  readonly status?: number | undefined;
6
10
  readonly name = "TypeSafeIntegrationError";
7
11
  constructor(code: IntegrationErrorCode, message: string, status?: number | undefined);
8
12
  }
9
- export declare function safeError(error: unknown): TypeSafeIntegrationError;
13
+ /**
14
+ * Classify an error into a message safe to display. `backend` names the key variable the 401 advice tells the user
15
+ * to check and selects the 402 wording; omitting it keeps the one-argument call and assumes the default TypeSafe key.
16
+ */
17
+ export declare function safeError(error: unknown, backend?: TypeSafeBackend | BackendConfig): TypeSafeIntegrationError;
@@ -1,5 +1,9 @@
1
1
  import { APIError, APIConnectionError, APITimeoutError, APIUserAbortError } from "@typesafe-ai/sdk";
2
- /** Safe to display: never contains upstream bodies, headers, keys, or submitted state. */
2
+ import { DECISIONS_BACKENDS, TYPESAFE_KEY_ENV, backendConfig } from "./backends.js";
3
+ /**
4
+ * Safe to display: never contains upstream bodies, keys, or submitted state. The only header value a message can
5
+ * quote is a numeric Retry-After count in seconds, which cannot carry a secret.
6
+ */
3
7
  export class TypeSafeIntegrationError extends Error {
4
8
  code;
5
9
  status;
@@ -10,7 +14,19 @@ export class TypeSafeIntegrationError extends Error {
10
14
  this.status = status;
11
15
  }
12
16
  }
13
- export function safeError(error) {
17
+ /** A numeric Retry-After delay in seconds; dates, blanks, and anything else stay out of the message. */
18
+ function retryAfterSeconds(error) {
19
+ const raw = error.headers?.get("retry-after")?.trim();
20
+ if (!raw)
21
+ return undefined;
22
+ const seconds = Number(raw);
23
+ return Number.isSafeInteger(seconds) && seconds >= 0 ? seconds : undefined;
24
+ }
25
+ /**
26
+ * Classify an error into a message safe to display. `backend` names the key variable the 401 advice tells the user
27
+ * to check and selects the 402 wording; omitting it keeps the one-argument call and assumes the default TypeSafe key.
28
+ */
29
+ export function safeError(error, backend) {
14
30
  if (error instanceof TypeSafeIntegrationError)
15
31
  return error;
16
32
  if (error instanceof APIUserAbortError)
@@ -18,11 +34,16 @@ export function safeError(error) {
18
34
  if (error instanceof APITimeoutError)
19
35
  return new TypeSafeIntegrationError("timeout", "TypeSafe request timed out; it was not retried and may still be billed.");
20
36
  if (error instanceof APIError) {
21
- const advice = error.status === 401 ? "Check TYPESAFE_API_KEY."
22
- : error.status === 403 ? "Check your account access and model permissions."
23
- : error.status === 429 ? "Check your account quota and try again later."
24
- : error.status === 400 || error.status === 422 ? "Check the question format and model limits."
25
- : "Try again later or check the service status.";
37
+ const config = backend === undefined ? undefined : typeof backend === "string" ? backendConfig(backend) : backend;
38
+ const keyEnv = config?.keyEnv ?? TYPESAFE_KEY_ENV;
39
+ const openrouter = config !== undefined && config.host === DECISIONS_BACKENDS.openrouter.host;
40
+ const retry = error.status === 429 ? retryAfterSeconds(error) : undefined;
41
+ const advice = error.status === 401 ? `Check ${keyEnv}.`
42
+ : error.status === 402 ? (openrouter ? "Insufficient credits. Add credits at https://openrouter.ai/credits." : "Check your account balance.")
43
+ : error.status === 403 ? "Check your account access and model permissions."
44
+ : error.status === 429 ? `Check your account quota and try again later.${retry === undefined ? "" : ` Retry after ${retry} seconds.`}`
45
+ : error.status === 400 || error.status === 422 ? "Check the question format and model limits."
46
+ : "Try again later or check the service status.";
26
47
  return new TypeSafeIntegrationError("http", `TypeSafe returned HTTP ${error.status}. ${advice} No automatic retry was made.`, error.status);
27
48
  }
28
49
  if (error instanceof APIConnectionError)
@@ -1,4 +1,5 @@
1
1
  import { Text } from "@earendil-works/pi-tui";
2
+ import { DEFAULT_BACKEND, defaultModelId } from "./backends.js";
2
3
  import { createTypeSafe, DEFAULT_MAX_REQUESTS } from "./client.js";
3
4
  import { authState, clearAuthState, describeAuth } from "./auth.js";
4
5
  import { clearStoredApiKey, credentialsPath, keySituation, keySourceLabel } from "./credentials.js";
@@ -136,7 +137,7 @@ export default function typesafeExtension(pi) {
136
137
  ? `Today ${spend.today.requestsStarted} requests (${spend.today.requestsSucceeded} ok, ${spend.today.requestsFailed} failed), ${spend.today.inputTokens} input tokens, ~$${spend.today.estimatedUsd.toFixed(4)}.`
137
138
  : "";
138
139
  const blocked = spend?.blocked ? ` Cap reached: ${spend.blocked.cap} ${spend.blocked.used}/${spend.blocked.limit} on ${spend.blocked.day}; no request will be submitted until the local day rolls over.` : "";
139
- report(`TypeSafe: ${enabled ? "enabled" : "disabled"}. ${auth.text} ${session} ${today}${blocked} Model: jev-latest. Session limits reset on session start/reload; daily counters persist and caps come from client options or PI_TYPESAFE_MAX_* environment variables. ${disclosure}`, auth.level === "error" && enabled ? "warning" : "info");
140
+ report(`TypeSafe: ${enabled ? "enabled" : "disabled"}. ${auth.text} ${session} ${today}${blocked} Model: ${defaultModelId(DEFAULT_BACKEND)}. Session limits reset on session start/reload; daily counters persist and caps come from client options or PI_TYPESAFE_MAX_* environment variables. ${disclosure}`, auth.level === "error" && enabled ? "warning" : "info");
140
141
  return;
141
142
  }
142
143
  if (action === "logout") {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-typesafe",
3
- "version": "0.7.1",
3
+ "version": "0.7.4",
4
4
  "description": "TypeSafe AI (Jev) decisions for Pi: batched Choice/Score/Noul evaluation tool, terminal playground, and a typed API other extensions build on.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -80,10 +80,10 @@
80
80
  }
81
81
  },
82
82
  "devDependencies": {
83
- "@earendil-works/pi-coding-agent": "0.85.1",
84
- "@earendil-works/pi-tui": "^0.85.1",
83
+ "@earendil-works/pi-coding-agent": "0.86.1",
84
+ "@earendil-works/pi-tui": "^0.86.1",
85
85
  "@types/node": "^22.0.0",
86
86
  "tsx": "^4.23.13",
87
- "typescript": "^6.0.0"
87
+ "typescript": "^7.0.2"
88
88
  }
89
89
  }
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.31.0] - 2026-09-22
8
+
9
+ ### Highlights
10
+
11
+ - Start fresh sessions with a smaller web-tool list, then enable the tools when you need them.
12
+ - Keep long search responses manageable without losing access to the full results.
13
+ - Search with Baizhi MCP when you explicitly configure it.
14
+ - Connect Brave, Exa, and Tavily to local HTTP services on loopback addresses.
15
+
16
+ ### Added
17
+
18
+ - Fresh sessions now show `web_enable` instead of the full web-tool list when supported. Call it to make your configured web tools available. Thanks to [@Knimoms](https://github.com/Knimoms) for [PR #424](https://github.com/nicobailon/pi-web-access/pull/424).
19
+ - Added Baizhi MCP as an optional search provider, with search routing and Curator support. It is used only when explicitly selected. Thanks to [@ct-jaryn](https://github.com/ct-jaryn) for [PR #422](https://github.com/nicobailon/pi-web-access/pull/422).
20
+
21
+ ### Changed
22
+
23
+ - Long `web_search` responses now show a shorter result and the providers used. Full results remain available through `get_search_content`. Thanks to [@theSprog](https://github.com/theSprog) for [issue #423](https://github.com/nicobailon/pi-web-access/issues/423).
24
+
25
+ ### Fixed
26
+
27
+ - Allow explicitly configured Brave, Exa, and Tavily API base URLs to use HTTP on true loopback hosts while continuing to require HTTPS remotely. Thanks to [@aaschmid](https://github.com/aaschmid) for [issue #421](https://github.com/nicobailon/pi-web-access/issues/421).
28
+
7
29
  ## [0.30.0] - 2026-09-19
8
30
 
9
31
  ### Highlights
@@ -163,7 +163,7 @@ fetch_content({ url: "/path/to/recording.mp4", prompt: "What error appears on sc
163
163
 
164
164
  ### web_search
165
165
 
166
- Search the web via OpenAI, Brave, Parallel, TinyFish, Search1API, Searchinfinity, Querit, Tavily, Firecrawl, Jina, SERPdive, Kagi, Bocha, Ollama, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data SERP, SerpBase, SerpApi, Serper, Serply, self-hosted SearXNG, keyless DuckDuckGo, Exa, Perplexity AI, Gemini, or Kimi. By default, returns source-linked search results or provider answers.
166
+ Search the web via OpenAI, Brave, Parallel, TinyFish, Search1API, Searchinfinity, Querit, Tavily, Firecrawl, Jina, SERPdive, Kagi, Bocha, Ollama, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data SERP, SerpBase, SerpApi, Serper, Serply, self-hosted SearXNG, keyless DuckDuckGo, Exa, Perplexity AI, Gemini, or Kimi. The default `none` workflow makes no summary-generation model call and opens no curator/browser: it returns bounded raw results, identifies the provider used for each query, and stores the full results for retrieval by responseId. The selected search provider may itself be model-backed.
167
167
 
168
168
  ```typescript
169
169
  web_search({ query: "rust async programming" })
@@ -186,7 +186,7 @@ web_search({ queries: ["query 1", "query 2"], workflow: "auto-summary" })
186
186
  | `numResults` | Results per query (default: 5, max: 20) |
187
187
  | `recencyFilter` | `day`, `week`, `month`, or `year` |
188
188
  | `domainFilter` | Limit to domains (prefix with `-` to exclude) |
189
- | `provider` | Configured provider when omitted or set to `auto`; `all` searches every eligible provider except Parallel MCP, DuckDuckGo, Kimi, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, SerpApi, Serper, and Serply simultaneously; otherwise `openai`, `brave`, `parallel`, `parallel-mcp`, `tinyfish`, `search1api`, `searchinfinity`, `querit`, `tavily`, `firecrawl`, `jina`, `serpdive`, `kagi`, `bocha`, `ollama`, `anysearch`, `xcrawl`, `valyu`, `xai`, `mistral`, `brightdata`, `serpbase`, `serpapi`, `serper`, `serply`, `searxng`, `duckduckgo`, `exa`, `perplexity`, `gemini`, or `kimi` (auto-selects when no provider or routing is configured; Parallel MCP, DuckDuckGo, Kimi, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, SerpApi, Serper, and Serply are explicit-only) |
189
+ | `provider` | Configured provider when omitted or set to `auto`; `all` searches every eligible provider except Parallel MCP, DuckDuckGo, Kimi, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, SerpApi, Serper, Serply, and Baizhi simultaneously; otherwise `openai`, `brave`, `parallel`, `parallel-mcp`, `tinyfish`, `search1api`, `searchinfinity`, `querit`, `tavily`, `firecrawl`, `jina`, `serpdive`, `kagi`, `bocha`, `ollama`, `anysearch`, `xcrawl`, `valyu`, `xai`, `mistral`, `brightdata`, `serpbase`, `serpapi`, `serper`, `serply`, `baizhi`, `searxng`, `duckduckgo`, `exa`, `perplexity`, `gemini`, or `kimi` (auto-selects when no provider or routing is configured; Parallel MCP, DuckDuckGo, Kimi, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, SerpApi, Serper, Serply, and Baizhi are explicit-only) |
190
190
  | `includeContent` | Fetch full page content from sources in background |
191
191
  | `workflow` | `none` (skip curator; fresh-install default), `summary-review` (open curator and auto-generate a summary draft), or `auto-summary` (generate a summary without opening the curator) |
192
192
 
@@ -226,17 +226,18 @@ Thanks to [@linuxtextadventurer](https://github.com/linuxtextadventurer) for PR
226
226
 
227
227
  ### get_search_content
228
228
 
229
- Retrieve stored content from previous searches or fetches. Fetched URL content is stored in full in a private `web-search-cache` directory under the Pi config directory, not in the session JSONL. This includes `fetch_content` answer mode, which stores the original page content. The cache has a one-hour lifetime and fixed limits of 128 entries and 128 MiB; when either limit is reached, the oldest entries are removed first. On macOS and Linux the cache directory and files are kept at permissions `0700` and `0600`, respectively. Use `findText` to locate bounded matching passages without paging through a large page, or use `offset` and `limit` to retrieve slices intentionally.
229
+ Retrieve stored content from previous searches or fetches. Search provider answers and every result remain available in full and can be paged with `offset` and `limit` or searched with `findText`. Fetched URL content is stored in full in a private `web-search-cache` directory under the Pi config directory, not in the session JSONL. This includes `fetch_content` answer mode, which stores the original page content. The cache has a one-hour lifetime and fixed limits of 128 entries and 128 MiB; when either limit is reached, the oldest entries are removed first. On macOS and Linux the cache directory and files are kept at permissions `0700` and `0600`, respectively. Use `findText` to locate bounded matching passages without paging through a large page, or use `offset` and `limit` to retrieve slices intentionally.
230
230
 
231
231
  ```typescript
232
232
  get_search_content({ responseId: "abc123", urlIndex: 0 })
233
233
  get_search_content({ responseId: "abc123", url: "https://...", offset: 30000 })
234
234
  get_search_content({ responseId: "abc123", query: "original query" })
235
+ get_search_content({ responseId: "abc123", queryIndex: 0, offset: 30000 })
235
236
  get_search_content({ responseId: "abc123", urlIndex: 0, findText: "installation" })
236
237
  get_search_content({ responseId: "abc123", urlIndex: 0, findText: ["timeout", "retry"], findMode: "fuzzy" })
237
238
  ```
238
239
 
239
- `findMode` supports `exact`, `case-insensitive` (default), and `fuzzy`. Finder output is capped at 20,000 characters with match counts and nearby context. Fitting responses retain their existing format and document order. Overflow responses identify queries as `Q1`, `Q2`, and so on, list each full query once, and reserve a representative excerpt for each query whose discovered match span fits the output budget. Queries with oversized spans are listed as having no representative excerpt. `findText` cannot be combined with `offset` or `limit`. The default `limit` and maximum permitted `limit` use `maxInlineContentChars`.
240
+ `findMode` supports `exact`, `case-insensitive` (default), and `fuzzy`. Finder output is capped at 20,000 characters with match counts and nearby context. Fitting responses retain their existing format and document order. Overflow responses identify queries as `Q1`, `Q2`, and so on, list each full query once, and reserve a representative excerpt for each query whose discovered match span fits the output budget. Queries with oversized spans are listed as having no representative excerpt. `findText` cannot be combined with `offset` or `limit`. The default `limit` and maximum permitted `limit` use `maxInlineContentChars`; search-page continuation guidance shares that overall output budget and may reduce the returned stored-content characters.
240
241
 
241
242
  ### source_check
242
243
 
@@ -442,6 +443,7 @@ Config defaults to `~/.pi/agent/web-search.json` when neither `PI_CODING_AGENT_D
442
443
  "serpapiApiKey": "$SERPAPI_KEY",
443
444
  "serperApiKey": "$SERPER_API_KEY",
444
445
  "serplyApiKey": "$SERPLY_API_KEY",
446
+ "baizhiApiKey": "$BAIZHI_API_KEY",
445
447
  "brightdataApiKey": "$BRIGHTDATA_API_KEY",
446
448
  "brightdataSerpZone": "pi_serp",
447
449
  "xaiApiKey": "xai-...",
@@ -560,11 +562,13 @@ Config defaults to `~/.pi/agent/web-search.json` when neither `PI_CODING_AGENT_D
560
562
  }
561
563
  ```
562
564
 
565
+ `maxInlineContentChars` accepts integers from 1,000 through 200,000. Missing, invalid, or smaller values use the 30,000-character default so truncation and retrieval guidance always fit.
566
+
563
567
  `webSearch.allowedProviders` is an optional, non-empty search-provider allowlist with no duplicate entries. When omitted, every current search provider remains permitted. When present, explicit scalar and array requests for providers outside the list fail before any provider request, while `auto`, `all`, configured routing, `source_check`, and Curator use only listed providers. The allowlist does not make explicit-only or paid providers eligible for automatic fallback or `all`; they must still be named explicitly or included in `searchRouting.providers`. A configured `provider`/`searchProvider` or `searchRouting.providers` entry outside the allowlist is rejected as invalid configuration. This setting affects search only: it does not restrict `fetchRouting`, fetch answer models, or summary models.
564
568
 
565
569
  `summaryModel` accepts an optional thinking-level suffix, such as `anthropic/claude-haiku-4-5:low`. Supported suffixes are `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`.
566
570
 
567
- All provider API-key fields (`openaiApiKey`, `braveApiKey`, `parallelApiKey`, `tinyfishApiKey`, `search1apiApiKey`, `searchinfinityApiKey`, `queritApiKey`, `tavilyApiKey`, `jinaApiKey`, `serpdiveApiKey`, `kagiApiKey`, `bochaApiKey`, `ollamaApiKey`, `valyuApiKey`, `serpbaseApiKey`, `serpapiApiKey`, `serperApiKey`, `serplyApiKey`, `anysearchApiKey`, `xcrawlApiKey`, `xaiApiKey`, `mistralApiKey`, `brightdataApiKey`, `firecrawlApiKey`, `crawl4aiApiToken`, `exaApiKey`, `perplexityApiKey`, `geminiApiKey`, `datalabApiKey`, and `cloudflareApiKey`) accept explicit credential sources. Use `$NAME` or `${NAME}` to read one named environment variable, or prefix a trusted local shell command with `!` to resolve one value at provider request time. Escape `$$` as a literal leading `$` and `$!` as a literal leading `!`:
571
+ All provider API-key fields (`openaiApiKey`, `braveApiKey`, `parallelApiKey`, `tinyfishApiKey`, `search1apiApiKey`, `searchinfinityApiKey`, `queritApiKey`, `tavilyApiKey`, `jinaApiKey`, `serpdiveApiKey`, `kagiApiKey`, `bochaApiKey`, `ollamaApiKey`, `valyuApiKey`, `serpbaseApiKey`, `serpapiApiKey`, `serperApiKey`, `serplyApiKey`, `baizhiApiKey`, `anysearchApiKey`, `xcrawlApiKey`, `xaiApiKey`, `mistralApiKey`, `brightdataApiKey`, `firecrawlApiKey`, `crawl4aiApiToken`, `exaApiKey`, `perplexityApiKey`, `geminiApiKey`, `datalabApiKey`, and `cloudflareApiKey`) accept explicit credential sources. Use `$NAME` or `${NAME}` to read one named environment variable, or prefix a trusted local shell command with `!` to resolve one value at provider request time. Escape `$$` as a literal leading `$` and `$!` as a literal leading `!`:
568
572
 
569
573
  ```json
570
574
  {
@@ -575,7 +579,7 @@ All provider API-key fields (`openaiApiKey`, `braveApiKey`, `parallelApiKey`, `t
575
579
  }
576
580
  ```
577
581
 
578
- This syntax applies to provider credentials only; other configuration fields are not interpolated. `firecrawlApiKey`, `crawl4aiApiToken`, `kagiApiKey`, `ollamaApiKey`, `valyuApiKey`, `serpbaseApiKey`, `serpapiApiKey`, `serperApiKey`, `serplyApiKey`, `mistralApiKey`, and `brightdataApiKey` use the same credential-source rules, while `braveBaseUrl`, `exaBaseUrl`, `tavilyBaseUrl`, `firecrawlBaseUrl`, `firecrawlApiVersion`, `firecrawlFreshScrape`, `crawl4aiBaseUrl`, `brightdataSerpZone`, and `brightdataUnlockerZone` are literal config values.
582
+ This syntax applies to provider credentials only; other configuration fields are not interpolated. `firecrawlApiKey`, `crawl4aiApiToken`, `kagiApiKey`, `ollamaApiKey`, `valyuApiKey`, `serpbaseApiKey`, `serpapiApiKey`, `serperApiKey`, `serplyApiKey`, `baizhiApiKey`, `mistralApiKey`, and `brightdataApiKey` use the same credential-source rules, while `braveBaseUrl`, `exaBaseUrl`, `tavilyBaseUrl`, `firecrawlBaseUrl`, `firecrawlApiVersion`, `firecrawlFreshScrape`, `crawl4aiBaseUrl`, `brightdataSerpZone`, and `brightdataUnlockerZone` are literal config values.
579
583
 
580
584
  A command source is not run while the extension loads or registers tools. Each selected provider request runs it again with a five-second timeout, a 16 KiB output limit, a minimized environment, and a one-line non-empty stdout requirement. Command text and stderr are omitted from errors. These commands are trusted local configuration, not a same-user process isolation boundary; use absolute executable paths and protect the config file. `OP_SESSION_*` and `OP_SERVICE_ACCOUNT_TOKEN`, when present in Pi's environment, are forwarded to trusted resolver commands so 1Password CLI sessions and service accounts can be reused without storing their credentials in config. For example, `"braveApiKey": "!/absolute/path/to/op read 'op://Automation/Brave/credential'"` resolves that item after you replace the executable placeholder with your trusted installation's absolute path, but the `op://` argument is not an authorization boundary: every configured resolver command that receives the token can exercise all vault and item permissions granted to its service account. Prefer a narrowly scoped service account. An explicit source overrides legacy provider environment variables and fails that provider locally rather than falling back with a stale credential. Direct Google Gemini API requests send the resolved key only in the `x-goog-api-key` header, never in the URL.
581
585
 
@@ -621,11 +625,15 @@ Bright Data Web Unlocker is a paid `fetch_content` fallback after Parallel and b
621
625
 
622
626
  **Serper.** Set `serperApiKey` or `SERPER_API_KEY` and select `provider: "serper"` to query Serper's Google Search API. Serper is explicit-only: it is never chosen by `auto` or `provider: "all"`, but it can be configured as the named provider or added to `searchRouting`.
623
627
 
628
+ **Baizhi.** Set `BAIZHI_API_KEY` (or `baizhiApiKey: "$BAIZHI_API_KEY"` in `web-search.json`) and explicitly select `provider: "baizhi"` to call [Baizhi Cloud Agent Toolkit](https://baizhi.cloud/landing/agent-toolkit)'s `websearch_search` over Streamable HTTP MCP. It is excluded from automatic selection and `all`; you may explicitly configure it as the default or in `searchRouting`. The client initializes an MCP session, sends your own key only in the Bearer header, and closes the session after each search. Requests and response bodies share a 60-second deadline and respect cancellation. Redirects are rejected. Queries and filters are sent to the hosted commercial service, and calls may consume credits; review your account permissions and balance. Domain include/exclude filters map to the tool's `filter` object; recency maps to `time_range`, which defaults to `month`. Generated summaries are disabled.
629
+
630
+ The Baizhi input contract is based on the [published September 16 discovery snapshot](https://github.com/ct-jaryn/baizhi-agent-toolkit/blob/b774c117369b8da2a1e42c7d01bf9535bb9a41c6/tests/fixtures/tools-history-2026-09-16.json); it has no output schema. This first version preserves MCP text and structured content as the provider answer rather than guessing result item fields. Consequently it does not populate source cards or fetch pages with `includeContent`. Tests use a synthetic MCP backend; live service results and billing have not been validated. This integration does not imply the hosted backend is open source.
631
+
624
632
  **Serply.** Set `serplyApiKey` or `SERPLY_API_KEY` and select `provider: "serply"` to query [Serply](https://serply.io)'s Google Search API. Serply is explicit-only: it is never chosen by `auto` or `provider: "all"`, but it can be configured as the named provider or added to `searchRouting`. The key is sent in the `X-Api-Key` request header. Domain filters are sent as Google `site:` clauses and reapplied locally; recency maps to Google's `tbs` time filter. Each request consumes Serply search credits; see the [API documentation](https://serply.io/docs).
625
633
 
626
634
  **Parallel MCP.** Select `provider: "parallel-mcp"` to use Parallel Search MCP without an API key, or add it to `searchRouting`. It is explicit-only and is never chosen by `auto` or `provider: "all"`; the existing `parallel` provider remains the key-required REST API. A configured `parallelApiKey` or `PARALLEL_API_KEY` is sent as an optional Bearer token for higher MCP limits. To use MCP `web_fetch`, add `parallel-mcp` to `fetchRouting.providers` and set `fetchRouting.allowRemoteHostedProviders` to `true`; it is not part of the default fetch route.
627
635
 
628
- Without an explicit `$` or `!` source, `OPENAI_API_KEY`, `BRAVE_API_KEY`, `PARALLEL_API_KEY`, `TINYFISH_API_KEY`, `SEARCH1API_KEY`, `SEARCHINFINITY_API_KEY`, `QUERIT_API_KEY`, `TAVILY_API_KEY`, `JINA_API_KEY`, `SERPDIVE_API_KEY`, `KAGI_API_KEY`, `BOCHA_API_KEY`, `OLLAMA_API_KEY`, `SERPBASE_API_KEY`, `SERPAPI_KEY`, `SERPER_API_KEY`, `SERPLY_API_KEY`, `ANYSEARCH_API_KEY`, `XCRAWL_API_KEY`, `VALYU_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `BRIGHTDATA_API_KEY`, `FIRECRAWL_API_KEY`, `CRAWL4AI_API_TOKEN`, `EXA_API_KEY`, `GEMINI_API_KEY`, `DATALAB_API_KEY`, `DATALAB_PROCESSING_LOCATION`, `DATALAB_MODE`, `DATALAB_API_BASE`, `PERPLEXITY_API_KEY`, `GOOGLE_GEMINI_BASE_URL`, and `CLOUDFLARE_API_KEY` env vars retain their existing precedence over literal config file values. `openaiResponsesUrl` can point OpenAI `web_search` and `source_check` at a third-party gateway that supports the OpenAI Responses API and web search tool; it is an explicit endpoint override, not derived from Pi model provider settings, and defaults to `https://api.openai.com/v1/responses`. `openaiSearchModel` pins the model id used for OpenAI `web_search`, bypassing automatic selection (newest terra-tier model); the id is sent verbatim with whichever OpenAI auth resolves, so gateway-only model ids work too. `xaiSearchModel` similarly pins the xAI search model. `openaiSearchProviders` sets which Pi model providers OpenAI `web_search` resolves login credentials from, in priority order; it defaults to `["openai-codex", "openai"]`, entries that are not registered or not signed in are skipped, and an empty array skips Pi credentials entirely so the `openaiApiKey` / `OPENAI_API_KEY` fallback applies. Useful for choosing between multiple Codex accounts (for example a second account registered by an extension) or forcing API-key billing while signed into Codex. Configured Exa API keys use Exa's own account limits directly; any legacy local `exa-usage.json` file is ignored. `GOOGLE_GEMINI_BASE_URL` overrides the Gemini API host for Gemini generate-content calls such as search, URL context, YouTube, and local video analysis. Set it to a bare host with no trailing slash and no version segment, for example `https://my-gateway.example.com/gemini`; `geminiBaseUrl` is the config-file equivalent. When the configured host contains `gateway.ai.cloudflare.com`, authentication uses `cf-aig-authorization: Bearer <token>` from `CLOUDFLARE_API_KEY` or `cloudflareApiKey`, and `GEMINI_API_KEY` is not required for generate-content calls. Alternatively, set `geminiAuth` to `"adc"` to authenticate Gemini generate-content calls with Google Application Default Credentials (ADC) instead of an API key; calls go to the Vertex AI endpoint (`aiplatform.googleapis.com`) with an OAuth bearer token minted from the ADC file (`GOOGLE_APPLICATION_CREDENTIALS` or `~/.config/gcloud/application_default_credentials.json`, i.e. `gcloud auth application-default login`). `geminiProject`/`geminiLocation` set the Vertex project and location and fall back to the `GOOGLE_CLOUD_PROJECT`/`GOOGLE_CLOUD_LOCATION` (or `GCLOUD_PROJECT`) env vars; project and location are required. ADC supports `authorized_user` (OAuth refresh token) and `service_account` (JWT assertion) credential files, and tokens are cached and refreshed from expiry. ADC mode covers search, URL context, and PDF/inline-data extraction; YouTube and local video analysis still go through the Gemini Files API, so they fall back to Gemini Web unless a `GEMINI_API_KEY` is also configured. The access token is treated as a credential and is redacted from errors. Local video file upload still uses Google's Files API directly, so gateway-only video extraction falls back to Gemini Web unless a `GEMINI_API_KEY` is also configured. `provider` or `searchProvider` sets the default search provider and is used when a tool call omits `provider` or sends `"auto"`: `"all"`, `"openai"`, `"brave"`, `"parallel"`, `"parallel-mcp"`, `"tinyfish"`, `"search1api"`, `"searchinfinity"`, `"querit"`, `"tavily"`, `"firecrawl"`, `"jina"`, `"serpdive"`, `"kagi"`, `"bocha"`, `"ollama"`, `"anysearch"`, `"xcrawl"`, `"valyu"`, `"xai"`, `"mistral"`, `"brightdata"`, `"serpbase"`, `"serpapi"`, `"serper"`, `"serply"`, `"searxng"`, `"exa"`, `"perplexity"`, or `"gemini"`. Parallel MCP, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, SerpApi, Serper, and Serply are never selected by `auto`; choose them explicitly or place them in `searchRouting`. If either single-provider field is configured, it takes precedence over `searchRouting`. Otherwise, `searchRouting` can opt into an ordered `providers` list and an explicit `fallbackOn` list containing `"transient"`, `"quota"`, `"network"`, and/or `"invalid-response"`; only those typed failures continue to the next available candidate. `"all"` is not valid inside `searchRouting.providers`, because that list defines sequential fallback rather than multi-provider aggregation. Named providers remain strict, and exhausted routes return per-provider diagnostics. `provider` can also be a non-empty array of named providers such as `["brave", "exa"]`; those providers run concurrently using the same aggregation path as `"all"`, while `"auto"` and `"all"` are invalid inside arrays. Random, weighted, sticky, and cooldown routing are not enabled. This is also updated automatically when you change the provider in the curator UI. Set `webSearch.enabled` to `false` to unregister the configured search and source-check tools while leaving fetch/content tools available. `toolNames` can opt into alternate public tool names for environments where another extension or model reserves the defaults, without changing behavior: `webSearch`, `sourceCheck`, `fetchContent`, and `getSearchContent` default to `web_search`, `source_check`, `fetch_content`, and `get_search_content`. `workflow` sets the default search workflow: `"none"` (default; raw results, no curator), `"summary-review"` (opens curator with an auto-generated summary draft), or `"auto-summary"` (returns a model-generated summary without opening the browser curator). Overridden per-call via the `workflow` parameter on the configured search tool, or toggled at runtime with `/curator`. `browserCookies.profile` pins Gemini Web cookie lookup to a specific Chromium profile. When omitted, detected Chromium profiles are scanned in stable order and the first profile containing the required Gemini cookies is used. macOS discovery supports Helium, Chrome, Brave, and Arc; Linux discovery supports Chromium and Chrome. `allowBrowserCookies` enables Chromium cookie extraction for Gemini Web; it defaults to `false` to avoid browser data access and surprise macOS Keychain prompts. You can also set `PI_ALLOW_BROWSER_COOKIES=1`. Cookie databases are copied to a temporary read-only working copy; the reader uses `node:sqlite` when available and otherwise tries the `sqlite3` CLI or Python's standard-library SQLite module. `searchModel` overrides the Gemini API model used by the configured search tool without changing URL, YouTube, or video extraction defaults. Gemini API grounded search uses `gemini-3.6-flash` by default; set `searchModel` to choose another model. Gemini Web browser-cookie fallback uses its separate `gemini-3.1-pro` default because Gemini Web relies on private header values; explicitly configured unsupported Web models fail instead of silently falling back to 2.5 Flash. `summaryModel` sets the default model used for generating summary drafts in the curator UI and `auto-summary` mode (e.g. `"anthropic/claude-haiku-4-5"`, `"openai-codex/gpt-5.3-codex-spark"`, or `"openrouter/nvidia/nemotron-3-super-120b-a12b:free"`). Preferred summary and query-rewrite models also resolve through routed provider registrations such as OpenRouter when the native provider is unavailable. When Pi `enabledModels` is configured, summaries are limited to that allowlist; if no enabled summary model is available, the tool returns a deterministic summary instead of calling an unrelated model. `summaryGenerationDeadlineMs` sets the maximum time for one summary model attempt in the curator UI and `auto-summary` mode. It defaults to `30000`, must be a positive integer, and is capped at `600000`. `maxInlineContentChars` sets the direct `fetch_content` content slice and the default and maximum `get_search_content` slice. It defaults to `30000`, must be a positive integer, and is capped at `200000`; full fetched content remains stored for later retrieval. `curatorTimeoutSeconds` controls the initial curator idle timeout (default `20`, max `600`); users can still adjust the timer in the curator UI. `ssrf.allowRanges` lists CIDR ranges (e.g. `"198.18.0.0/15"`, `"fd00::/8"`) exempted from the SSRF guard that otherwise blocks private/reserved IP ranges. This unblocks `fetch_content`/`web_search` on hosts whose network proxy runs in TUN + fake-IP mode (Surge, Clash, Mihomo, Stash, ...), where public domains resolve into a synthetic reserved range. It is **off by default** — the guard stays fully enabled unless you list ranges here. Use the narrowest range that covers your proxy's fake-IP pool. All-address CIDRs such as `0.0.0.0/0` and `::/0` are rejected. `ssrf.trustEnvProxy` is a separate opt-in for sandboxed environments with valid HTTP(S) proxy env vars; it skips local DNS preflight only for proxied hostnames and still blocks localhost, literal private IP targets, and `NO_PROXY` matches. It does not configure proxy transport.
636
+ Without an explicit `$` or `!` source, `OPENAI_API_KEY`, `BRAVE_API_KEY`, `PARALLEL_API_KEY`, `TINYFISH_API_KEY`, `SEARCH1API_KEY`, `SEARCHINFINITY_API_KEY`, `QUERIT_API_KEY`, `TAVILY_API_KEY`, `JINA_API_KEY`, `SERPDIVE_API_KEY`, `KAGI_API_KEY`, `BOCHA_API_KEY`, `OLLAMA_API_KEY`, `SERPBASE_API_KEY`, `SERPAPI_KEY`, `SERPER_API_KEY`, `SERPLY_API_KEY`, `BAIZHI_API_KEY`, `ANYSEARCH_API_KEY`, `XCRAWL_API_KEY`, `VALYU_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `BRIGHTDATA_API_KEY`, `FIRECRAWL_API_KEY`, `CRAWL4AI_API_TOKEN`, `EXA_API_KEY`, `GEMINI_API_KEY`, `DATALAB_API_KEY`, `DATALAB_PROCESSING_LOCATION`, `DATALAB_MODE`, `DATALAB_API_BASE`, `PERPLEXITY_API_KEY`, `GOOGLE_GEMINI_BASE_URL`, and `CLOUDFLARE_API_KEY` env vars retain their existing precedence over literal config file values. `openaiResponsesUrl` can point OpenAI `web_search` and `source_check` at a third-party gateway that supports the OpenAI Responses API and web search tool; it is an explicit endpoint override, not derived from Pi model provider settings, and defaults to `https://api.openai.com/v1/responses`. `openaiSearchModel` pins the model id used for OpenAI `web_search`, bypassing automatic selection (newest terra-tier model); the id is sent verbatim with whichever OpenAI auth resolves, so gateway-only model ids work too. `xaiSearchModel` similarly pins the xAI search model. `openaiSearchProviders` sets which Pi model providers OpenAI `web_search` resolves login credentials from, in priority order; it defaults to `["openai-codex", "openai"]`, entries that are not registered or not signed in are skipped, and an empty array skips Pi credentials entirely so the `openaiApiKey` / `OPENAI_API_KEY` fallback applies. Useful for choosing between multiple Codex accounts (for example a second account registered by an extension) or forcing API-key billing while signed into Codex. Configured Exa API keys use Exa's own account limits directly; any legacy local `exa-usage.json` file is ignored. `GOOGLE_GEMINI_BASE_URL` overrides the Gemini API host for Gemini generate-content calls such as search, URL context, YouTube, and local video analysis. Set it to a bare host with no trailing slash and no version segment, for example `https://my-gateway.example.com/gemini`; `geminiBaseUrl` is the config-file equivalent. When the configured host contains `gateway.ai.cloudflare.com`, authentication uses `cf-aig-authorization: Bearer <token>` from `CLOUDFLARE_API_KEY` or `cloudflareApiKey`, and `GEMINI_API_KEY` is not required for generate-content calls. Alternatively, set `geminiAuth` to `"adc"` to authenticate Gemini generate-content calls with Google Application Default Credentials (ADC) instead of an API key; calls go to the Vertex AI endpoint (`aiplatform.googleapis.com`) with an OAuth bearer token minted from the ADC file (`GOOGLE_APPLICATION_CREDENTIALS` or `~/.config/gcloud/application_default_credentials.json`, i.e. `gcloud auth application-default login`). `geminiProject`/`geminiLocation` set the Vertex project and location and fall back to the `GOOGLE_CLOUD_PROJECT`/`GOOGLE_CLOUD_LOCATION` (or `GCLOUD_PROJECT`) env vars; project and location are required. ADC supports `authorized_user` (OAuth refresh token) and `service_account` (JWT assertion) credential files, and tokens are cached and refreshed from expiry. ADC mode covers search, URL context, and PDF/inline-data extraction; YouTube and local video analysis still go through the Gemini Files API, so they fall back to Gemini Web unless a `GEMINI_API_KEY` is also configured. The access token is treated as a credential and is redacted from errors. Local video file upload still uses Google's Files API directly, so gateway-only video extraction falls back to Gemini Web unless a `GEMINI_API_KEY` is also configured. `provider` or `searchProvider` sets the default search provider and is used when a tool call omits `provider` or sends `"auto"`: `"all"`, `"openai"`, `"brave"`, `"parallel"`, `"parallel-mcp"`, `"tinyfish"`, `"search1api"`, `"searchinfinity"`, `"querit"`, `"tavily"`, `"firecrawl"`, `"jina"`, `"serpdive"`, `"kagi"`, `"bocha"`, `"ollama"`, `"anysearch"`, `"xcrawl"`, `"valyu"`, `"xai"`, `"mistral"`, `"brightdata"`, `"serpbase"`, `"serpapi"`, `"serper"`, `"serply"`, `"baizhi"`, `"searxng"`, `"exa"`, `"perplexity"`, or `"gemini"`. Parallel MCP, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, SerpApi, Serper, Serply, and Baizhi are never selected by `auto`; choose them explicitly or place them in `searchRouting`. If either single-provider field is configured, it takes precedence over `searchRouting`. Otherwise, `searchRouting` can opt into an ordered `providers` list and an explicit `fallbackOn` list containing `"transient"`, `"quota"`, `"network"`, and/or `"invalid-response"`; only those typed failures continue to the next available candidate. `"all"` is not valid inside `searchRouting.providers`, because that list defines sequential fallback rather than multi-provider aggregation. Named providers remain strict, and exhausted routes return per-provider diagnostics. `provider` can also be a non-empty array of named providers such as `["brave", "exa"]`; those providers run concurrently using the same aggregation path as `"all"`, while `"auto"` and `"all"` are invalid inside arrays. Random, weighted, sticky, and cooldown routing are not enabled. This is also updated automatically when you change the provider in the curator UI. Set `webSearch.enabled` to `false` to unregister the configured search and source-check tools while leaving fetch/content tools available. `toolNames` can opt into alternate public tool names for environments where another extension or model reserves the defaults, without changing behavior: `webSearch`, `sourceCheck`, `fetchContent`, and `getSearchContent` default to `web_search`, `source_check`, `fetch_content`, and `get_search_content`. `workflow` sets the default search workflow: `"none"` (default; raw results, no curator), `"summary-review"` (opens curator with an auto-generated summary draft), or `"auto-summary"` (returns a model-generated summary without opening the browser curator). Overridden per-call via the `workflow` parameter on the configured search tool, or toggled at runtime with `/curator`. `browserCookies.profile` pins Gemini Web cookie lookup to a specific Chromium profile. When omitted, detected Chromium profiles are scanned in stable order and the first profile containing the required Gemini cookies is used. macOS discovery supports Helium, Chrome, Brave, and Arc; Linux discovery supports Chromium and Chrome. `allowBrowserCookies` enables Chromium cookie extraction for Gemini Web; it defaults to `false` to avoid browser data access and surprise macOS Keychain prompts. You can also set `PI_ALLOW_BROWSER_COOKIES=1`. Cookie databases are copied to a temporary read-only working copy; the reader uses `node:sqlite` when available and otherwise tries the `sqlite3` CLI or Python's standard-library SQLite module. `searchModel` overrides the Gemini API model used by the configured search tool without changing URL, YouTube, or video extraction defaults. Gemini API grounded search uses `gemini-3.6-flash` by default; set `searchModel` to choose another model. Gemini Web browser-cookie fallback uses its separate `gemini-3.1-pro` default because Gemini Web relies on private header values; explicitly configured unsupported Web models fail instead of silently falling back to 2.5 Flash. `summaryModel` sets the default model used for generating summary drafts in the curator UI and `auto-summary` mode (e.g. `"anthropic/claude-haiku-4-5"`, `"openai-codex/gpt-5.3-codex-spark"`, or `"openrouter/nvidia/nemotron-3-super-120b-a12b:free"`). Preferred summary and query-rewrite models also resolve through routed provider registrations such as OpenRouter when the native provider is unavailable. When Pi `enabledModels` is configured, summaries are limited to that allowlist; if no enabled summary model is available, the tool returns a deterministic summary instead of calling an unrelated model. `summaryGenerationDeadlineMs` sets the maximum time for one summary model attempt in the curator UI and `auto-summary` mode. It defaults to `30000`, must be a positive integer, and is capped at `600000`. `maxInlineContentChars` sets the direct `fetch_content` content slice and the default and maximum `get_search_content` slice. It defaults to `30000`, must be a positive integer, and is capped at `200000`; full fetched content remains stored for later retrieval. `curatorTimeoutSeconds` controls the initial curator idle timeout (default `20`, max `600`); users can still adjust the timer in the curator UI. `ssrf.allowRanges` lists CIDR ranges (e.g. `"198.18.0.0/15"`, `"fd00::/8"`) exempted from the SSRF guard that otherwise blocks private/reserved IP ranges. This unblocks `fetch_content`/`web_search` on hosts whose network proxy runs in TUN + fake-IP mode (Surge, Clash, Mihomo, Stash, ...), where public domains resolve into a synthetic reserved range. It is **off by default** — the guard stays fully enabled unless you list ranges here. Use the narrowest range that covers your proxy's fake-IP pool. All-address CIDRs such as `0.0.0.0/0` and `::/0` are rejected. `ssrf.trustEnvProxy` is a separate opt-in for sandboxed environments with valid HTTP(S) proxy env vars; it skips local DNS preflight only for proxied hostnames and still blocks localhost, literal private IP targets, and `NO_PROXY` matches. It does not configure proxy transport.
629
637
  ### Kimi Code Plan
630
638
 
631
639
  Run `/login kimi-coding` in Pi and complete sign-in for an active Kimi Code Plan. Then select `provider: "kimi"`, include `"kimi"` in an explicit provider array, or add it to `searchRouting.providers`. The extension resolves a model with provider `kimi-coding` from Pi's model registry and reuses Pi's refreshed OAuth credential; no Moonshot Open Platform key is configured here.
@@ -636,7 +644,7 @@ Kimi is explicit-only: it is never chosen by `auto` and never participates in `p
636
644
 
637
645
  ### All providers
638
646
 
639
- Set `provider: "all"` on `web_search` or `source_check`, or configure `"provider": "all"` as the default, to run the same query against every eligible search provider simultaneously. Parallel MCP, DuckDuckGo, Kimi, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, SerpApi, Serper, and Serply are always excluded because they are explicit-only; Bright Data, SerpBase, SerpApi, Serper, and Serply are paid Google SERP providers, while Kimi and Mistral may consume account quota or paid search-tool usage, so `all` never spends either resource without an explicit request. Exa remains eligible through its zero-config MCP path, OpenAI can use Pi auth, and other API-backed search providers participate when their API key, local endpoint, or gateway makes them available. Browser-cookie access alone does not opt Gemini into `all`; select Gemini explicitly or configure its API/gateway.
647
+ Set `provider: "all"` on `web_search` or `source_check`, or configure `"provider": "all"` as the default, to run the same query against every eligible search provider simultaneously. Parallel MCP, DuckDuckGo, Kimi, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, SerpApi, Serper, Serply, and Baizhi are always excluded because they are explicit-only; Bright Data, SerpBase, SerpApi, Serper, and Serply are paid Google SERP providers, while Baizhi, Kimi, and Mistral may consume account quota or paid search-tool usage, so `all` never spends either resource without an explicit request. Exa remains eligible through its zero-config MCP path, OpenAI can use Pi auth, and other API-backed search providers participate when their API key, local endpoint, or gateway makes them available. Browser-cookie access alone does not opt Gemini into `all`; select Gemini explicitly or configure its API/gateway.
640
648
 
641
649
  Successful provider answers are preserved separately while source URLs and inline content are deduplicated, and one provider failure does not discard the other results. If every participating provider fails, the tool returns per-provider diagnostics. Configured Firecrawl participates in `all` like other eligible providers. In the Curator, **All** can also be selected like the other provider buttons. Each participating provider gets its own result card, including a provider badge and independent selection checkbox; failed providers get their own disabled error card. The final summary is generated from the selected provider cards and is what Pi receives. Outside the Curator, the same provider answers remain available as labeled sections in one tool response.
642
650
 
@@ -975,6 +983,10 @@ Remote curator sessions print the URL instead of trying to open a browser by def
975
983
 
976
984
  When `false`, a requested Curator session never tries to open a Glimpse window or a browser and always prints the URL for you to open manually. For requested local-only Curator sessions it defaults to `true`; remote curator sessions print the URL unless you set `autoOpenBrowser: true` explicitly. This is worth setting locally when you would rather paste the link into a specific browser than have one launched for you. It changes nothing about where the server binds; that is `curatorRemote`'s job alone.
977
985
 
986
+ ### Tool activation
987
+
988
+ Pi starts normal sessions with the compact `web_enable` tool. Its guidance lists only the web capabilities enabled in `web-search.json`. The model can call it without a human toggle; the configured search, source-check, fetch, and stored-content tools then appear on the immediately following model request. Disabled tools stay unregistered, and alternate `toolNames` are preserved. Warm and resumed sessions retain their recorded tool selection.
989
+
978
990
  ### Shortcuts
979
991
 
980
992
  Both shortcuts are configurable via `~/.pi/agent/web-search.json`:
@@ -1033,6 +1045,7 @@ Rate limits: Perplexity is capped at 10 requests/minute (client-side). Jina Sear
1033
1045
  | `serpapi.ts` | Explicit-only SerpApi Google Search provider |
1034
1046
  | `serper.ts` | Explicit-only Serper Google SERP provider |
1035
1047
  | `serply.ts` | Explicit-only Serply Google SERP provider |
1048
+ | `baizhi.ts` | Explicit-only Baizhi Streamable HTTP MCP search provider |
1036
1049
  | `anysearch.ts` | Explicit-only AnySearch search provider |
1037
1050
  | `xcrawl.ts` | Explicit-only XCrawl search provider |
1038
1051
  | `valyu.ts` | Explicit-only Valyu research search provider |
@@ -0,0 +1,124 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { isIP } from "node:net";
3
+ import { activityMonitor } from "./activity.ts";
4
+ import { hasCredentialSource, redactCredential, resolveCredential } from "./credential-source.ts";
5
+ import { normalizeDomain } from "./domain-filter-normalization.ts";
6
+ import type { SearchOptions, SearchResponse } from "./perplexity.ts";
7
+ import { normalizeSearchResultCount } from "./search-result-count-normalization.ts";
8
+ import { getWebSearchConfigPath } from "./utils.ts";
9
+
10
+ const MCP_URL = "https://agent-toolkit.app.baizhi.cloud/mcp";
11
+ const SEARCH_TIMEOUT_MS = 60_000;
12
+ const CONFIG_PATH = getWebSearchConfigPath();
13
+
14
+ function credentialOptions() {
15
+ let config: Record<string, unknown> = {};
16
+ if (existsSync(CONFIG_PATH)) {
17
+ try {
18
+ const parsed: unknown = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
19
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
20
+ config = parsed as Record<string, unknown>;
21
+ } catch {
22
+ // JSON parser errors may include a fragment containing a literal key.
23
+ throw new Error(`Invalid configuration in ${CONFIG_PATH}: expected a JSON object`);
24
+ }
25
+ }
26
+ return { provider: "Baizhi", configuredValue: config.baizhiApiKey, environmentValue: process.env.BAIZHI_API_KEY };
27
+ }
28
+
29
+ export function isBaizhiAvailable(): boolean {
30
+ return hasCredentialSource(credentialOptions());
31
+ }
32
+
33
+ function searchArguments(query: string, options: SearchOptions): Record<string, unknown> {
34
+ if (!query.trim()) throw new Error("Baizhi search query must not be empty");
35
+ const domains: string[] = [];
36
+ const excludeDomains: string[] = [];
37
+ for (const raw of options.domainFilter ?? []) {
38
+ const bare = raw.trim().replace(/^-/, "").trim();
39
+ const domain = isIP(bare) ? bare : normalizeDomain(raw);
40
+ if (!domain) throw new Error("Baizhi domain filter must contain a valid domain");
41
+ const target = raw.trim().startsWith("-") ? excludeDomains : domains;
42
+ if (!target.includes(domain)) target.push(domain);
43
+ }
44
+ return {
45
+ query: query.trim(),
46
+ count: normalizeSearchResultCount(options.numResults),
47
+ need_summary: false,
48
+ // The published discovery contract defaults to month, not unrestricted time.
49
+ time_range: options.recencyFilter ?? "month",
50
+ ...(domains.length || excludeDomains.length ? { filter: {
51
+ ...(domains.length ? { domains } : {}),
52
+ ...(excludeDomains.length ? { exclude_domains: excludeDomains } : {}),
53
+ } } : {}),
54
+ };
55
+ }
56
+
57
+ export async function searchWithBaizhi(query: string, options: SearchOptions = {}): Promise<SearchResponse> {
58
+ const args = searchArguments(query, options);
59
+ const timeoutSignal = AbortSignal.timeout(SEARCH_TIMEOUT_MS);
60
+ const signal = options.signal ? AbortSignal.any([timeoutSignal, options.signal]) : timeoutSignal;
61
+ if (signal.aborted) throw new Error("Aborted");
62
+ const apiKey = await resolveCredential({ ...credentialOptions(), signal });
63
+ if (!apiKey) throw new Error("Baizhi API key not found. Set BAIZHI_API_KEY or baizhiApiKey in web-search.json. Get your own key at https://agent-toolkit.app.baizhi.cloud/");
64
+ let headers: Headers;
65
+ try { headers = new Headers({ Authorization: `Bearer ${apiKey}` }); }
66
+ catch { throw new Error("Baizhi credential resolution failed: invalid-header-value"); }
67
+ // Load the protocol client only when this explicit provider is selected.
68
+ const [{ Client }, { StreamableHTTPClientTransport }] = await Promise.all([
69
+ import("@modelcontextprotocol/sdk/client/index.js"),
70
+ import("@modelcontextprotocol/sdk/client/streamableHttp.js"),
71
+ ]);
72
+ let cleaningUp = false;
73
+ const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
74
+ requestInit: { headers },
75
+ fetch: async (url, init) => {
76
+ // Fixed endpoint, no redirects: neither credentials nor session IDs can
77
+ // be forwarded to another origin (including MCP discovery redirects).
78
+ if (String(url) !== MCP_URL) throw new Error("Baizhi endpoint changed unexpectedly");
79
+ const requestSignal = cleaningUp ? AbortSignal.timeout(1_500) : signal;
80
+ return fetch(url, { ...init, redirect: "error", signal: !cleaningUp && init?.signal ? AbortSignal.any([init.signal, requestSignal]) : requestSignal });
81
+ },
82
+ reconnectionOptions: { maxRetries: 0, initialReconnectionDelay: 1_000, maxReconnectionDelay: 1_000, reconnectionDelayGrowFactor: 1 },
83
+ });
84
+ const client = new Client({ name: "pi-web-access-baizhi", version: "1.0.0" });
85
+ const activityId = activityMonitor.logStart({ type: "api", query });
86
+ try {
87
+ await client.connect(transport, { signal, timeout: SEARCH_TIMEOUT_MS });
88
+ const result = await client.callTool({ name: "websearch_search", arguments: args }, undefined, { signal, timeout: SEARCH_TIMEOUT_MS });
89
+ if (result.isError) throw new Error("Baizhi search tool returned an error");
90
+ // The public discovery snapshot documents inputs but no outputSchema.
91
+ // Preserve the MCP payload instead of guessing result item field names.
92
+ const parts: string[] = [];
93
+ if (result.structuredContent !== undefined) parts.push(JSON.stringify(result.structuredContent, null, 2));
94
+ for (const item of Array.isArray(result.content) ? result.content : []) {
95
+ if (item.type === "text" && typeof item.text === "string" && item.text.trim()) parts.push(item.text);
96
+ }
97
+ if (!parts.length) throw new Error("Baizhi returned invalid response: no search text or structured content");
98
+ const answer = redactCredential(redactCredential(parts.join("\n\n"), apiKey), JSON.stringify(apiKey).slice(1, -1));
99
+ activityMonitor.logComplete(activityId, 200);
100
+ return { answer, results: [] };
101
+ } catch (err) {
102
+ let message: string;
103
+ if (options.signal?.aborted) message = "Aborted";
104
+ else if (timeoutSignal.aborted) message = "Baizhi request timed out after 60s";
105
+ else {
106
+ const code = err && typeof err === "object" && "code" in err ? err.code : undefined;
107
+ // Never expose remote HTTP/JSON-RPC bodies or nested SDK errors: they
108
+ // can echo credentials in plain, encoded or transformed form.
109
+ message = typeof code === "number" && Number.isInteger(code) && code >= 400 && code <= 599
110
+ ? `Baizhi HTTP ${code} request failed; check your API key, account and service status`
111
+ : err instanceof TypeError ? "Baizhi network request failed"
112
+ : err instanceof SyntaxError || code === -32700 || code === -32602 ? "Baizhi returned invalid response"
113
+ : err instanceof Error && /^Baizhi (search tool returned an error|returned invalid response:)/.test(err.message) ? err.message
114
+ : "Baizhi MCP request failed; check your connection, API key and account status";
115
+ }
116
+ activityMonitor.logError(activityId, message);
117
+ throw new Error(message);
118
+ } finally {
119
+ cleaningUp = true;
120
+ // Best effort, separately bounded DELETE; always close the local streams.
121
+ try { await transport.terminateSession(); } catch { /* Server may not support DELETE. */ }
122
+ await client.close();
123
+ }
124
+ }
@@ -1,10 +1,9 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import net from "node:net";
3
2
  import { activityMonitor } from "./activity.ts";
4
3
  import { redactCredential, resolveCredential } from "./credential-source.ts";
5
4
  import type { ExtractedContent, ExtractOptions } from "./extract.ts";
6
5
  import { fetchRemoteUrl, loadSsrfConfig, validateRemoteUrl, type Lookup, type SsrfConfig } from "./ssrf-protection.ts";
7
- import { getWebSearchConfigPath } from "./utils.ts";
6
+ import { getWebSearchConfigPath, isLoopbackHostname } from "./utils.ts";
8
7
 
9
8
  const CONFIG_PATH = getWebSearchConfigPath();
10
9
  const EXTRACT_TIMEOUT_MS = 60_000;
@@ -113,13 +112,6 @@ function ssrfOptions(options?: Crawl4aiExtractOptions): SsrfConfig & { lookup?:
113
112
  };
114
113
  }
115
114
 
116
- function isLoopbackApiUrl(url: URL): boolean {
117
- const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
118
- if (hostname === "localhost" || hostname === "::1") return true;
119
- if (net.isIP(hostname) !== 4) return false;
120
- return hostname.split(".")[0] === "127";
121
- }
122
-
123
115
  function firstHeadingTitle(markdown: string): string {
124
116
  return /^[ \t]*#[ \t]+(\S.*?)[ \t\r]*$/m.exec(markdown)?.[1] ?? "";
125
117
  }
@@ -151,7 +143,7 @@ export async function extractWithCrawl4ai(
151
143
  try {
152
144
  const response = await fetchRemoteUrl(requestUrl, init, {
153
145
  ...ssrf,
154
- allowLoopback: isLoopbackApiUrl(requestUrl),
146
+ allowLoopback: isLoopbackHostname(requestUrl.hostname),
155
147
  onRedirect: ({ to, init: redirectInit, response }) => {
156
148
  if (to.origin !== requestUrl.origin) {
157
149
  throw new Error(`Crawl4AI refused cross-origin redirect to ${to.origin}`);
@@ -46,6 +46,7 @@ function buildProviderButtons(
46
46
  { value: "serpapi", label: "SerpApi", available: available.serpapi },
47
47
  { value: "serper", label: "Serper", available: available.serper },
48
48
  { value: "serply", label: "Serply", available: available.serply },
49
+ { value: "baizhi", label: "Baizhi", available: available.baizhi },
49
50
  { value: "valyu", label: "Valyu", available: available.valyu },
50
51
  ];
51
52
 
@@ -1473,7 +1474,7 @@ const SCRIPT = `(function() {
1473
1474
  var token = DATA.sessionToken;
1474
1475
  var timeoutSec = DATA.timeout;
1475
1476
  var queries = Array.isArray(DATA.queries) ? DATA.queries : [];
1476
- var providers = ["auto", "all", "openai", "exa", "brave", "parallel", "parallel-mcp", "tinyfish", "search1api", "searchinfinity", "querit", "tavily", "firecrawl", "jina", "serpdive", "kagi", "bocha", "ollama", "searxng", "duckduckgo", "perplexity", "gemini", "kimi", "anysearch", "xcrawl", "xai", "mistral", "brightdata", "serpbase", "serpapi", "serper", "serply", "valyu"];
1477
+ var providers = ["auto", "all", "openai", "exa", "brave", "parallel", "parallel-mcp", "tinyfish", "search1api", "searchinfinity", "querit", "tavily", "firecrawl", "jina", "serpdive", "kagi", "bocha", "ollama", "searxng", "duckduckgo", "perplexity", "gemini", "kimi", "anysearch", "xcrawl", "xai", "mistral", "brightdata", "serpbase", "serpapi", "serper", "serply", "valyu", "baizhi"];
1477
1478
  var availProviders = DATA.availableProviders && typeof DATA.availableProviders === "object" ? DATA.availableProviders : {};
1478
1479
  var workflow = "summary-review";
1479
1480
  var initialDefaultProvider = typeof DATA.defaultProvider === "string" ? DATA.defaultProvider : "exa";
@@ -1708,6 +1709,7 @@ const SCRIPT = `(function() {
1708
1709
  if (provider === "serpapi") return "SerpApi";
1709
1710
  if (provider === "serper") return "Serper";
1710
1711
  if (provider === "serply") return "Serply";
1712
+ if (provider === "baizhi") return "Baizhi";
1711
1713
  if (provider === "valyu") return "Valyu";
1712
1714
  return "Unknown";
1713
1715
  }
@@ -305,6 +305,7 @@ export function startCuratorServer(
305
305
  if (provider === "serpbase") return availableProviders.serpbase;
306
306
  if (provider === "serper") return availableProviders.serper;
307
307
  if (provider === "serply") return availableProviders.serply;
308
+ if (provider === "baizhi") return availableProviders.baizhi;
308
309
  if (provider === "valyu") return availableProviders.valyu;
309
310
  return false;
310
311
  }