@oh-my-pi/pi-coding-agent 15.1.8 → 15.2.1

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 (71) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/dist/types/cli/update-cli.d.ts +18 -0
  3. package/dist/types/config/settings-schema.d.ts +10 -0
  4. package/dist/types/eval/py/kernel.d.ts +6 -0
  5. package/dist/types/goals/state.d.ts +1 -1
  6. package/dist/types/goals/tools/goal-tool.d.ts +4 -0
  7. package/dist/types/hashline/parser.d.ts +6 -2
  8. package/dist/types/internal-urls/memory-protocol.d.ts +6 -0
  9. package/dist/types/main.d.ts +25 -1
  10. package/dist/types/modes/theme/shimmer.d.ts +27 -0
  11. package/dist/types/slash-commands/helpers/format.d.ts +4 -1
  12. package/dist/types/tools/ast-edit.d.ts +3 -0
  13. package/dist/types/tools/ast-grep.d.ts +3 -0
  14. package/dist/types/tools/find.d.ts +3 -0
  15. package/dist/types/tools/search.d.ts +3 -0
  16. package/dist/types/tui/file-list.d.ts +6 -0
  17. package/dist/types/tui/hyperlink.d.ts +42 -0
  18. package/dist/types/tui/index.d.ts +1 -0
  19. package/dist/types/utils/tool-choice.d.ts +2 -1
  20. package/dist/types/web/search/providers/utils.d.ts +27 -1
  21. package/package.json +7 -7
  22. package/src/cli/update-cli.ts +78 -36
  23. package/src/config/model-registry.ts +23 -12
  24. package/src/config/settings-schema.ts +12 -0
  25. package/src/config/settings.ts +28 -5
  26. package/src/edit/renderer.ts +5 -3
  27. package/src/eval/py/executor.ts +12 -1
  28. package/src/eval/py/kernel.ts +24 -8
  29. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  30. package/src/goals/runtime.ts +9 -3
  31. package/src/goals/state.ts +1 -1
  32. package/src/goals/tools/goal-tool.ts +12 -2
  33. package/src/hashline/diff.ts +1 -1
  34. package/src/hashline/execute.ts +2 -2
  35. package/src/hashline/parser.ts +87 -12
  36. package/src/internal-urls/memory-protocol.ts +1 -1
  37. package/src/main.ts +13 -2
  38. package/src/modes/interactive-mode.ts +29 -1
  39. package/src/modes/theme/shimmer.ts +79 -0
  40. package/src/prompts/agents/oracle.md +15 -16
  41. package/src/prompts/tools/goal.md +7 -2
  42. package/src/session/agent-session.ts +12 -75
  43. package/src/slash-commands/helpers/format.ts +23 -3
  44. package/src/task/executor.ts +115 -19
  45. package/src/tools/ast-edit.ts +39 -6
  46. package/src/tools/ast-grep.ts +38 -6
  47. package/src/tools/find.ts +13 -2
  48. package/src/tools/read.ts +46 -6
  49. package/src/tools/search.ts +447 -265
  50. package/src/tui/file-list.ts +10 -2
  51. package/src/tui/hyperlink.ts +126 -0
  52. package/src/tui/index.ts +1 -0
  53. package/src/utils/tool-choice.ts +7 -7
  54. package/src/web/kagi.ts +2 -2
  55. package/src/web/parallel.ts +3 -3
  56. package/src/web/search/index.ts +20 -9
  57. package/src/web/search/providers/anthropic.ts +4 -2
  58. package/src/web/search/providers/brave.ts +4 -2
  59. package/src/web/search/providers/codex.ts +4 -1
  60. package/src/web/search/providers/exa.ts +4 -1
  61. package/src/web/search/providers/gemini.ts +4 -1
  62. package/src/web/search/providers/jina.ts +4 -2
  63. package/src/web/search/providers/kagi.ts +5 -1
  64. package/src/web/search/providers/kimi.ts +4 -2
  65. package/src/web/search/providers/parallel.ts +5 -1
  66. package/src/web/search/providers/perplexity.ts +7 -2
  67. package/src/web/search/providers/searxng.ts +4 -1
  68. package/src/web/search/providers/synthetic.ts +4 -2
  69. package/src/web/search/providers/tavily.ts +4 -2
  70. package/src/web/search/providers/utils.ts +63 -1
  71. package/src/web/search/providers/zai.ts +4 -2
