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

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 (57) hide show
  1. package/CHANGELOG.md +14 -1
  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/types.d.ts +3 -1
  6. package/dist/types/mcp/oauth-discovery.d.ts +4 -1
  7. package/dist/types/mcp/oauth-flow.d.ts +6 -1
  8. package/dist/types/tools/fetch.d.ts +2 -1
  9. package/dist/types/tools/index.d.ts +3 -1
  10. package/dist/types/tools/report-tool-issue.d.ts +5 -0
  11. package/dist/types/web/kagi.d.ts +2 -1
  12. package/dist/types/web/parallel.d.ts +3 -0
  13. package/dist/types/web/search/providers/anthropic.d.ts +2 -1
  14. package/dist/types/web/search/providers/base.d.ts +2 -1
  15. package/dist/types/web/search/providers/brave.d.ts +2 -1
  16. package/dist/types/web/search/providers/codex.d.ts +2 -1
  17. package/dist/types/web/search/providers/exa.d.ts +2 -1
  18. package/dist/types/web/search/providers/gemini.d.ts +2 -1
  19. package/dist/types/web/search/providers/jina.d.ts +7 -2
  20. package/dist/types/web/search/providers/kagi.d.ts +7 -2
  21. package/dist/types/web/search/providers/kimi.d.ts +7 -2
  22. package/dist/types/web/search/providers/parallel.d.ts +2 -1
  23. package/dist/types/web/search/providers/perplexity.d.ts +2 -1
  24. package/dist/types/web/search/providers/searxng.d.ts +2 -1
  25. package/dist/types/web/search/providers/synthetic.d.ts +7 -3
  26. package/dist/types/web/search/providers/tavily.d.ts +2 -1
  27. package/dist/types/web/search/providers/zai.d.ts +2 -1
  28. package/package.json +9 -9
  29. package/src/config/model-registry.ts +13 -7
  30. package/src/config/model-resolver.ts +57 -2
  31. package/src/config/settings-schema.ts +6 -0
  32. package/src/extensibility/custom-tools/types.ts +3 -1
  33. package/src/internal-urls/docs-index.generated.ts +1 -1
  34. package/src/mcp/oauth-discovery.ts +8 -3
  35. package/src/mcp/oauth-flow.ts +12 -5
  36. package/src/modes/components/assistant-message.ts +28 -6
  37. package/src/tools/fetch.ts +22 -5
  38. package/src/tools/image-gen.ts +33 -11
  39. package/src/tools/index.ts +4 -2
  40. package/src/tools/report-tool-issue.ts +7 -1
  41. package/src/web/kagi.ts +5 -2
  42. package/src/web/parallel.ts +7 -3
  43. package/src/web/search/providers/anthropic.ts +5 -1
  44. package/src/web/search/providers/base.ts +2 -1
  45. package/src/web/search/providers/brave.ts +5 -2
  46. package/src/web/search/providers/codex.ts +6 -2
  47. package/src/web/search/providers/exa.ts +91 -8
  48. package/src/web/search/providers/gemini.ts +6 -0
  49. package/src/web/search/providers/jina.ts +15 -5
  50. package/src/web/search/providers/kagi.ts +9 -2
  51. package/src/web/search/providers/kimi.ts +18 -4
  52. package/src/web/search/providers/parallel.ts +6 -2
  53. package/src/web/search/providers/perplexity.ts +7 -4
  54. package/src/web/search/providers/searxng.ts +6 -2
  55. package/src/web/search/providers/synthetic.ts +9 -5
  56. package/src/web/search/providers/tavily.ts +4 -2
  57. package/src/web/search/providers/zai.ts +15 -4
@@ -5,7 +5,16 @@ import { settings } from "../../config/settings";
5
5
  import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
6
6
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
7
7
  import { resolveAbortLabel, shouldRenderAbortReason } from "../../session/messages";
