@oh-my-pi/pi-coding-agent 15.10.7 → 15.10.9

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 (80) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/types/config/model-registry.d.ts +4 -2
  3. package/dist/types/config/model-resolver.d.ts +2 -0
  4. package/dist/types/config/settings-schema.d.ts +9 -0
  5. package/dist/types/extensibility/custom-tools/loader.d.ts +22 -3
  6. package/dist/types/extensibility/custom-tools/types.d.ts +3 -1
  7. package/dist/types/extensibility/extensions/index.d.ts +1 -1
  8. package/dist/types/extensibility/extensions/loader.d.ts +17 -1
  9. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
  10. package/dist/types/mcp/oauth-discovery.d.ts +4 -1
  11. package/dist/types/mcp/oauth-flow.d.ts +6 -1
  12. package/dist/types/mcp/transports/stdio.d.ts +12 -0
  13. package/dist/types/modes/components/custom-editor.d.ts +3 -2
  14. package/dist/types/sdk.d.ts +42 -2
  15. package/dist/types/task/executor.d.ts +16 -0
  16. package/dist/types/tools/fetch.d.ts +2 -1
  17. package/dist/types/tools/index.d.ts +20 -1
  18. package/dist/types/tools/report-tool-issue.d.ts +5 -0
  19. package/dist/types/tui/hyperlink.d.ts +8 -0
  20. package/dist/types/web/kagi.d.ts +2 -1
  21. package/dist/types/web/parallel.d.ts +3 -0
  22. package/dist/types/web/search/providers/anthropic.d.ts +2 -1
  23. package/dist/types/web/search/providers/base.d.ts +2 -1
  24. package/dist/types/web/search/providers/brave.d.ts +2 -1
  25. package/dist/types/web/search/providers/codex.d.ts +2 -1
  26. package/dist/types/web/search/providers/exa.d.ts +2 -1
  27. package/dist/types/web/search/providers/gemini.d.ts +2 -1
  28. package/dist/types/web/search/providers/jina.d.ts +7 -2
  29. package/dist/types/web/search/providers/kagi.d.ts +7 -2
  30. package/dist/types/web/search/providers/kimi.d.ts +7 -2
  31. package/dist/types/web/search/providers/parallel.d.ts +2 -1
  32. package/dist/types/web/search/providers/perplexity.d.ts +2 -1
  33. package/dist/types/web/search/providers/searxng.d.ts +2 -1
  34. package/dist/types/web/search/providers/synthetic.d.ts +7 -3
  35. package/dist/types/web/search/providers/tavily.d.ts +2 -1
  36. package/dist/types/web/search/providers/zai.d.ts +2 -1
  37. package/package.json +9 -9
  38. package/src/config/model-registry.ts +13 -7
  39. package/src/config/model-resolver.ts +57 -2
  40. package/src/config/settings-schema.ts +6 -0
  41. package/src/extensibility/custom-tools/loader.ts +43 -19
  42. package/src/extensibility/custom-tools/types.ts +3 -1
  43. package/src/extensibility/extensions/index.ts +1 -0
  44. package/src/extensibility/extensions/loader.ts +29 -6
  45. package/src/extensibility/plugins/legacy-pi-compat.ts +30 -6
  46. package/src/internal-urls/docs-index.generated.ts +1 -1
  47. package/src/mcp/oauth-discovery.ts +8 -3
  48. package/src/mcp/oauth-flow.ts +12 -5
  49. package/src/mcp/transports/stdio.ts +139 -3
  50. package/src/modes/components/assistant-message.ts +28 -6
  51. package/src/modes/components/custom-editor.ts +69 -9
  52. package/src/modes/components/transcript-container.ts +77 -25
  53. package/src/modes/controllers/input-controller.ts +1 -1
  54. package/src/modes/controllers/mcp-command-controller.ts +2 -2
  55. package/src/sdk.ts +138 -56
  56. package/src/ssh/ssh-executor.ts +60 -4
  57. package/src/task/executor.ts +19 -0
  58. package/src/task/index.ts +4 -0
  59. package/src/tools/fetch.ts +22 -5
  60. package/src/tools/image-gen.ts +33 -11
  61. package/src/tools/index.ts +21 -2
  62. package/src/tools/report-tool-issue.ts +7 -1
  63. package/src/tui/hyperlink.ts +27 -3
  64. package/src/web/kagi.ts +5 -2
  65. package/src/web/parallel.ts +7 -3
  66. package/src/web/search/providers/anthropic.ts +5 -1
  67. package/src/web/search/providers/base.ts +2 -1
  68. package/src/web/search/providers/brave.ts +5 -2
  69. package/src/web/search/providers/codex.ts +6 -2
  70. package/src/web/search/providers/exa.ts +91 -8
  71. package/src/web/search/providers/gemini.ts +6 -0
  72. package/src/web/search/providers/jina.ts +15 -5
  73. package/src/web/search/providers/kagi.ts +9 -2
  74. package/src/web/search/providers/kimi.ts +18 -4
  75. package/src/web/search/providers/parallel.ts +6 -2
  76. package/src/web/search/providers/perplexity.ts +7 -4
  77. package/src/web/search/providers/searxng.ts +6 -2
  78. package/src/web/search/providers/synthetic.ts +9 -5
  79. package/src/web/search/providers/tavily.ts +4 -2
  80. package/src/web/search/providers/zai.ts +15 -4
