@oh-my-pi/pi-ai 16.2.7 → 16.2.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.2.9] - 2026-06-30
6
+
7
+ ### Added
8
+
9
+ - Added `OAuthCallbackFlowOptions.allowPortFallback` to allow disabling random-port fallback, enabling strict port enforcement and early configuration errors for OAuth flows with static redirect URIs.
10
+
11
+ ### Changed
12
+
13
+ - Improved `OAuthCallbackFlow` port conflict error messages to include the busy port, configured redirect URI, and actionable remediation steps.
14
+
15
+ ### Fixed
16
+
17
+ - Fixed an issue where malformed tool-call JSON from local Ollama or llama.cpp models was incorrectly retried as generic 500 errors, now surfacing a clear recovery message.
18
+ - Fixed a race condition in OAuth callback flows where abort signals triggered before the callback listener was registered were ignored.
19
+
5
20
  ## [16.2.7] - 2026-06-30
6
21
 
7
22
  ### Added
@@ -23,6 +23,13 @@ export declare const Flag: {
23
23
  export type Flag = (typeof Flag)[keyof typeof Flag];
24
24
  export declare const STREAM_READ_ERROR_PATTERN: RegExp;
25
25
  export declare const TRANSIENT_TRANSPORT_PATTERN: RegExp;
26
+ /**
27
+ * Local llama.cpp / Ollama deterministic tool-call argument JSON parse failure.
28
+ * The model emitted invalid JSON in a tool call and the server returned HTTP 500
29
+ * with this exact text — replaying the same prompt yields the same malformed
30
+ * output, so callers strip {@link Flag.Transient} when this matches.
31
+ */
32
+ export declare const LLAMA_CPP_TOOL_CALL_PARSE_PATTERN: RegExp;
26
33
  /** Whether an OAuth refresh error message means the grant is definitively dead. */
27
34
  export declare function isOAuthExpiry(errorMessage: string): boolean;
28
35
  export declare function create(...flags: number[]): number;
@@ -5,7 +5,7 @@ export interface FormatMessageOptions {
5
5
  rawRequestDump?: RawHttpRequestDump;
6
6
  /** Captured non-2xx response body, appended to the message when available. */
7
7
  capturedErrorResponse?: CapturedHttpErrorResponse;
8
- /** Provider id; `"github-copilot"` triggers the copilot message rewrite. */
8
+ /** Provider id; gates provider-specific user-facing rewrites. */
9
9
  provider?: string;
10
10
  }
11
11
  /**
@@ -9,6 +9,20 @@ export interface OAuthCallbackFlowOptions {
9
9
  callbackHostname?: string;
10
10
  /** Exact redirect URI advertised to the provider; disables port fallback. */
11
11
  redirectUri?: string;
12
+ /**
13
+ * Whether the flow may bind to a random port when {@link preferredPort} is
14
+ * unavailable. Defaults to `true` so historical AI-provider flows (which
15
+ * pick uncommon ports and tolerate any loopback callback) keep working.
16
+ *
17
+ * Set to `false` for providers that validate the redirect URI against a
18
+ * registered callback — silently advertising a random-port URI would be
19
+ * rejected by the authorization server, leaving the browser on an opaque
20
+ * 500 page and the local callback waiting until the 5-minute timeout fires.
21
+ * With fallback disabled, {@link OAuthCallbackFlow.login} throws a
22
+ * {@link AIError.ConfigurationError} immediately so the caller can surface
23
+ * an actionable message before opening the browser.
24
+ */
25
+ allowPortFallback?: boolean;
12
26
  }