8
- import { resolveImageOptions } from "../../tools/render-utils";
8
+ import { getPreviewLines, resolveImageOptions, TRUNCATE_LENGTHS } from "../../tools/render-utils";
9
+
10
+ /**
11
+ * Max lines of a turn-ending provider error rendered inline in the transcript.
12
+ * Bounds pathological error bodies — e.g. a proxy 502 whose body is a full HTML
13
+ * page — so they can't flood the scrollback. Blank lines are dropped and each
14
+ * line is width-truncated by {@link getPreviewLines}. Full text is still kept in
15
+ * the persisted session.
16
+ */
17
+ const MAX_TRANSCRIPT_ERROR_LINES = 8;
9
18
 
10
19
  /**
11
20
  * Component that renders a complete assistant message
@@ -78,6 +87,22 @@ export class AssistantMessageComponent extends Container {
78
87
  this.#transcriptBlockFinalized = true;
79
88
  }
80
89
 
90
+ /**
91
+ * Render a turn-ending provider error inline. Drops blank lines, clamps the
92
+ * line count to {@link MAX_TRANSCRIPT_ERROR_LINES}, and width-truncates each
93
+ * line so a pathological body — e.g. the HTML page a proxy returns on a 502 —
94
+ * can't flood the transcript. Mirrors {@link ErrorBannerComponent}.
95
+ */
96
+ #appendErrorBlock(message: string): void {
97
+ const lines = getPreviewLines(message, MAX_TRANSCRIPT_ERROR_LINES, TRUNCATE_LENGTHS.LINE);
98
+ if (lines.length === 0) lines.push("Unknown error");
99
+ this.#contentContainer.addChild(new Spacer(1));
100
+ this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${lines[0]}`), 1, 0));
101
+ for (const line of lines.slice(1)) {
102
+ this.#contentContainer.addChild(new Text(theme.fg("error", ` ${line}`), 1, 0));
103
+ }
104
+ }
105
+
81
106
  setToolResultImages(toolCallId: string, images: ImageContent[]): void {
82
107
  if (!toolCallId) return;
83
108
  const validImages = images.filter(img => img.type === "image" && img.data && img.mimeType);
@@ -249,9 +274,7 @@ export class AssistantMessageComponent extends Container {
249
274
  }
250
275
  this.#contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0));
251
276
  } else if (message.stopReason === "error" && !this.#errorPinned) {
252
- const errorMsg = message.errorMessage || "Unknown error";
253
- this.#contentContainer.addChild(new Spacer(1));
254
- this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
277
+ this.#appendErrorBlock(message.errorMessage || "Unknown error");
255
278
  }
256
279
  }
257
280
  if (
@@ -260,8 +283,7 @@ export class AssistantMessageComponent extends Container {
260
283
  message.stopReason !== "aborted" &&
261
284
  message.stopReason !== "error"
262
285
  ) {
263
- this.#contentContainer.addChild(new Spacer(1));
264
- this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${message.errorMessage}`), 1, 0));
286
+ this.#appendErrorBlock(message.errorMessage);
265
287
  }
266
288
 
267
289
  // Token usage metadata
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
6
- import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
6
+ import type { FetchImpl, ImageContent, TextContent } from "@oh-my-pi/pi-ai";
7
7
  import { htmlToMarkdown } from "@oh-my-pi/pi-natives";
8
8
  import { type Component, Text } from "@oh-my-pi/pi-tui";
9
9
  import { $which, ptree, truncate } from "@oh-my-pi/pi-utils";