@@ -7,6 +7,9 @@ import { renderTreeList } from "./tree-list";
7
7
 
8
8
  export interface FileEntry {
9
9
  path: string;
10
+ /** Absolute filesystem path. When provided together with {@link FileListOptions.hyperlinkFn}, the
11
+ * rendered path text is wrapped in an OSC 8 hyperlink. */
12
+ absPath?: string;
10
13
  isDirectory?: boolean;
11
14
  meta?: string;
12
15
  }
@@ -16,10 +19,13 @@ export interface FileListOptions {
16
19
  expanded?: boolean;
17
20
  maxCollapsed?: number;
18
21
  showIcons?: boolean;
22
+ /** When provided, called with the entry's absolute path and the ANSI-styled display string to
23
+ * optionally wrap the path in an OSC 8 hyperlink. Only invoked when {@link FileEntry.absPath} is set. */
24
+ hyperlinkFn?: (absPath: string, displayText: string) => string;
19
25
  }
20
26
 
21
27
  export function renderFileList(options: FileListOptions, theme: Theme): string[] {
22
- const { files, expanded = false, maxCollapsed = 8, showIcons = true } = options;
28
+ const { files, expanded = false, maxCollapsed = 8, showIcons = true, hyperlinkFn } = options;
23
29
 
24
30
  return renderTreeList(
25
31
  {
@@ -39,7 +45,9 @@ export function renderFileList(options: FileListOptions, theme: Theme): string[]
39
45
  const labelColor = isDirectory ? "accent" : "toolOutput";
40
46
  const meta = entry.meta ? ` ${theme.fg("dim", entry.meta)}` : "";
41
47
  const iconPrefix = icon ? `${icon} ` : "";
42
- return `${iconPrefix}${theme.fg(labelColor, displayPath)}${meta}`;
48
+ const pathStr = theme.fg(labelColor, displayPath);
49
+ const linkedPath = entry.absPath && hyperlinkFn ? hyperlinkFn(entry.absPath, pathStr) : pathStr;
50
+ return `${iconPrefix}${linkedPath}${meta}`;
43
51
  },
44
52
  },
45
53
  theme,
@@ -0,0 +1,126 @@
1
+ /**
2
+ * OSC 8 terminal hyperlink support for file paths.
3
+ *
4
+ * Wraps display text in `ESC ] 8 ; id=HASH ; URI ESC \ TEXT ESC ] 8 ; ; ESC \`
5
+ * sequences when the active terminal supports hyperlinks and the user setting
6
+ * permits it. Falls back to plain text when disabled.
7
+ */
8
+ import { TERMINAL } from "@oh-my-pi/pi-tui";
9
+ import { settings } from "../config/settings";
10
+ import {
11
+ LocalProtocolHandler,
12
+ memoryRootsFromRegistry,
13
+ parseInternalUrl,
14
+ resolveLocalUrlToPath,
15
+ resolveMemoryUrlToPath,
16
+ } from "../internal-urls";
17
+
18
+ const OSC = "\x1b]";
19
+ const ST = "\x1b\\";
20
+
21
+ /** Stable 8-char hex ID derived from a URI — hints terminals to coalesce identical adjacent links. */
22
+ function buildLinkId(uri: string): string {
23
+ let h = 0;
24
+ for (let i = 0; i < uri.length; i++) {
25
+ // FNV-1a-inspired mix — good enough for a UI hint, no deps
26
+ h = (Math.imul(31, h) + uri.charCodeAt(i)) | 0;
27
+ }
28
+ return (h >>> 0).toString(16).padStart(8, "0");
29
+ }
30
+
31
+ /** Build a `file://` URI for an absolute path with optional line/col query params. */
32
+ function buildFileUri(absPath: string, opts?: { line?: number; col?: number }): string {
33
+ // Normalize backslashes for Windows paths before constructing the URL.
34
+ const normalized = absPath.replaceAll("\\", "/");
35
+ const prefix = normalized.startsWith("/") ? "file://" : "file:///";
36
+ // Split on slashes, encode each component, reassemble.
37
+ const encoded = normalized
38
+ .split("/")
39
+ .map(segment => encodeURIComponent(segment))
40
+ .join("/");
41
+ const params: string[] = [];
42
+ if (opts?.line !== undefined) params.push(`line=${opts.line}`);
43
+ if (opts?.col !== undefined) params.push(`col=${opts.col}`);
44
+ const query = params.length > 0 ? `?${params.join("&")}` : "";
45
+ return `${prefix}${encoded}${query}`;
46
+ }
47
+
48
+ /**
49
+ * Returns true when OSC 8 hyperlinks should be emitted.
50
+ *
51
+ * Respects `tui.hyperlinks` setting:
52
+ * - `"off"`: never
53
+ * - `"auto"`: when `process.stdout.isTTY`, `NO_COLOR` is unset, and the detected terminal reports hyperlink support
54
+ * - `"always"`: unconditionally (useful for viewers that support OSC 8 without advertising it)
55
+ */
56
+ export function isHyperlinkEnabled(): boolean {
57
+ const mode = settings.get("tui.hyperlinks");
58
+ if (mode === "off") return false;
59
+ if (mode === "always") return true;
60
+ // auto: respect terminal capabilities and NO_COLOR
61
+ if (Bun.env.NO_COLOR) return false;
62
+ if (!process.stdout.isTTY) return false;
63
+ return TERMINAL.hyperlinks;
64
+ }
65
+
66
+ /**
67
+ * Wrap `displayText` in an OSC 8 hyperlink pointing at the given absolute file path.
68
+ *
69
+ * Returns `displayText` unchanged when hyperlinks are disabled or when
70
+ * the text already contains an OSC 8 sequence (prevents double-wrapping).
71
+ *
72
+ * The caller is responsible for passing an absolute path. Relative paths
73
+ * produce invalid `file://` URIs and are accepted silently to avoid runtime
74
+ * errors in renderer hot paths.
75
+ *
76
+ * @param absPath - Absolute filesystem path
77
+ * @param displayText - Text to render as the hyperlink anchor (may contain ANSI codes)
78
+ * @param opts - Optional line/col position appended as `?line=N&col=M` query params
79
+ */
80
+ export function fileHyperlink(absPath: string, displayText: string, opts?: { line?: number; col?: number }): string {
81
+ if (!isHyperlinkEnabled()) return displayText;
82
+ // Do not double-wrap if the text already embeds an OSC 8 sequence.
83
+ if (displayText.includes("\x1b]8;")) return displayText;
84
+ const uri = buildFileUri(absPath, opts);
85
+ const id = buildLinkId(uri);
86
+ return `${OSC}8;id=${id};${uri}${ST}${displayText}${OSC}8;;${ST}`;
87
+ }
88
+
89
+ /**
90
+ * Synchronously resolve a filesystem-backed internal URL (e.g. `local://foo.md`,
91
+ * `memory://root/notes.md`) to its absolute filesystem path. Returns `undefined`
92
+ * for inputs that aren't fs-backed, aren't resolvable in the current session
93
+ * registry, or fail to parse.
94
+ *
95
+ * Used by renderers to wrap fs-backed internal URLs in OSC 8 hyperlinks even
96
+ * when the resolved path isn't yet available from tool result details (e.g.
97
+ * during the call/streaming phase before a result lands).
98
+ *
99
+ * Async-resolved schemes (`artifact://`, `agent://`, `skill://`, `rule://`,
100
+ * `omp://`) are not handled here — those rely on `details.resolvedPath` set
101
+ * by the read tool's router resolution.
102
+ */
103
+ export function tryResolveInternalUrlSync(input: string): string | undefined {
104
+ try {
105
+ if (input.startsWith("local://")) {
106
+ const opts = LocalProtocolHandler.resolveOptions();
107
+ if (!opts) return undefined;
108
+ return resolveLocalUrlToPath(input, opts);
109
+ }
110
+ if (input.startsWith("memory://")) {
111
+ const url = parseInternalUrl(input);
112
+ const roots = memoryRootsFromRegistry();
113
+ for (const root of roots) {
114
+ try {
115
+ return resolveMemoryUrlToPath(url, root);
116
+ } catch {
117
+ // Try the next root; some sessions may not have this namespace mounted.
118
+ }
119
+ }
120
+ return undefined;
121
+ }
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ return undefined;
126
+ }
package/src/tui/index.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  export * from "./code-cell";
6
6
  export * from "./file-list";
7
+ export * from "./hyperlink";
7
8
  export * from "./output-block";
8
9
  export * from "./status-line";
9
10
  export * from "./tree-list";
@@ -2,7 +2,8 @@ import type { Api, Model, ToolChoice } from "@oh-my-pi/pi-ai";
2
2
 
3
3
  /**
4
4
  * Build a provider-aware tool choice that targets one specific tool when supported.
5
- * Some providers only support "any tool" forcing, not a named tool.
5
+ * Providers that only expose required/any forcing may still honor named choices by
6
+ * narrowing their request tool list before transport.
6
7
  */
7
8
  export function buildNamedToolChoice(toolName: string, model?: Model<Api>): ToolChoice | undefined {
8
9
  if (!model) return undefined;
@@ -20,12 +21,11 @@ export function buildNamedToolChoice(toolName: string, model?: Model<Api>): Tool
20
21
  return { type: "function", name: toolName };
21
22
  }
22
23
 
23
- if (
24
- model.api === "google-generative-ai" ||
25
- model.api === "google-gemini-cli" ||
26
- model.api === "google-vertex" ||
27
- model.api === "ollama-chat"
28
- ) {
24
+ if (model.api === "ollama-chat") {
25
+ return { type: "function", name: toolName };
26
+ }
27
+
28
+ if (model.api === "google-generative-ai" || model.api === "google-gemini-cli" || model.api === "google-vertex") {
29
29
  return "required";
30
30
  }
31
31
 
package/src/web/kagi.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { getEnvApiKey } from "@oh-my-pi/pi-ai";
2
- import { findCredential } from "./search/providers/utils";
2
+ import { findCredential, withHardTimeout } from "./search/providers/utils";
3
3
 
4
4
  const KAGI_SEARCH_URL = "https://kagi.com/api/v0/search";
5
5
 
@@ -138,7 +138,7 @@ export async function searchWithKagi(query: string, options: KagiSearchOptions =
138
138
 
139
139
  const response = await fetch(requestUrl, {
140
140
  headers: getAuthHeaders(apiKey),
141
- signal: options.signal,
141
+ signal: withHardTimeout(options.signal),
142
142
  });
143
143
  if (!response.ok) {
144
144
  throw parseKagiErrorResponse(response.status, await response.text());
@@ -1,5 +1,5 @@
1
1
  import { getEnvApiKey } from "@oh-my-pi/pi-ai";
2
- import { findCredential } from "./search/providers/utils";
2
+ import { findCredential, withHardTimeout } from "./search/providers/utils";
3
3
 
4
4
  const PARALLEL_API_URL = "https://api.parallel.ai";
5
5
  const PARALLEL_SEARCH_URL = `${PARALLEL_API_URL}/v1beta/search`;
@@ -304,7 +304,7 @@ export async function searchWithParallel(
304
304
  max_chars_per_result: options.maxCharsPerResult ?? 10_000,
305
305
  },
306
306
  }),
307
- signal: options.signal,
307
+ signal: withHardTimeout(options.signal),
308
308
  });
309
309
  if (!response.ok) {
310
310
  throw parseParallelErrorResponse(response.status, await response.text());
@@ -335,7 +335,7 @@ export async function extractWithParallel(
335
335
  excerpts: options.excerpts ?? true,
336
336
  full_content: options.fullContent ?? false,
337
337
  }),
338
- signal: options.signal,
338
+ signal: withHardTimeout(options.signal),
339
339
  });