13
27
  /**
14
28
  * Abstract base class for OAuth flows with local callback servers.
@@ -20,6 +34,7 @@ export declare abstract class OAuthCallbackFlow {
20
34
  callbackPath: string;
21
35
  callbackHostname: string;
22
36
  redirectUri?: string;
37
+ allowPortFallback: boolean;
23
38
  constructor(ctrl: OAuthController, preferredPortOrOptions: number | OAuthCallbackFlowOptions, callbackPath?: string);
24
39
  /**
25
40
  * Generate provider-specific authorization URL.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.2.7",
4
+ "version": "16.2.9",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.2.7",
42
- "@oh-my-pi/pi-utils": "16.2.7",
43
- "@oh-my-pi/pi-wire": "16.2.7",
41
+ "@oh-my-pi/pi-catalog": "16.2.9",
42
+ "@oh-my-pi/pi-utils": "16.2.9",
43
+ "@oh-my-pi/pi-wire": "16.2.9",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -94,6 +94,14 @@ const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
94
94
  const PROVIDER_FINISH_ERROR_PATTERN = /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i;
95
95
  const STALE_RESPONSE_ITEM_PATTERNS = [/\bItem with id ['"][^'"]+['"] not found\.?/i, /previous[ _]?response/i] as const;
96
96
  const STALE_RESPONSE_ITEM_DETAIL_PATTERN = /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i;
97
+ /**
98
+ * Local llama.cpp / Ollama deterministic tool-call argument JSON parse failure.
99
+ * The model emitted invalid JSON in a tool call and the server returned HTTP 500
100
+ * with this exact text — replaying the same prompt yields the same malformed
101
+ * output, so callers strip {@link Flag.Transient} when this matches.
102
+ */
103
+ export const LLAMA_CPP_TOOL_CALL_PARSE_PATTERN =
104
+ /failed to parse tool call arguments as json|\[json\.exception\.parse_error\.101\]/i;
97
105
 
98
106
  // Copilot routing flap: HTTP 400 `model_not_supported` (structural code on the
99
107
  // error, also surfaced in text). Treated as transient — a retry usually lands
@@ -441,7 +449,13 @@ export function classifyMessage(message: {
441
449
  const currentStatus = message.errorStatus ?? statusFromId(existingId);
442
450
  const textId = classifyText(message.errorMessage, currentStatus, message.api);
443
451
 
444
- const kinds = ((existingId ?? 0) | textId) & KIND_MASK;
452
+ let kinds = ((existingId ?? 0) | textId) & KIND_MASK;
453
+ if (message.errorMessage && LLAMA_CPP_TOOL_CALL_PARSE_PATTERN.test(message.errorMessage)) {
454
+ // Deterministic local-model tool-call JSON parse failure: HTTP 500 is misleading
455
+ // because the same prompt reproduces the same malformed output, so the agent-level
456
+ // auto-retry would loop. Strip Transient so the recovery message surfaces immediately.
457
+ kinds &= ~Flag.Transient;
458
+ }
445
459
  const id = kinds !== 0 ? create(kinds) : (statusFromId(textId) ?? statusFromId(existingId) ?? currentStatus ?? 0);
446
460
 
447
461
  message.errorId = id;
@@ -5,6 +5,12 @@ import {
5
5
  rewriteCopilotError,
6
6
  } from "../utils/http-inspector";
7
7
  import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
8
+ import { LLAMA_CPP_TOOL_CALL_PARSE_PATTERN } from "./flags";
9
+
10
+ function rewriteOllamaToolCallJsonError(message: string): string {
11
+ if (!LLAMA_CPP_TOOL_CALL_PARSE_PATTERN.test(message)) return message;
12
+ return `Local Ollama model emitted malformed tool-call JSON and llama.cpp rejected it (HTTP 500). This is usually a deterministic model-output failure after context degradation, not a transient server outage; reload the model or reduce context, then retry.\n${message}`;
13
+ }
8
14
 
9
15
  /** Inputs that steer {@link formatMessage}'s formatter selection. */
10
16
  export interface FormatMessageOptions {
@@ -12,7 +18,7 @@ export interface FormatMessageOptions {
12
18
  rawRequestDump?: RawHttpRequestDump;
13
19
  /** Captured non-2xx response body, appended to the message when available. */
14
20
  capturedErrorResponse?: CapturedHttpErrorResponse;
15
- /** Provider id; `"github-copilot"` triggers the copilot message rewrite. */
21
+ /** Provider id; gates provider-specific user-facing rewrites. */
16
22
  provider?: string;
17
23
  }
18
24
 
@@ -32,5 +38,8 @@ export async function formatMessage(error: unknown, opts: FormatMessageOptions =
32
38
  if (opts.provider === "github-copilot") {
33
39
  message = rewriteCopilotError(message, error, opts.provider);
34
40
  }
41
+ if (opts.provider === "ollama") {
42
+ message = rewriteOllamaToolCallJsonError(message);
43
+ }
35
44
  return message;
36
45
  }
@@ -323,6 +323,10 @@ function createChatBody(model: Model<"ollama-chat">, context: Context, options:
323
323
  };
324
324
  }
325
325
 
326
+ function shouldRetryOllamaResponse(response: Response, bodyText: string): boolean {
327
+ return response.status < 500 || !AIError.LLAMA_CPP_TOOL_CALL_PARSE_PATTERN.test(bodyText);
328
+ }
329
+
326
330
  async function captureHttpErrorResponse(response: Response): Promise<CapturedHttpErrorResponse> {
327
331
  let bodyText: string | undefined;
328
332
  let bodyJson: unknown;
@@ -598,6 +602,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
598
602
  body: JSON.stringify(body),
599
603
  signal: watchdog.signal,
600
604
  defaultDelayMs: OLLAMA_RETRY_DELAYS_MS,
605
+ shouldRetryResponse: shouldRetryOllamaResponse,
601
606
  fetch: options.fetch,
602
607
  timeout: false,
603
608
  });
@@ -747,6 +752,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
747
752
  }