@@ -7,7 +7,7 @@
7
7
  * SQLite store, never POSTs the broker sentinel to an OpenAI token endpoint.
8
8
  */
9
9
  import * as os from "node:os";
10
- import { type AuthStorage, getBundledModels } from "@oh-my-pi/pi-ai";
10
+ import { type AuthStorage, type FetchImpl, getBundledModels } from "@oh-my-pi/pi-ai";
11
11
  import { decodeJwt } from "@oh-my-pi/pi-ai/oauth/openai-codex";
12
12
  import { $env, readSseJson } from "@oh-my-pi/pi-utils";
13
13
  import packageJson from "../../../../package.json" with { type: "json" };
@@ -66,6 +66,7 @@ function shouldRetryWithNextDefaultModel(error: unknown): boolean {
66
66
 
67
67
  export interface CodexSearchParams {
68
68
  signal?: AbortSignal;
69
+ fetch?: FetchImpl;
69
70
  query: string;
70
71
  system_prompt?: string;
71
72
  num_results?: number;
@@ -322,6 +323,7 @@ async function callCodexSearch(
322
323
  systemPrompt?: string;
323
324
  searchContextSize?: "low" | "medium" | "high";
324
325
  modelId: string;
326
+ fetch?: FetchImpl;
325
327
  },
326
328
  ): Promise<{
327
329
  answer: string;
@@ -356,7 +358,8 @@ async function callCodexSearch(
356
358
  instructions: options.systemPrompt ?? DEFAULT_INSTRUCTIONS,
357
359
  };
358
360
 
359
- const response = await fetch(url, {
361
+ const fetchImpl = options.fetch ?? fetch;
362
+ const response = await fetchImpl(url, {
360
363
  method: "POST",
361
364
  headers,
362
365
  body: JSON.stringify(body),
@@ -522,6 +525,7 @@ export async function searchCodex(params: SearchParams): Promise<SearchResponse>
522
525
  systemPrompt: params.systemPrompt,
523
526
  searchContextSize: "high",
524
527
  modelId,
528
+ fetch: params.fetch,
525
529
  });
526
530
  break;
527
531
  } catch (error) {
@@ -6,10 +6,10 @@
6
6
  * Requests per-result summaries via `contents.summary` and synthesizes
7
7
  * them into a combined `answer` string on the SearchResponse.
8
8
  */
9
- import { type ApiKey, type AuthStorage, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
9
+ import { type ApiKey, type AuthStorage, type FetchImpl, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
10
10
  import { settings } from "../../../config/settings";
11
- import { callExaTool, findApiKey, isSearchResponse } from "../../../exa/mcp-client";
12
-
11
+ import { findApiKey, isSearchResponse } from "../../../exa/mcp-client";
12
+ import { parseSSE } from "../../../mcp/json-rpc";
13
13
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
14
14
  import { SearchProviderError } from "../../../web/search/types";
15
15
  import { dateToAgeSeconds } from "../utils";
@@ -32,6 +32,7 @@ export interface ExaSearchParams {
32
32
  start_published_date?: string;
33
33
  end_published_date?: string;
34
34
  signal?: AbortSignal;
35
+ fetch?: FetchImpl;
35
36
  /**
36
37
  * Credential source. Resolved before falling back to `EXA_API_KEY` so
37
38
  * Exa works when the key is stored via the broker/auth pipeline.
@@ -62,6 +63,48 @@ function asRecord(value: unknown): Record<string, unknown> | null {
62
63
  return value as Record<string, unknown>;
63
64
  }
64
65
 
66
+ function parseJsonContent(text: string): unknown | null {
67
+ try {
68
+ return JSON.parse(text) as unknown;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ function normalizeExaMcpPayload(payload: unknown): unknown {
75
+ const candidates: unknown[] = [];
76
+ const root = asRecord(payload);
77
+
78
+ if (root) {
79
+ if (root.structuredContent !== undefined) candidates.push(root.structuredContent);
80
+ if (root.data !== undefined) candidates.push(root.data);
81
+ if (root.result !== undefined) candidates.push(root.result);
82
+ candidates.push(root);
83
+
84
+ const content = root.content;
85
+ if (Array.isArray(content)) {
86
+ for (const item of content) {
87
+ const part = asRecord(item);
88
+ if (!part) continue;
89
+ const text = part.text;
90
+ if (typeof text !== "string" || text.trim().length === 0) continue;
91
+ const parsed = parseJsonContent(text);
92
+ if (parsed !== null) candidates.push(parsed);
93
+ }
94
+ }
95
+ } else {
96
+ candidates.push(payload);
97
+ }
98
+
99
+ for (const candidate of candidates) {
100
+ if (isSearchResponse(candidate)) {
101
+ return candidate;
102
+ }
103
+ }
104
+
105
+ return payload;
106
+ }
107
+
65
108
  function parseOptionalField(section: string, label: string): string | null | undefined {
66
109
  const regex = new RegExp(`(?:^|\\n)${label}:\\s*([^\\n]*)`);
67
110
  const match = section.match(regex);
@@ -180,7 +223,8 @@ export function buildExaRequestBody(params: ExaSearchParams): Record<string, unk
180
223
  async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<ExaSearchResponse> {
181
224
  const body = buildExaRequestBody(params);
182
225
 
183
- const response = await fetch(EXA_API_URL, {
226
+ const fetchImpl = params.fetch ?? fetch;
227
+ const response = await fetchImpl(EXA_API_URL, {
184
228
  method: "POST",
185
229
  headers: {
186
230
  "Content-Type": "application/json",
@@ -211,14 +255,52 @@ function buildExaMcpArgs(params: ExaSearchParams): Record<string, unknown> {
211
255
  }
212
256
 
213
257
  async function callExaMcpSearch(params: ExaSearchParams): Promise<ExaSearchResponse> {
214
- const response = await callExaTool("web_search_exa", buildExaMcpArgs(params), findApiKey(), {
258
+ const query = new URLSearchParams();
259
+ const apiKey = findApiKey();
260
+ if (apiKey) query.set("exaApiKey", apiKey);
261
+ query.set("tools", "web_search_exa");
262
+ const fetchImpl = params.fetch ?? fetch;
263
+ const response = await fetchImpl(`https://mcp.exa.ai/mcp?${query.toString()}`, {
264
+ method: "POST",
265
+ headers: {
266
+ "Content-Type": "application/json",
267
+ Accept: "application/json, text/event-stream",
268
+ },
269
+ body: JSON.stringify({
270
+ jsonrpc: "2.0",
271
+ id: Math.random().toString(36).slice(2),
272
+ method: "tools/call",
273
+ params: {
274
+ name: "web_search_exa",
275
+ arguments: buildExaMcpArgs(params),
276
+ },
277
+ }),
215
278
  signal: withHardTimeout(params.signal),
216
279
  });
217
- if (isSearchResponse(response)) {
218
- return response as ExaSearchResponse;
280
+ if (!response.ok) {
281
+ throw new Error(`MCP request failed: ${response.status} ${response.statusText}`);
282
+ }
283
+ const mcpResponse = parseSSE(await response.text()) as {
284
+ result?: {
285
+ content?: Array<{ type: string; text?: string }>;
286
+ };
287
+ error?: {
288
+ code: number;
289
+ message: string;
290
+ };
291
+ } | null;
292
+ if (!mcpResponse) {
293
+ throw new Error("Failed to parse MCP response");
294
+ }
295
+ if (mcpResponse.error) {
296
+ throw new Error(`MCP error: ${mcpResponse.error.message}`);
297
+ }
298
+ const responsePayload = normalizeExaMcpPayload(mcpResponse.result);
299
+ if (isSearchResponse(responsePayload)) {
300
+ return responsePayload as ExaSearchResponse;
219
301
  }
220
302
 
221
- const parsed = parseExaMcpTextPayload(response);
303
+ const parsed = parseExaMcpTextPayload(responsePayload);
222
304
  if (parsed) {
223
305
  return parsed;
224
306
  }
@@ -312,6 +394,7 @@ export class ExaProvider extends SearchProvider {
312
394
  signal: params.signal,
313
395
  authStorage: params.authStorage,
314
396
  sessionId: params.sessionId,
397
+ fetch: params.fetch,
315
398
  });
316
399
  }
317
400
  }
@@ -11,6 +11,7 @@
11
11
  import {
12
12
  ANTIGRAVITY_SYSTEM_INSTRUCTION,
13
13
  type AuthStorage,
14
+ type FetchImpl,
14
15
  getAntigravityUserAgent,
15
16
  getGeminiCliHeaders,
16
17
  } from "@oh-my-pi/pi-ai";
@@ -51,6 +52,7 @@ export interface GeminiSearchParams extends GeminiToolParams {
51
52
  signal?: AbortSignal;
52
53
  authStorage: AuthStorage;
53
54
  sessionId?: string;
55
+ fetch?: FetchImpl;
54
56
  }
55
57
 
56
58
  export function buildGeminiRequestTools(params: GeminiToolParams): Array<Record<string, Record<string, unknown>>> {
@@ -156,6 +158,7 @@ async function callGeminiSearch(
156
158
  maxOutputTokens: number | undefined,
157
159
  temperature: number | undefined,
158
160
  toolParams: GeminiToolParams,
161
+ fetchImpl: FetchImpl | undefined,
159
162
  signal: AbortSignal | undefined,
160
163
  ): Promise<{
161
164
  answer: string;
@@ -237,6 +240,7 @@ async function callGeminiSearch(
237
240
 
238
241
  const response = await fetchWithRetry(urlFor, {
239
242
  ...buildInit(),
243
+ fetch: fetchImpl,
240
244
  maxAttempts: MAX_RETRIES + 1,
241
245
  defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt,
242
246
  maxDelayMs: RATE_LIMIT_BUDGET_MS,
@@ -405,6 +409,7 @@ export async function searchGemini(params: GeminiSearchParams): Promise<SearchRe
405
409
  code_execution: params.code_execution,
406
410
  url_context: params.url_context,
407
411
  },
412
+ params.fetch,
408
413
  params.signal,
409
414
  );
410
415
 
@@ -450,6 +455,7 @@ export class GeminiProvider extends SearchProvider {
450
455
  signal: params.signal,
451
456
  authStorage: params.authStorage,
452
457
  sessionId: params.sessionId,
458
+ fetch: params.fetch,
453
459
  });
454
460
  }
455
461
  }
@@ -5,7 +5,7 @@
5
5
  * cleaned content.
6
6
  */
7
7
 
8
- import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
8
+ import { type AuthStorage, type FetchImpl, getEnvApiKey } from "@oh-my-pi/pi-ai";
9
9
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
10
10
  import { SearchProviderError } from "../../../web/search/types";
11
11
  import type { SearchParams } from "./base";
@@ -13,11 +13,13 @@ import { SearchProvider } from "./base";
13
13
  import { classifyProviderHttpError, withHardTimeout } from "./utils";
14
14
 
15
15
  const JINA_SEARCH_URL = "https://s.jina.ai";
16
+ type SearchParamsWithFetch = SearchParams & { fetch?: FetchImpl };
16
17
 
17
18
  export interface JinaSearchParams {
18
19
  query: string;
19
20
  num_results?: number;
20
21
  signal?: AbortSignal;
22
+ fetch?: FetchImpl;
21
23
  }
22
24
 
23
25
  interface JinaSearchResult {
@@ -34,9 +36,14 @@ export function findApiKey(): string | null {
34
36
  }
35
37
 
36
38
  /** Call Jina Reader search API. */
37
- async function callJinaSearch(apiKey: string, query: string, signal?: AbortSignal): Promise<JinaSearchResponse> {
39
+ async function callJinaSearch(
40
+ apiKey: string,
41
+ query: string,
42
+ signal?: AbortSignal,
43
+ fetchImpl: FetchImpl = fetch,
44
+ ): Promise<JinaSearchResponse> {
38
45
  const requestUrl = `${JINA_SEARCH_URL}/${encodeURIComponent(query)}`;
39
- const response = await fetch(requestUrl, {
46
+ const response = await fetchImpl(requestUrl, {
40
47
  headers: {
41
48
  Accept: "application/json",
42
49
  Authorization: `Bearer ${apiKey}`,
@@ -62,7 +69,7 @@ export async function searchJina(params: JinaSearchParams): Promise<SearchRespon
62
69
  throw new Error("JINA_API_KEY not found. Set it in environment or .env file.");
63
70
  }
64
71
 
65
- const response = await callJinaSearch(apiKey, params.query, params.signal);
72
+ const response = await callJinaSearch(apiKey, params.query, params.signal, params.fetch);
66
73
  const sources: SearchSource[] = [];
67
74
 
68
75
  for (const result of response) {
@@ -91,11 +98,14 @@ export class JinaProvider extends SearchProvider {
91
98
  return !!findApiKey();
92
99
  }
93
100
 
94
- search(params: SearchParams): Promise<SearchResponse> {
101
+ search(params: SearchParamsWithFetch): Promise<SearchResponse> {
102
+ const fetchImpl = params.fetch;
103
+
95
104
  return searchJina({
96
105
  query: params.query,
97
106
  num_results: params.numSearchResults ?? params.limit,
98
107
  signal: params.signal,
108
+ fetch: fetchImpl,
99
109
  });
100
110
  }
101
111
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Thin wrapper that adapts shared Kagi API utilities to SearchResponse shape.
5
5
  */
6
- import type { AuthStorage } from "@oh-my-pi/pi-ai";
6
+ import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
7
7
  import type { SearchResponse } from "../../../web/search/types";
8
8
  import { SearchProviderError } from "../../../web/search/types";
9
9
  import { KagiApiError, searchWithKagi } from "../../kagi";
@@ -12,6 +12,8 @@ import type { SearchParams } from "./base";
12
12
  import { SearchProvider } from "./base";
13
13
  import { classifyProviderHttpError, toSearchSources } from "./utils";
14
14
 
15
+ type SearchParamsWithFetch = SearchParams & { fetch?: FetchImpl };
16
+
15
17
  const DEFAULT_NUM_RESULTS = 10;
16
18
  const MAX_NUM_RESULTS = 40;
17
19
 
@@ -23,6 +25,7 @@ export async function searchKagi(params: {
23
25
  signal?: AbortSignal;
24
26
  authStorage: AuthStorage;
25
27
  sessionId?: string;
28
+ fetch?: FetchImpl;
26
29
  }): Promise<SearchResponse> {
27
30
  const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
28
31
 
@@ -34,6 +37,7 @@ export async function searchKagi(params: {
34
37
  recency: params.recency,
35
38
  sessionId: params.sessionId,
36
39
  signal: params.signal,
40
+ fetch: params.fetch,
37
41
  },
38
42
  params.authStorage,
39
43
  );
@@ -66,7 +70,9 @@ export class KagiProvider extends SearchProvider {
66
70
  return authStorage.hasAuth("kagi");
67
71
  }
68
72
 
69
- search(params: SearchParams): Promise<SearchResponse> {
73
+ search(params: SearchParamsWithFetch): Promise<SearchResponse> {
74
+ const fetchImpl = params.fetch;
75
+
70
76
  return searchKagi({
71
77
  query: params.query,
72
78
  num_results: params.numSearchResults ?? params.limit,
@@ -74,6 +80,7 @@ export class KagiProvider extends SearchProvider {
74
80
  signal: params.signal,
75
81
  authStorage: params.authStorage,
76
82
  sessionId: params.sessionId,
83
+ fetch: fetchImpl,
77
84
  });
78
85
  }
79
86
  }
@@ -4,7 +4,7 @@
4
4
  * Uses Moonshot Kimi Code search API to retrieve web results.
5
5
  * Endpoint: POST https://api.kimi.com/coding/v1/search
6
6
  */
7
- import { type ApiKey, type AuthStorage, withAuth } from "@oh-my-pi/pi-ai";
7
+ import { type ApiKey, type AuthStorage, type FetchImpl, withAuth } from "@oh-my-pi/pi-ai";
8
8
  import { $env } from "@oh-my-pi/pi-utils";
9
9
 
10
10
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
@@ -14,6 +14,8 @@ import type { SearchParams } from "./base";
14
14
  import { SearchProvider } from "./base";
15
15
  import { classifyProviderHttpError, withHardTimeout } from "./utils";
16
16
 
17
+ type SearchParamsWithFetch = SearchParams & { fetch?: FetchImpl };
18
+
17
19
  const KIMI_SEARCH_URL = "https://api.kimi.com/coding/v1/search";
18
20
 
19
21
  const DEFAULT_NUM_RESULTS = 10;
@@ -27,6 +29,7 @@ export interface KimiSearchParams {
27
29
  signal?: AbortSignal;
28
30
  authStorage: AuthStorage;
29
31
  sessionId?: string;
32
+ fetch?: FetchImpl;
30
33
  }
31
34
 
32
35
  interface KimiSearchResult {
@@ -78,9 +81,16 @@ async function resolveKey(
78
81
 
79
82
  async function callKimiSearch(
80
83
  apiKey: string,
81
- params: { query: string; limit: number; includeContent: boolean; signal?: AbortSignal },
84
+ params: {
85
+ query: string;
86
+ limit: number;
87
+ includeContent: boolean;
88
+ signal?: AbortSignal;
89
+ fetch?: FetchImpl;
90
+ },
82
91
  ): Promise<{ response: KimiSearchResponse; requestId?: string }> {
83
- const response = await fetch(resolveBaseUrl(), {
92
+ const fetchImpl = params.fetch ?? fetch;
93
+ const response = await fetchImpl(resolveBaseUrl(), {
84
94
  method: "POST",
85
95
  headers: {
86
96
  Accept: "application/json",
@@ -130,6 +140,7 @@ export async function searchKimi(params: KimiSearchParams): Promise<SearchRespon
130
140
  limit,
131
141
  includeContent: params.include_content ?? false,
132
142
  signal: params.signal,
143
+ fetch: params.fetch,
133
144
  }),
134
145
  { signal: params.signal },
135
146
  );
@@ -170,13 +181,16 @@ export class KimiProvider extends SearchProvider {
170
181
  );
171
182
  }
172
183
 
173
- search(params: SearchParams): Promise<SearchResponse> {
184
+ search(params: SearchParamsWithFetch): Promise<SearchResponse> {
185
+ const fetchImpl = params.fetch;
186
+
174
187
  return searchKimi({
175
188
  query: params.query,
176
189
  num_results: params.numSearchResults ?? params.limit,
177
190
  signal: params.signal,
178
191
  authStorage: params.authStorage,
179
192
  sessionId: params.sessionId,
193
+ fetch: fetchImpl,
180
194
  });
181
195
  }
182
196
  }
@@ -1,4 +1,4 @@
1
- import { type ApiKey, type AuthStorage, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
1
+ import { type ApiKey, type AuthStorage, type FetchImpl, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
2
2
  import type { SearchResponse } from "../../../web/search/types";
3
3
  import { SearchProviderError } from "../../../web/search/types";
4
4
  import { ParallelApiError, type ParallelSearchResult, type ParallelSearchSource } from "../../parallel";
@@ -112,6 +112,7 @@ async function searchWithAuthStorage(
112
112
  queries: string[],
113
113
  params: {
114
114
  signal?: AbortSignal;
115
+ fetch?: FetchImpl;
115
116
  },
116
117
  authStorage: AuthStorage,
117
118
  sessionId?: string,
@@ -131,7 +132,7 @@ async function searchWithAuthStorage(
131
132
  return withAuth(
132
133
  keyOrResolver,
133
134
  async key => {
134
- const response = await fetch(PARALLEL_SEARCH_URL, {
135
+ const response = await (params.fetch ?? fetch)(PARALLEL_SEARCH_URL, {
135
136
  method: "POST",
136
137
  headers: {
137
138
  Accept: "application/json",
@@ -165,6 +166,7 @@ export async function searchParallel(
165
166
  query: string;
166
167
  num_results?: number;
167
168
  signal?: AbortSignal;
169
+ fetch?: FetchImpl;
168
170
  },
169
171
  authStorage: AuthStorage,
170
172
  sessionId?: string,
@@ -177,6 +179,7 @@ export async function searchParallel(
177
179
  [params.query],
178
180
  {
179
181
  signal: params.signal,
182
+ fetch: params.fetch,
180
183
  },
181
184
  authStorage,
182
185
  sessionId,
@@ -213,6 +216,7 @@ export class ParallelProvider extends SearchProvider {
213
216
  query: params.query,
214
217
  num_results: params.numSearchResults ?? params.limit,
215
218
  signal: params.signal,
219
+ fetch: params.fetch,
216
220
  },
217
221
  params.authStorage,
218
222
  params.sessionId,
@@ -8,7 +8,7 @@
8
8
  * - Anonymous via `www.perplexity.ai/rest/sse/perplexity_ask`
9
9
  */
10
10
 
11
- import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
11
+ import { type AuthStorage, type FetchImpl, getEnvApiKey } from "@oh-my-pi/pi-ai";
12
12
  import { $env, readSseJson } from "@oh-my-pi/pi-utils";
13
13
  import type {
14
14
  PerplexityMessageOutput,
@@ -275,6 +275,7 @@ export interface PerplexitySearchParams {
275
275
  num_search_results?: number;
276
276
  authStorage: AuthStorage;
277
277
  sessionId?: string;
278
+ fetch?: FetchImpl;
278
279
  }
279
280
 
280
281
  /** Find PERPLEXITY_API_KEY from environment or .env files (also checks PPLX_API_KEY) */
@@ -356,9 +357,10 @@ async function findPerplexityAuth(
356
357
  async function callPerplexityApi(
357
358
  apiKey: string,
358
359
  request: PerplexityRequest,
360
+ fetchImpl: FetchImpl | undefined,
359
361
  signal?: AbortSignal,
360
362
  ): Promise<PerplexityResponse> {
361
- const response = await fetch(PERPLEXITY_API_URL, {
363
+ const response = await (fetchImpl ?? fetch)(PERPLEXITY_API_URL, {
362
364
  method: "POST",
363
365
  headers: {
364
366
  Authorization: `Bearer ${apiKey}`,
@@ -505,7 +507,7 @@ async function callPerplexityAsk(
505
507
  requestParams.source = "default";
506
508
  }
507
509
 
508
- const response = await fetch(PERPLEXITY_OAUTH_ASK_URL, {
510
+ const response = await (params.fetch ?? fetch)(PERPLEXITY_OAUTH_ASK_URL, {
509
511
  method: "POST",
510
512
  headers,
511
513
  body: JSON.stringify({
@@ -686,7 +688,7 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise<
686
688
  request.search_recency_filter = params.search_recency_filter;
687
689
  }
688
690
 
689
- const response = await callPerplexityApi(auth.token, request, params.signal);
691
+ const response = await callPerplexityApi(auth.token, request, params.fetch, params.signal);
690
692
  const result = parseResponse(response);
691
693
  result.authMode = "api_key";
692
694
  return applySourceLimit(result, params.num_results);
@@ -722,6 +724,7 @@ export class PerplexityProvider extends SearchProvider {
722
724
  num_results: params.limit,
723
725
  authStorage: params.authStorage,
724
726
  sessionId: params.sessionId,
727
+ fetch: params.fetch,
725
728
  });
726
729
  }
727
730
  }
@@ -25,7 +25,7 @@
25
25
  * Reference: https://docs.searxng.org/dev/search_api.html
26
26
  */
27
27
 
28
- import type { AuthStorage } from "@oh-my-pi/pi-ai";
28
+ import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
29
29
 
30
30
  import { settings } from "../../../config/settings";
31
31
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
@@ -207,12 +207,13 @@ async function callSearXNGSearch(
207
207
  categories?: string;
208
208
  language?: string;
209
209
  signal?: AbortSignal;
210
+ fetch?: FetchImpl;
210
211
  },
211
212
  auth: SearXNGAuth | null,
212
213
  ): Promise<SearXNGResponse> {
213
214
  const { url, headers } = buildRequest(endpoint, params, auth);
214
215
 
215
- const response = await fetch(url, {
216
+ const response = await (params.fetch ?? fetch)(url, {
216
217
  headers,
217
218
  signal: withHardTimeout(params.signal),
218
219
  });
@@ -233,6 +234,7 @@ export async function searchSearXNG(params: {
233
234
  num_results?: number;
234
235
  recency?: "day" | "week" | "month" | "year";
235
236
  signal?: AbortSignal;
237
+ fetch?: FetchImpl;
236
238
  }): Promise<SearchResponse> {
237
239
  const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
238
240
 
@@ -260,6 +262,7 @@ export async function searchSearXNG(params: {
260
262
  ...params,
261
263
  categories,
262
264
  language,
265
+ fetch: params.fetch,
263
266
  },
264
267
  auth,
265
268
  );
@@ -304,6 +307,7 @@ export class SearXNGProvider extends SearchProvider {
304
307
  num_results: params.numSearchResults ?? params.limit,
305
308
  recency: params.recency,
306
309
  signal: params.signal,
310
+ fetch: params.fetch,
307
311
  });
308
312
  }
309
313
  }
@@ -5,13 +5,15 @@
5
5
  * Endpoint: POST https://api.synthetic.new/v2/search
6
6
  */
7
7
 
8
- import { type ApiKey, type AuthStorage, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
8
+ import { type ApiKey, type AuthStorage, type FetchImpl, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
9
9
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
10
10
  import { SearchProviderError } from "../../../web/search/types";
11
11
  import type { SearchParams } from "./base";
12
12
  import { SearchProvider } from "./base";
13
13
  import { classifyProviderHttpError, withHardTimeout } from "./utils";
14
14
 
15
+ type SearchParamsWithFetch = SearchParams & { fetch?: FetchImpl };
16
+
15
17
  const SYNTHETIC_SEARCH_URL = "https://api.synthetic.new/v2/search";
16
18
 
17
19
  interface SyntheticSearchResult {
@@ -39,8 +41,9 @@ async function callSyntheticSearch(
39
41
  apiKey: string,
40
42
  query: string,
41
43
  signal?: AbortSignal,
44
+ fetchImpl: FetchImpl = fetch,
42
45
  ): Promise<SyntheticSearchResponse> {
43
- const response = await fetch(SYNTHETIC_SEARCH_URL, {
46
+ const response = await fetchImpl(SYNTHETIC_SEARCH_URL, {
44
47
  method: "POST",
45
48
  headers: {
46
49
  "Content-Type": "application/json",
@@ -65,12 +68,13 @@ async function callSyntheticSearch(
65
68
  }
66
69
 
67
70
  /** Execute Synthetic web search. */
68
- export async function searchSynthetic(params: SearchParams): Promise<SearchResponse> {
71
+ export async function searchSynthetic(params: SearchParamsWithFetch): Promise<SearchResponse> {
69
72
  const keyOrResolver: ApiKey = params.authStorage.resolver("synthetic", {
70
73
  sessionId: params.sessionId,
71
74
  });
72
75
 
73
- const data = await withAuth(keyOrResolver, key => callSyntheticSearch(key, params.query, params.signal), {
76
+ const fetchImpl = params.fetch;
77
+ const data = await withAuth(keyOrResolver, key => callSyntheticSearch(key, params.query, params.signal, fetchImpl), {
74
78
  signal: params.signal,
75
79
  missingKeyMessage: "Synthetic credentials not found. Set SYNTHETIC_API_KEY or login with 'omp /login synthetic'.",
76
80
  });
@@ -104,7 +108,7 @@ export class SyntheticProvider extends SearchProvider {
104
108
  return authStorage.hasAuth("synthetic") || !!getEnvApiKey("synthetic");
105
109
  }
106
110
 
107
- search(params: SearchParams): Promise<SearchResponse> {
111
+ search(params: SearchParamsWithFetch): Promise<SearchResponse> {
108
112
  return searchSynthetic(params);
109
113
  }
110
114
  }
@@ -4,7 +4,7 @@
4
4
  * Uses Tavily's agent-focused search API to return structured results with an
5
5
  * optional synthesized answer.
6
6
  */
7
- import { type ApiKey, type AuthStorage, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
7
+ import { type ApiKey, type AuthStorage, type FetchImpl, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
8
8
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
9
9
  import { SearchProviderError } from "../../../web/search/types";
10
10
  import { clampNumResults, dateToAgeSeconds } from "../utils";
@@ -21,6 +21,7 @@ export interface TavilySearchParams {
21
21
  num_results?: number;
22
22
  recency?: "day" | "week" | "month" | "year";
23
23
  signal?: AbortSignal;
24
+ fetch?: FetchImpl;
24
25
  }
25
26
 
26
27
  interface TavilySearchResult {
@@ -89,7 +90,7 @@ export function buildRequestBody(params: TavilySearchParams): Record<string, unk
89
90
  }
90
91
 
91
92
  async function callTavilySearch(apiKey: string, params: TavilySearchParams): Promise<TavilySearchResponse> {
92
- const response = await fetch(TAVILY_SEARCH_URL, {
93
+ const response = await (params.fetch ?? fetch)(TAVILY_SEARCH_URL, {
93
94
  method: "POST",
94
95
  headers: {
95
96
  "Content-Type": "application/json",
@@ -126,6 +127,7 @@ export async function searchTavily(params: SearchParams): Promise<SearchResponse
126
127
  num_results: params.numSearchResults ?? params.limit,
127
128
  recency: params.recency,
128
129
  signal: params.signal,
130
+ fetch: params.fetch,
129
131
  };
130
132
  const keyOrResolver: ApiKey = params.authStorage.resolver("tavily", {
131
133
  sessionId: params.sessionId,