340
340
  if (!response.ok) {
341
341
  throw parseParallelErrorResponse(response.status, await response.text());
@@ -14,6 +14,7 @@ import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { ty
14
14
  import webSearchDescription from "../../prompts/tools/web-search.md" with { type: "text" };
15
15
  import type { ToolSession } from "../../tools";
16
16
  import { formatAge } from "../../tools/render-utils";
17
+ import { throwIfAborted } from "../../tools/tool-errors";
17
18
  import { getSearchProvider, getSearchProviderLabel, resolveProviderChain, type SearchProvider } from "./provider";
18
19
  import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
19
20
  import type { SearchProviderId, SearchResponse } from "./types";
@@ -35,10 +36,6 @@ export interface SearchQueryParams extends SearchToolParams {
35
36
  provider?: SearchProviderId | "auto";
36
37
  }
37
38
 
38
- function formatProviderList(providers: SearchProvider[]): string {
39
- return providers.map(provider => provider.label).join(", ");
40
- }
41
-
42
39
  function formatProviderError(error: unknown, provider: SearchProvider): string {
43
40
  if (error instanceof SearchProviderError) {
44
41
  if (error.provider === "anthropic" && error.status === 404) {
@@ -137,9 +134,8 @@ async function executeSearch(
137
134
  };
138
135
  }
139
136
 
140
- let lastError: unknown;
137
+ const failures: Array<{ provider: SearchProvider; error: unknown }> = [];
141
138
  let lastProvider = providers[0];
142
-
143
139
  for (const provider of providers) {
144
140
  lastProvider = provider;
145
141
  try {
@@ -161,14 +157,29 @@ async function executeSearch(
161
157
  details: { response },
162
158
  };
163
159
  } catch (error) {
164
- lastError = error;
160
+ // Surface user-initiated cancellation immediately so the session sees
161
+ // a clean abort instead of a generic "all providers failed" message.
162
+ // Without this, an AbortError from `fetch()` is treated as a provider
163
+ // failure and the loop falls through to the next provider (or to the
164
+ // summary error), masking the cancellation.
165
+ throwIfAborted(signal);
166
+ failures.push({ provider, error });
165
167
  }
166
168
  }
167
169
 
168
- const baseMessage = formatProviderError(lastError, lastProvider);
170
+ const lastFailure = failures[failures.length - 1];
171
+ const baseMessage = lastFailure
172
+ ? formatProviderError(lastFailure.error, lastFailure.provider)
173
+ : `Unknown error from ${lastProvider.label}`;
169
174
  const message =
170
175
  providers.length > 1
171
- ? `All web search providers failed (${formatProviderList(providers)}). Last error: ${baseMessage}`
176
+ ? `All web search providers failed: ${failures
177
+ .map(f =>
178
+ f.error instanceof SearchProviderError
179
+ ? f.error.message
180
+ : `${f.provider.id}: ${formatProviderError(f.error, f.provider)}`,
181
+ )
182
+ .join("; ")}`
172
183
  : baseMessage;
173
184
 
174
185
  return {
@@ -24,12 +24,12 @@ import type {
24
24
  import { SearchProviderError } from "../../../web/search/types";
25
25
  import type { SearchParams } from "./base";
26
26
  import { SearchProvider } from "./base";
27
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
27
28
 
28
29
  const DEFAULT_MODEL = "claude-haiku-4-5";
29
30
  const DEFAULT_MAX_TOKENS = 4096;
30
31
  const WEB_SEARCH_TOOL_NAME = "web_search";
31
32
  const WEB_SEARCH_TOOL_TYPE = "web_search_20250305";
32
-
33
33
  export interface AnthropicSearchParams {
34
34
  query: string;
35
35
  system_prompt?: string;
@@ -118,11 +118,13 @@ async function callSearch(
118
118
  method: "POST",
119
119
  headers,
120
120
  body: JSON.stringify(body),
121
- signal,
121
+ signal: withHardTimeout(signal),
122
122
  });
123
123
 
124
124
  if (!response.ok) {
125
125
  const errorText = await response.text();
126
+ const classified = classifyProviderHttpError("anthropic", response.status, errorText);
127
+ if (classified) throw classified;
126
128
  throw new SearchProviderError(
127
129
  "anthropic",
128
130
  `Anthropic API error (${response.status}): ${errorText}`,
@@ -10,7 +10,7 @@ import { SearchProviderError } from "../../../web/search/types";
10
10
  import { clampNumResults, dateToAgeSeconds } from "../utils";
11
11
  import type { SearchParams } from "./base";
12
12
  import { SearchProvider } from "./base";
13
- import { isApiKeyAvailable } from "./utils";
13
+ import { classifyProviderHttpError, isApiKeyAvailable, withHardTimeout } from "./utils";
14
14
 
15
15
  const BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search";
16
16
  const DEFAULT_NUM_RESULTS = 10;
@@ -85,11 +85,13 @@ async function callBraveSearch(
85
85
  Accept: "application/json",
86
86
  "X-Subscription-Token": apiKey,
87
87
  },
88
- signal: params.signal,
88
+ signal: withHardTimeout(params.signal),
89
89
  });
90
90
 
91
91
  if (!response.ok) {
92
92
  const errorText = await response.text();
93
+ const classified = classifyProviderHttpError("brave", response.status, errorText);
94
+ if (classified) throw classified;
93
95
  throw new SearchProviderError("brave", `Brave API error (${response.status}): ${errorText}`, response.status);
94
96
  }
95
97
 
@@ -15,6 +15,7 @@ import type { SearchResponse, SearchSource } from "../../../web/search/types";
15
15
  import { SearchProviderError } from "../../../web/search/types";
16
16
  import type { SearchParams } from "./base";
17
17
  import { SearchProvider } from "./base";
18
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
18
19
 
19
20
  const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
20
21
  const CODEX_RESPONSES_PATH = "/codex/responses";
@@ -338,11 +339,13 @@ async function callCodexSearch(
338
339
  method: "POST",
339
340
  headers,
340
341
  body: JSON.stringify(body),
341
- signal: options.signal,
342
+ signal: withHardTimeout(options.signal),
342
343
  });
343
344
 
344
345
  if (!response.ok) {
345
346
  const errorText = await response.text();
347
+ const classified = classifyProviderHttpError("codex", response.status, errorText);
348
+ if (classified) throw classified;
346
349
  throw new SearchProviderError("codex", `Codex API error (${response.status}): ${errorText}`, response.status);
347
350
  }
348
351
 
@@ -14,6 +14,7 @@ import { SearchProviderError } from "../../../web/search/types";
14
14
  import { dateToAgeSeconds } from "../utils";
15
15
  import type { SearchParams } from "./base";
16
16
  import { SearchProvider } from "./base";
17
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
17
18
 
18
19
  const EXA_API_URL = "https://api.exa.ai/search";
19
20
 
@@ -180,11 +181,13 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
180
181
  "x-api-key": apiKey,
181
182
  },
182
183
  body: JSON.stringify(body),
183
- signal: params.signal,
184
+ signal: withHardTimeout(params.signal),
184
185
  });
185
186
 
186
187
  if (!response.ok) {
187
188
  const errorText = await response.text();
189
+ const classified = classifyProviderHttpError("exa", response.status, errorText);
190
+ if (classified) throw classified;
188
191
  throw new SearchProviderError("exa", `Exa API error (${response.status}): ${errorText}`, response.status);
189
192
  }
190
193
 
@@ -15,6 +15,7 @@ import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/
15
15
  import { SearchProviderError } from "../../../web/search/types";
16
16
  import type { SearchParams } from "./base";
17
17
  import { SearchProvider } from "./base";
18
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
18
19
 
19
20
  const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
20
21
  const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
@@ -310,7 +311,7 @@ async function callGeminiSearch(
310
311
  ...headers,
311
312
  },
312
313
  body: JSON.stringify(requestBody),
313
- signal,
314
+ signal: withHardTimeout(signal),
314
315
  });