748
753
  const result = await AIError.finalize(error, {
749
754
  api: model.api,
755
+ provider: model.provider,
750
756
  signal: options.signal,
751
757
  rawRequestDump,
752
758
  capturedErrorResponse,
@@ -26,6 +26,20 @@ export interface OAuthCallbackFlowOptions {
26
26
  callbackHostname?: string;
27
27
  /** Exact redirect URI advertised to the provider; disables port fallback. */
28
28
  redirectUri?: string;
29
+ /**
30
+ * Whether the flow may bind to a random port when {@link preferredPort} is
31
+ * unavailable. Defaults to `true` so historical AI-provider flows (which
32
+ * pick uncommon ports and tolerate any loopback callback) keep working.
33
+ *
34
+ * Set to `false` for providers that validate the redirect URI against a
35
+ * registered callback — silently advertising a random-port URI would be
36
+ * rejected by the authorization server, leaving the browser on an opaque
37
+ * 500 page and the local callback waiting until the 5-minute timeout fires.
38
+ * With fallback disabled, {@link OAuthCallbackFlow.login} throws a
39
+ * {@link AIError.ConfigurationError} immediately so the caller can surface
40
+ * an actionable message before opening the browser.
41
+ */
42
+ allowPortFallback?: boolean;
29
43
  }
30
44
 
31
45
  /**
@@ -37,6 +51,7 @@ export abstract class OAuthCallbackFlow {
37
51
  callbackPath: string;
38
52
  callbackHostname: string;
39
53
  redirectUri?: string;
54
+ allowPortFallback: boolean;
40
55
  #callbackResolve?: (result: CallbackResult) => void;
41
56
  #callbackReject?: (error: string) => void;
42
57
 
@@ -50,6 +65,7 @@ export abstract class OAuthCallbackFlow {
50
65
  this.preferredPort = preferredPortOrOptions;
51
66
  this.callbackPath = callbackPath;
52
67
  this.callbackHostname = DEFAULT_HOSTNAME;
68
+ this.allowPortFallback = true;
53
69
  return;
54
70
  }
55
71
 
@@ -57,6 +73,7 @@ export abstract class OAuthCallbackFlow {
57
73
  this.callbackPath = preferredPortOrOptions.callbackPath ?? CALLBACK_PATH;
58
74
  this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
59
75
  this.redirectUri = preferredPortOrOptions.redirectUri;
76
+ this.allowPortFallback = preferredPortOrOptions.allowPortFallback ?? true;
60
77
  }
61
78
 
62
79
  /**
@@ -87,18 +104,29 @@ export abstract class OAuthCallbackFlow {
87
104
  .join("");
88
105
  }
89
106
 
107
+ #loginCancelledError(): AIError.LoginCancelledError {
108
+ return new AIError.LoginCancelledError(`OAuth callback cancelled: ${this.ctrl.signal?.reason}`);
109
+ }
110
+
111
+ #throwIfCancelled(): void {
112
+ if (this.ctrl.signal?.aborted) throw this.#loginCancelledError();
113
+ }
114
+
90
115
  /**
91
116
  * Execute the OAuth login flow.
92
117
  */
93
118
  async login(): Promise<OAuthCredentials> {
94
119
  const state = this.generateState();
120
+ this.#throwIfCancelled();
95
121
 
96
122
  // Start callback server first to get actual redirect URI
97
123
  const { server, redirectUri } = await this.#startCallbackServer(state);
98
124
 
99
125
  try {
126
+ this.#throwIfCancelled();
100
127
  // Generate auth URL with the ACTUAL redirect URI (may differ from expected if port was busy)
101
128
  const { url: authUrl, instructions } = await this.generateAuthUrl(state, redirectUri);
129
+ this.#throwIfCancelled();
102
130
 
103
131
  // Notify controller that auth is ready
104
132
  this.ctrl.onAuth?.({ url: authUrl, instructions });
@@ -106,6 +134,7 @@ export abstract class OAuthCallbackFlow {
106
134
 
107
135
  // Wait for callback or manual input
108
136
  const { code } = await this.#waitForCallback(state);
137
+ this.#throwIfCancelled();
109
138
 
110
139
  this.ctrl.onProgress?.("Exchanging authorization code for tokens...");
111
140
 
@@ -126,10 +155,17 @@ export abstract class OAuthCallbackFlow {
126
155
  }
127
156
  const redirectUri = `http://${this.callbackHostname}:${this.preferredPort}${this.callbackPath}`;
128
157
  return { server, redirectUri };
129
- } catch {
158
+ } catch (cause) {
130
159
  if (this.redirectUri) {
131
160
  throw new AIError.ConfigurationError(
132
- `OAuth callback port ${this.preferredPort} unavailable; cannot fall back to a random port when oauth.redirectUri is set`,
161
+ `OAuth callback port ${this.preferredPort} is in use, but oauth.redirectUri (${this.redirectUri}) requires this exact port. Free port ${this.preferredPort} (e.g. stop the process bound to it) and retry, or change oauth.redirectUri to point at an available port.`,
162
+ { cause },
163
+ );
164
+ }
165
+ if (!this.allowPortFallback) {
166
+ throw new AIError.ConfigurationError(
167
+ `OAuth callback port ${this.preferredPort} is in use. The OAuth provider validates redirect URIs against its registered callback, so falling back to a random port would be rejected. Free port ${this.preferredPort} (e.g. stop the process bound to it) and retry, or set oauth.callbackPort/oauth.redirectUri to a port the provider has registered.`,
168
+ { cause },
133
169
  );
134
170
  }
135
171
  const server = this.#createServer(0, expectedState);
@@ -208,17 +244,18 @@ export abstract class OAuthCallbackFlow {
208
244
  #waitForCallback(expectedState: string): Promise<CallbackResult> {
209
245
  const timeoutSignal = AbortSignal.timeout(DEFAULT_TIMEOUT);
210
246
  const signal = this.ctrl.signal ? AbortSignal.any([this.ctrl.signal, timeoutSignal]) : timeoutSignal;
247
+ if (signal.aborted) return Promise.reject(this.#loginCancelledError());
211
248
 
212
- const callbackPromise = new Promise<CallbackResult>((resolve, reject) => {
213
- this.#callbackResolve = resolve;
214
- this.#callbackReject = reject;
249
+ const callback = Promise.withResolvers<CallbackResult>();
250
+ this.#callbackResolve = callback.resolve;
251
+ this.#callbackReject = callback.reject;
215
252
 
216
- signal.addEventListener("abort", () => {
217
- this.#callbackResolve = undefined;
218
- this.#callbackReject = undefined;
219
- reject(new AIError.LoginCancelledError(`OAuth callback cancelled: ${signal.reason}`));
220
- });
253
+ signal.addEventListener("abort", () => {
254
+ this.#callbackResolve = undefined;
255
+ this.#callbackReject = undefined;
256
+ callback.reject(new AIError.LoginCancelledError(`OAuth callback cancelled: ${signal.reason}`));
221
257
  });
258
+ const callbackPromise = callback.promise;
222
259
 
223
260
  // Manual input race (if supported)
224
261
  if (this.ctrl.onManualCodeInput) {