@@ -637,6 +637,7 @@ export async function renderHtmlToText(
637
637
  settings: Settings,
638
638
  userSignal: AbortSignal | undefined,
639
639
  storage: AgentStorage | null,
640
+ fetchOverride?: FetchImpl,
640
641
  ): Promise<{ content: string; ok: boolean; method: string }> {
641
642
  const overallSignal = ptree.combineSignals(userSignal, timeout * 1000);
642
643
  const execOptions = {
@@ -650,6 +651,7 @@ export async function renderHtmlToText(
650
651
  // Per-attempt budget for remote endpoints so one stall cannot consume the
651
652
  // whole reader-mode budget and starve the local fallbacks.
652
653
  const remoteSignal = () => ptree.combineSignals(userSignal, remoteBudgetMs);
654
+ const fetchImpl = fetchOverride ?? fetch;
653
655
 
654
656
  const runners: Record<FetchProvider, () => Promise<string | null>> = {
655
657
  // Purely local, no network/subprocess: still works on already-loaded HTML
@@ -670,14 +672,20 @@ export async function renderHtmlToText(
670
672
  if (!findParallelApiKey(storage)) return null;
671
673
  const parallelResult = await extractWithParallel(
672
674
  [url],
673
- { objective: "Extract the main content", excerpts: true, fullContent: false, signal: remoteSignal() },
675
+ {
676
+ objective: "Extract the main content",
677
+ excerpts: true,
678
+ fullContent: false,
679
+ signal: remoteSignal(),
680
+ fetch: fetchImpl,
681
+ },
674
682
  storage,
675
683
  );
676
684
  const firstDocument = parallelResult.results[0];
677
685
  return firstDocument ? getParallelExtractContent(firstDocument) : null;
678
686
  },
679
687
  jina: async () => {
680
- const response = await fetch(`https://r.jina.ai/${url}`, {
688
+ const response = await fetchImpl(`https://r.jina.ai/${url}`, {
681
689
  headers: { Accept: "text/markdown" },
682
690
  signal: remoteSignal(),
683
691
  });
@@ -1052,6 +1060,7 @@ async function renderUrl(
1052
1060
  settings: Settings,
1053
1061
  signal: AbortSignal | undefined,
1054
1062
  storage: AgentStorage | null,
1063
+ fetchOverride?: FetchImpl,
1055
1064
  ): Promise<FetchRenderResult> {
1056
1065
  const notes: string[] = [];
1057
1066
  const fetchedAt = new Date().toISOString();
@@ -1425,7 +1434,15 @@ async function renderUrl(
1425
1434
  }
1426
1435
 
1427
1436
  // 5E: Render HTML via the reader-backend chain (native/trafilatura/lynx/parallel/jina)
1428
- const htmlResult = await renderHtmlToText(finalUrl, rawContent, timeout, settings, signal, storage);
1437
+ const htmlResult = await renderHtmlToText(
1438
+ finalUrl,
1439
+ rawContent,
1440
+ timeout,
1441
+ settings,
1442
+ signal,
1443
+ storage,
1444
+ fetchOverride,
1445
+ );
1429
1446
  if (!htmlResult.ok) {
1430
1447
  notes.push("html rendering failed (no reader backend produced usable output)");
1431
1448
 
@@ -1626,7 +1643,7 @@ async function buildReadUrlCacheEntry(
1626
1643
  }
1627
1644
 
1628
1645
  const storage = session.settings.getStorage();
1629
- const result = await renderUrl(url, effectiveTimeout, raw, session.settings, signal, storage);
1646
+ const result = await renderUrl(url, effectiveTimeout, raw, session.settings, signal, storage, session.fetch);
1630
1647
  const output = buildUrlReadOutput(result, result.content);
1631
1648
  const artifactId = options?.ensureArtifact ? await persistReadUrlArtifact(session, output) : undefined;
1632
1649
 
@@ -1,6 +1,13 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
- import { type ApiKey, getAntigravityUserAgent, getEnvApiKey, type Model, withAuth } from "@oh-my-pi/pi-ai";
3
+ import {
4
+ type ApiKey,
5
+ type FetchImpl,
6
+ getAntigravityUserAgent,
7
+ getEnvApiKey,
8
+ type Model,
9
+ withAuth,
10
+ } from "@oh-my-pi/pi-ai";
4
11
  import {
5
12
  CODEX_BASE_URL,
6
13
  getCodexAccountId,
@@ -366,7 +373,11 @@ function toDataUrl(image: InlineImageData): string {
366
373
  return `data:${image.mimeType};base64,${image.data}`;
367
374
  }
368
375
 
369
- async function loadImageFromUrl(imageUrl: string, signal?: AbortSignal): Promise<InlineImageData> {
376
+ async function loadImageFromUrl(
377
+ imageUrl: string,
378
+ fetchImpl: FetchImpl,
379
+ signal?: AbortSignal,
380
+ ): Promise<InlineImageData> {
370
381
  if (imageUrl.startsWith("data:")) {
371
382
  const normalized = normalizeDataUrl(imageUrl.trim());
372
383
  if (!normalized.mimeType) {
@@ -378,7 +389,7 @@ async function loadImageFromUrl(imageUrl: string, signal?: AbortSignal): Promise
378
389
  return { data: normalized.data, mimeType: normalized.mimeType };
379
390
  }
380
391
 
381
- const response = await fetch(imageUrl, { signal });
392
+ const response = await fetchImpl(imageUrl, { signal });
382
393
  if (!response.ok) {
383
394
  const rawText = await response.text();
384
395
  throw new Error(`Image download failed (${response.status}): ${rawText}`);
@@ -850,13 +861,14 @@ async function generateOpenAIHostedImage(
850
861
  model: Model,
851
862
  params: ImageGenParams,
852
863
  inputImages: InlineImageData[],
864
+ fetchImpl: FetchImpl,
853
865
  signal: AbortSignal | undefined,
854
866
  sessionId: string | undefined,
855
867
  ): Promise<OpenAIHostedImageResult> {
856
868
  const promptText = assemblePrompt(params);
857
869
  const stream = model.api === "openai-codex-responses" || model.provider === "openai-codex";
858
870
  const requestBody = buildOpenAIHostedImageRequest(model, promptText, params, inputImages, stream);
859
- const response = await fetch(getOpenAIResponsesUrl(model), {
871
+ const response = await fetchImpl(getOpenAIResponsesUrl(model), {
860
872
  method: "POST",
861
873
  headers: buildOpenAIImageHeaders(model, apiKey, sessionId),
862
874
  body: JSON.stringify(requestBody),
@@ -1035,6 +1047,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1035
1047
  }
1036
1048
 
1037
1049
  const requestSignal = ptree.combineSignals(signal, IMAGE_TIMEOUT);
1050
+ const fetchImpl = ctx.fetch ?? fetch;
1038
1051
 
1039
1052
  if (provider === "openai" || provider === "openai-codex") {
1040
1053
  if (!apiKey.model) {
@@ -1049,7 +1062,16 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1049
1062
 
1050
1063
  const parsed = await withAuth(
1051
1064
  hostedKey,
1052
- key => generateOpenAIHostedImage(key, hostedModel, params, resolvedImages, requestSignal, sessionId),
1065
+ key =>
1066
+ generateOpenAIHostedImage(
1067
+ key,
1068
+ hostedModel,
1069
+ params,
1070
+ resolvedImages,
1071
+ fetchImpl,
1072
+ requestSignal,
1073
+ sessionId,
1074
+ ),
1053
1075
  { signal: requestSignal },
1054
1076
  );
1055
1077
 
@@ -1117,7 +1139,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1117
1139
  resolvedImages,
1118
1140
  );
1119
1141
 
1120
- const resp = await fetch(`${ANTIGRAVITY_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`, {
1142
+ const resp = await fetchImpl(`${ANTIGRAVITY_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`, {
1121
1143
  method: "POST",
1122
1144
  headers: {
1123
1145
  Authorization: `Bearer ${bearer}`,
@@ -1225,7 +1247,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1225
1247
  const xaiRawText = await withAuth(
1226
1248
  xaiKey,
1227
1249
  async key => {
1228
- const resp = await fetch(`${xaiCreds.baseURL}${xaiEndpoint}`, {
1250
+ const resp = await fetchImpl(`${xaiCreds.baseURL}${xaiEndpoint}`, {
1229
1251
  method: "POST",
1230
1252
  headers: {
1231
1253
  Authorization: `Bearer ${key}`,
@@ -1263,7 +1285,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1263
1285
  const mimeType = parseImageMetadata(bytes)?.mimeType ?? "image/png";
1264
1286
  xaiInlineImages.push({ data: entry.b64_json, mimeType });
1265
1287
  } else if (entry.url) {
1266
- xaiInlineImages.push(await loadImageFromUrl(entry.url, requestSignal));
1288
+ xaiInlineImages.push(await loadImageFromUrl(entry.url, fetchImpl, requestSignal));
1267
1289
  }
1268
1290
  }
1269
1291
 
@@ -1309,7 +1331,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1309
1331
  };
1310
1332
 
1311
1333
  const rawText = await withAuth(apiKey.apiKey, async key => {
1312
- const resp = await fetch("https://openrouter.ai/api/v1/chat/completions", {
1334
+ const resp = await fetchImpl("https://openrouter.ai/api/v1/chat/completions", {
1313
1335
  method: "POST",
1314
1336
  headers: {
1315
1337
  "Content-Type": "application/json",
@@ -1343,7 +1365,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1343
1365
  const imageUrls = extractOpenRouterImageUrls(message);
1344
1366
  const inlineImages: InlineImageData[] = [];
1345
1367
  for (const imageUrl of imageUrls) {
1346
- inlineImages.push(await loadImageFromUrl(imageUrl, requestSignal));
1368
+ inlineImages.push(await loadImageFromUrl(imageUrl, fetchImpl, requestSignal));
1347
1369
  }
1348
1370
 
1349
1371
  if (inlineImages.length === 0) {
@@ -1404,7 +1426,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1404
1426
  };
1405
1427
 
1406
1428
  const rawText = await withAuth(apiKey.apiKey, async key => {
1407
- const resp = await fetch(
1429
+ const resp = await fetchImpl(
1408
1430
  `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`,
1409
1431
  {
1410
1432
  method: "POST",
@@ -1,6 +1,6 @@
1
1
  import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
2
  import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
- import type { ToolChoice } from "@oh-my-pi/pi-ai";
3
+ import type { FetchImpl, ToolChoice } from "@oh-my-pi/pi-ai";
4
4
  import { logger } from "@oh-my-pi/pi-utils";
5
5
  import type { AsyncJobManager } from "../async/job-manager";
6
6
  import type { PromptTemplate } from "../config/prompt-templates";
@@ -142,6 +142,8 @@ export interface ToolSession {
142
142
  cwd: string;
143
143
  /** Whether UI is available */
144
144
  hasUI: boolean;
145
+ /** Optional fetch implementation injected into the URL read pipeline (tests, proxies). Defaults to global fetch. */
146
+ fetch?: FetchImpl;
145
147
  /** Skip Python kernel availability check and warmup */
146
148
  skipPythonPreflight?: boolean;
147
149
  /** Pre-loaded context files (AGENTS.md, etc) */
@@ -492,7 +494,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
492
494
  const isToolAllowed = (name: string) => {
493
495
  if (name === "goal") return goalEnabled && goalModeActive;
494
496
  if (name === "lsp") return enableLsp && session.settings.get("lsp.enabled");
495
- if (name === "bash") return true;
497
+ if (name === "bash") return session.settings.get("bash.enabled");
496
498
  if (name === "eval") return allowEval;
497
499
  if (name === "debug") return session.settings.get("debug.enabled");
498
500
  if (name === "todo") return !includeYield && session.settings.get("todo.enabled");
@@ -22,6 +22,7 @@
22
22
  import { Database } from "bun:sqlite";
23
23
  import path from "node:path";
24
24
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
25
+ import type { FetchImpl } from "@oh-my-pi/pi-ai";
25
26
  import { $env, $flag, getAgentDir, getInstallId, logger, VERSION } from "@oh-my-pi/pi-utils";
26
27
  import * as z from "zod/v4";
27
28
  import type { Settings } from "..";
@@ -260,6 +261,10 @@ export interface FlushOptions {
260
261
  * future debug recipes); never set from the tool's auto-flush path.
261
262
  */
262
263
  bypassConsent?: boolean;
264
+ /**
265
+ * Fetch implementation for the push POST. Defaults to global fetch.
266
+ */
267
+ fetch?: FetchImpl;
263
268
  /**
264
269
  * Fires once at the start of the loop with the snapshot count of
265
270
  * unpushed rows. Subsequent inserts won't be reflected (the count is
@@ -345,6 +350,7 @@ async function performFlush(db: Database, config: PushConfig, options: FlushOpti
345
350
  const totalRow = db.prepare("SELECT COUNT(*) AS n FROM grievances WHERE pushed = 0").get() as { n: number };
346
351
  options.onStart(totalRow.n);
347
352
  }
353
+ const fetchImpl = options.fetch ?? fetch;
348
354
  let totalPushed = 0;
349
355
  for (;;) {
350
356
  const rows = selectStmt.all(FLUSH_BATCH_SIZE) as GrievanceRow[];
@@ -366,7 +372,7 @@ async function performFlush(db: Database, config: PushConfig, options: FlushOpti
366
372
 
367
373
  let response: Response;
368
374
  try {
369
- response = await fetch(config.endpoint, {
375
+ response = await fetchImpl(config.endpoint, {
370
376
  method: "POST",
371
377
  headers,
372
378
  body,
package/src/web/kagi.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * through the shared {@link AuthStorage} broker (Bearer token), and responses
7
7
  * are categorized result buckets rather than the legacy flat object array.
8
8
  */
9
- import type { AuthStorage } from "@oh-my-pi/pi-ai";
9
+ import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
10
10
  import { withHardTimeout } from "./search/providers/utils";
11
11
 
12
12
  const KAGI_SEARCH_URL = "https://kagi.com/api/v1/search";
@@ -156,6 +156,7 @@ export interface KagiSearchOptions {
156
156
  recency?: "day" | "week" | "month" | "year";
157
157
  sessionId?: string;
158
158
  signal?: AbortSignal;
159
+ fetch?: FetchImpl;
159
160
  }
160
161
 
161
162
  export interface KagiSearchSource {
@@ -251,7 +252,9 @@ export async function searchWithKagi(
251
252
  throw new KagiApiError("Kagi credentials not found. Set KAGI_API_KEY or login with 'omp /login kagi'.");
252
253
  }
253
254
 
254
- const response = await fetch(KAGI_SEARCH_URL, {
255
+ const fetchImpl = options.fetch ?? fetch;
256
+
257
+ const response = await fetchImpl(KAGI_SEARCH_URL, {
255
258
  method: "POST",
256
259
  headers: {
257
260
  Authorization: `Bearer ${apiKey}`,
@@ -1,4 +1,4 @@
1
- import { getEnvApiKey } from "@oh-my-pi/pi-ai";
1
+ import { type FetchImpl, getEnvApiKey } from "@oh-my-pi/pi-ai";
2
2
  import type { AgentStorage } from "../session/agent-storage";
3
3
  import { findCredential, withHardTimeout } from "./search/providers/utils";
4
4
 
@@ -54,6 +54,7 @@ export interface ParallelSearchOptions {
54
54
  mode?: "fast" | "research";
55
55
  maxCharsPerResult?: number;
56
56
  signal?: AbortSignal;
57
+ fetch?: FetchImpl;
57
58
  }
58
59
 
59
60
  export interface ParallelExtractOptions {
@@ -62,6 +63,7 @@ export interface ParallelExtractOptions {
62
63
  excerpts?: boolean;
63
64
  fullContent?: boolean;
64
65
  signal?: AbortSignal;
66
+ fetch?: FetchImpl;
65
67
  }
66
68
 
67
69
  export class ParallelApiError extends Error {
@@ -295,7 +297,8 @@ export async function searchWithParallel(
295
297
  );
296
298
  }
297
299
 
298
- const response = await fetch(PARALLEL_SEARCH_URL, {
300
+ const fetchImpl = options.fetch ?? fetch;
301
+ const response = await fetchImpl(PARALLEL_SEARCH_URL, {
299
302
  method: "POST",
300
303
  headers: getAuthHeaders(apiKey),
301
304
  body: JSON.stringify({
@@ -328,7 +331,8 @@ export async function extractWithParallel(
328
331
  );
329
332
  }
330
333
 
331
- const response = await fetch(PARALLEL_EXTRACT_URL, {
334
+ const fetchImpl = options.fetch ?? fetch;
335
+ const response = await fetchImpl(PARALLEL_EXTRACT_URL, {
332
336
  method: "POST",
333
337
  headers: getAuthHeaders(apiKey),
334
338
  body: JSON.stringify({
@@ -13,6 +13,7 @@ import {
13
13
  buildAnthropicSearchHeaders,
14
14
  buildAnthropicSystemBlocks,
15
15
  buildAnthropicUrl,
16
+ type FetchImpl,
16
17
  stripClaudeToolPrefix,
17
18
  withAuth,
18
19
  } from "@oh-my-pi/pi-ai";
@@ -40,6 +41,7 @@ export interface AnthropicSearchParams {
40
41
  max_tokens?: number;
41
42
  temperature?: number;
42
43
  signal?: AbortSignal;
44
+ fetch?: FetchImpl;
43
45
  }
44
46
 
45
47
  /**
@@ -89,6 +91,7 @@ async function callSearch(
89
91
  maxTokens?: number,
90
92
  temperature?: number,
91
93
  signal?: AbortSignal,
94
+ fetchImpl: FetchImpl = fetch,
92
95
  ): Promise<AnthropicApiResponse> {
93
96
  const url = buildAnthropicUrl(auth);
94
97
  const headers = buildAnthropicSearchHeaders(auth);
@@ -115,7 +118,7 @@ async function callSearch(
115
118
  body.system = systemBlocks;
116
119
  }
117
120
 
118
- const response = await fetch(url, {
121
+ const response = await fetchImpl(url, {
119
122
  method: "POST",
120
123
  headers,
121
124
  body: JSON.stringify(body),
@@ -275,6 +278,7 @@ export async function searchAnthropic(
275
278
  maxTokens,
276
279
  params.temperature,
277
280
  params.signal,
281
+ params.fetch,
278
282
  ),
279
283
  {
280
284
  signal: params.signal,
@@ -1,4 +1,4 @@
1
- import type { AuthStorage } from "@oh-my-pi/pi-ai";
1
+ import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
2
2
  import type { SearchProviderId, SearchResponse } from "../types";
3
3
 
4
4
  /**
@@ -30,6 +30,7 @@ export interface SearchParams {
30
30
  recency?: "day" | "week" | "month" | "year";
31
31
  systemPrompt: string;
32
32
  signal?: AbortSignal;
33
+ fetch?: FetchImpl;
33
34
  maxOutputTokens?: number;
34
35
  numSearchResults?: number;
35
36
  temperature?: number;
@@ -4,7 +4,7 @@
4
4
  * Calls Brave's web search REST API and maps results into the unified
5
5
  * SearchResponse shape used by the web search tool.
6
6
  */
7
- import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
7
+ import { type AuthStorage, type FetchImpl, getEnvApiKey } 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";
@@ -28,6 +28,7 @@ export interface BraveSearchParams {
28
28
  num_results?: number;
29
29
  recency?: "day" | "week" | "month" | "year";
30
30
  signal?: AbortSignal;
31
+ fetch?: FetchImpl;
31
32
  }
32
33
 
33
34
  interface BraveSearchResult {
@@ -80,7 +81,8 @@ async function callBraveSearch(
80
81
  url.searchParams.set("freshness", RECENCY_MAP[params.recency]);
81
82
  }
82
83
 
83
- const response = await fetch(url, {
84
+ const fetchImpl = params.fetch ?? fetch;
85
+ const response = await fetchImpl(url, {
84
86
  headers: {
85
87
  Accept: "application/json",
86
88
  "X-Subscription-Token": apiKey,
@@ -144,6 +146,7 @@ export class BraveProvider extends SearchProvider {
144
146
  num_results: params.numSearchResults ?? params.limit,
145
147
  recency: params.recency,
146
148
  signal: params.signal,
149
+ fetch: params.fetch,
147
150
  });
148
151
  }
149
152
  }
@@ -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
  }