315
316
  const urlFor = (attempt: number) =>
316
317
  `${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`;
@@ -340,6 +341,8 @@ async function callGeminiSearch(
340
341
 
341
342
  if (!response.ok) {
342
343
  const errorText = await response.text();
344
+ const classified = classifyProviderHttpError("gemini", response.status, errorText);
345
+ if (classified) throw classified;
343
346
  throw new SearchProviderError(
344
347
  "gemini",
345
348
  `Gemini Cloud Code API error (${response.status}): ${errorText}`,
@@ -10,7 +10,7 @@ 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
- import { isApiKeyAvailable } from "./utils";
13
+ import { classifyProviderHttpError, isApiKeyAvailable, withHardTimeout } from "./utils";
14
14
 
15
15
  const JINA_SEARCH_URL = "https://s.jina.ai";
16
16
 
@@ -41,11 +41,13 @@ async function callJinaSearch(apiKey: string, query: string, signal?: AbortSigna
41
41
  Accept: "application/json",
42
42
  Authorization: `Bearer ${apiKey}`,
43
43
  },
44
- signal,
44
+ signal: withHardTimeout(signal),
45
45
  });
46
46
 
47
47
  if (!response.ok) {
48
48
  const errorText = await response.text();
49
+ const classified = classifyProviderHttpError("jina", response.status, errorText);
50
+ if (classified) throw classified;
49
51
  throw new SearchProviderError("jina", `Jina API error (${response.status}): ${errorText}`, response.status);
50
52
  }
51
53
 
@@ -9,7 +9,7 @@ import { findKagiApiKey, KagiApiError, searchWithKagi } from "../../kagi";
9
9
  import { clampNumResults } from "../utils";
10
10
  import type { SearchParams } from "./base";
11
11
  import { SearchProvider } from "./base";
12
- import { toSearchSources } from "./utils";
12
+ import { classifyProviderHttpError, toSearchSources } from "./utils";
13
13
 
14
14
  const DEFAULT_NUM_RESULTS = 10;
15
15
  const MAX_NUM_RESULTS = 40;
@@ -36,6 +36,10 @@ export async function searchKagi(params: {
36
36
  };
37
37
  } catch (err) {
38
38
  if (err instanceof KagiApiError) {
39
+ if (typeof err.statusCode === "number") {
40
+ const classified = classifyProviderHttpError("kagi", err.statusCode, err.message);
41
+ if (classified) throw classified;
42
+ }
39
43
  throw new SearchProviderError("kagi", err.message, err.statusCode);
40
44
  }
41
45
  throw err;
@@ -11,7 +11,7 @@ import { SearchProviderError } from "../../../web/search/types";
11
11
  import { clampNumResults, dateToAgeSeconds } from "../utils";
12
12
  import type { SearchParams } from "./base";
13
13
  import { SearchProvider } from "./base";
14
- import { findCredential, isApiKeyAvailable } from "./utils";
14
+ import { classifyProviderHttpError, findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
15
15
 
16
16
  const KIMI_SEARCH_URL = "https://api.kimi.com/coding/v1/search";
17
17
 
@@ -78,11 +78,13 @@ async function callKimiSearch(
78
78
  enable_page_crawling: params.includeContent,
79
79
  timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
80
80
  }),
81
- signal: params.signal,
81
+ signal: withHardTimeout(params.signal),
82
82
  });
83
83
 
84
84
  if (!response.ok) {
85
85
  const errorText = await response.text();
86
+ const classified = classifyProviderHttpError("kimi", response.status, errorText);
87
+ if (classified) throw classified;
86
88
  throw new SearchProviderError(
87
89
  "kimi",
88
90
  `Kimi search API error (${response.status}): ${errorText}`,
@@ -4,7 +4,7 @@ import { findParallelApiKey, ParallelApiError, searchWithParallel } from "../../
4
4
  import { clampNumResults } from "../utils";
5
5
  import type { SearchParams } from "./base";
6
6
  import { SearchProvider } from "./base";
7
- import { toSearchSources } from "./utils";
7
+ import { classifyProviderHttpError, toSearchSources } from "./utils";
8
8
 
9
9
  const DEFAULT_NUM_RESULTS = 10;
10
10
  const MAX_NUM_RESULTS = 40;
@@ -30,6 +30,10 @@ export async function searchParallel(params: {
30
30
  };
31
31
  } catch (err) {
32
32
  if (err instanceof ParallelApiError) {
33
+ if (typeof err.statusCode === "number") {
34
+ const classified = classifyProviderHttpError("parallel", err.statusCode, err.message);
35
+ if (classified) throw classified;
36
+ }
33
37
  throw new SearchProviderError("parallel", err.message, err.statusCode);
34
38
  }
35
39
  throw err;
@@ -22,6 +22,7 @@ import { SearchProviderError } from "../../../web/search/types";
22
22
  import { dateToAgeSeconds } from "../utils";
23
23
  import type { SearchParams } from "./base";
24
24
  import { SearchProvider } from "./base";
25
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
25
26
 
26
27
  const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions";
27
28
  const PERPLEXITY_OAUTH_ASK_URL = "https://www.perplexity.ai/rest/sse/perplexity_ask";
@@ -247,11 +248,13 @@ async function callPerplexityApi(
247
248
  "Content-Type": "application/json",
248
249
  },
249
250
  body: JSON.stringify(request),
250
- signal,
251
+ signal: withHardTimeout(signal),
251
252
  });
252
253
 
253
254
  if (!response.ok) {
254
255
  const errorText = await response.text();
256
+ const classified = classifyProviderHttpError("perplexity", response.status, errorText);
257
+ if (classified) throw classified;
255
258
  throw new SearchProviderError(
256
259
  "perplexity",
257
260
  `Perplexity API error (${response.status}): ${errorText}`,
@@ -364,11 +367,13 @@ async function callPerplexityOAuth(
364
367
  skip_search_enabled: true,
365
368
  },
366
369
  }),
367
- signal: params.signal,
370
+ signal: withHardTimeout(params.signal),
368
371
  });
369
372
 
370
373
  if (!response.ok) {
371
374
  const errorText = await response.text();
375
+ const classified = classifyProviderHttpError("perplexity", response.status, errorText);
376
+ if (classified) throw classified;
372
377
  throw new SearchProviderError(
373
378
  "perplexity",
374
379
  `Perplexity OAuth API error (${response.status}): ${errorText}`,
@@ -31,6 +31,7 @@ import { SearchProviderError } from "../../../web/search/types";
31
31
  import { clampNumResults, dateToAgeSeconds } from "../utils";
32
32
  import type { SearchParams } from "./base";
33
33
  import { SearchProvider } from "./base";
34
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
34
35
 
35
36
  const DEFAULT_NUM_RESULTS = 10;
36
37
  const MAX_NUM_RESULTS = 20;
@@ -211,11 +212,13 @@ async function callSearXNGSearch(
211
212
 
212
213
  const response = await fetch(url, {
213
214
  headers,
214
- signal: params.signal,
215
+ signal: withHardTimeout(params.signal),
215
216
  });
216
217
 
217
218
  if (!response.ok) {
218
219
  const errorText = await response.text();
220
+ const classified = classifyProviderHttpError("searxng", response.status, errorText);
221
+ if (classified) throw classified;
219
222
  throw new SearchProviderError("searxng", `SearXNG API error (${response.status}): ${errorText}`, response.status);
220
223
  }
221
224
 
@@ -10,7 +10,7 @@ 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
- import { findCredential, isApiKeyAvailable } from "./utils";
13
+ import { classifyProviderHttpError, findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
14
14
 
15
15
  const SYNTHETIC_SEARCH_URL = "https://api.synthetic.new/v2/search";
16
16
 
@@ -43,11 +43,13 @@ async function callSyntheticSearch(
43
43
  Authorization: `Bearer ${apiKey}`,
44
44
  },
45
45
  body: JSON.stringify({ query }),
46
- signal,
46
+ signal: withHardTimeout(signal),
47
47
  });
48
48
 
49
49
  if (!response.ok) {
50
50
  const errorText = await response.text();
51
+ const classified = classifyProviderHttpError("synthetic", response.status, errorText);
52
+ if (classified) throw classified;
51
53
  throw new SearchProviderError(
52
54
  "synthetic",
53
55
  `Synthetic API error (${response.status}): ${errorText}`,
@@ -10,7 +10,7 @@ import { SearchProviderError } from "../../../web/search/types";
10
10
  import { clampNumResults, dateToAgeSeconds } from "../utils";
11
11
  import type { SearchParams } from "./base";
12
12
  import { SearchProvider } from "./base";
13
- import { findCredential, isApiKeyAvailable } from "./utils";
13
+ import { classifyProviderHttpError, findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
14
14
 
15
15
  const TAVILY_SEARCH_URL = "https://api.tavily.com/search";
16
16
  const DEFAULT_NUM_RESULTS = 5;
@@ -92,11 +92,13 @@ async function callTavilySearch(apiKey: string, params: TavilySearchParams): Pro
92
92
  Authorization: `Bearer ${apiKey}`,
93
93
  },
94
94
  body: JSON.stringify(buildRequestBody(params)),
95
- signal: params.signal,
95
+ signal: withHardTimeout(params.signal),
96
96
  });
97
97
 
98
98
  if (!response.ok) {
99
99
  const errorText = await response.text();
100
+ const classified = classifyProviderHttpError("tavily", response.status, errorText);
101
+ if (classified) throw classified;
100
102
  let message = errorText.trim();
101
103
  if (message.length === 0) {
102
104
  message = response.statusText;