@oh-my-pi/pi-ai 18.4.9 → 18.4.11

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,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.4.11] - 2026-10-02
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Cursor cached prompt token accounting to prevent duplicate input-token and cost reporting on cached turns.
10
+ - Fixed auth-broker credential handling so late token-refresh responses cannot restore logged-out credentials or overwrite a newer login.
11
+
12
+ ## [18.4.10] - 2026-10-02
13
+
14
+ ### Fixed
15
+
16
+ - Factory Droid login now reports the account's organization error instead of accepting a credential that every request rejects ([#14032](https://github.com/can1357/oh-my-pi/issues/14032)).
17
+ - Fixed Cursor turns being aborted with "Provider stream stalled while waiting for the next event" right after a long local tool finished; the provider now gets a full idle window once local tool work completes ([#13682](https://github.com/can1357/oh-my-pi/pull/13682) by [@eggpeat](https://github.com/eggpeat))
18
+ - Tool calls whose final argument JSON is cut off or followed by trailing text are no longer executed from an auto-closed preview. OpenAI Completions, Anthropic, Bedrock, Responses, Codex, Devin, Ollama, Apple, Cursor, GitLab Duo, and the in-band JSON dialects now give such a call the existing parse-error arguments, so the tool is not run and the model receives the parse error and can resend the call ([#13868](https://github.com/can1357/oh-my-pi/pull/13868) by [@alphastorm](https://github.com/alphastorm))
19
+ - Fixed Cursor "prepaid balance is used up" (`USAGE_PRICING_REQUIRED`) failures repeating on the same account instead of rotating to a sibling Cursor credential ([#14053](https://github.com/can1357/oh-my-pi/issues/14053))
20
+ - Sessions no longer get stuck on `400 string_above_max_length` after a model writes its whole tool invocation into the tool name. Tool calls with blank names, names longer than 128 characters, or names containing whitespace or control characters are dropped from replayed history, together with their tool results. This also applies when OpenAI Responses replays its stored native history ([#13985](https://github.com/can1357/oh-my-pi/pull/13985) by [@Xytronix](https://github.com/Xytronix)).
21
+ - Fixed Bedrock Converse requests failing with a "bound to a different conversation" 400 after the system prompt changed under signed thinking: the request is retried once without replayed reasoning ([#14019](https://github.com/can1357/oh-my-pi/pull/14019) by [@nick-maderight](https://github.com/nick-maderight))
22
+
5
23
  ## [18.4.9] - 2026-10-01
6
24
 
7
25
  ### Fixed
@@ -1,4 +1,11 @@
1
1
  import type { Api, AssistantMessage, Message, Model } from "../types.js";
2
+ /**
3
+ * Whether a tool-call name cannot belong to any declared tool: missing, empty,
4
+ * longer than OpenAI's 128-character replay limit, or containing whitespace or
5
+ * control characters (tool schemas never allow them; their presence means the
6
+ * name slot carries invocation text).
7
+ */
8
+ export declare function isMalformedToolCallName(name: unknown): boolean;
2
9
  /**
3
10
  * Normalize tool call ID for cross-provider compatibility.
4
11
  * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
@@ -2,6 +2,12 @@ import type { AssistantMessage, AssistantMessageEvent } from "../types.js";
2
2
  /** Anything a stream watchdog can consult for in-flight consumer-side local work. */
3
3
  export interface LocalWorkSource {
4
4
  readonly hasPendingLocalWork: boolean;
5
+ /**
6
+ * Epoch ms at which tracked local work last drained to zero, or 0 if none
7
+ * has completed. The provider cannot answer until it receives the local
8
+ * result, so idle watchdogs measure provider silence from this instant.
9
+ */
10
+ readonly localWorkSettledAt: number;
5
11
  }
6
12
  export declare class EventStream<T, R = T> implements AsyncIterable<T> {
7
13
  #private;
@@ -35,6 +41,8 @@ export declare class EventStream<T, R = T> implements AsyncIterable<T> {
35
41
  result(): Promise<R>;
36
42
  /** True while local work tracked via {@link trackLocalWork} — on this stream or a forwarded delegate — is pending. */
37
43
  get hasPendingLocalWork(): boolean;
44
+ /** Latest {@link LocalWorkSource.localWorkSettledAt} across this stream and a forwarded delegate. */
45
+ get localWorkSettledAt(): number;
38
46
  /**
39
47
  * Count `source`'s pending local work as this stream's own. Used when this
40
48
  * stream forwards another's events (Cursor discovered-id retry) so the
@@ -1,3 +1,4 @@
1
+ import type { LocalWorkSource } from "./event-stream.js";
1
2
  /**
2
3
  * Returns the idle timeout used for provider streaming transports.
3
4
  *
@@ -86,15 +87,17 @@ export interface IdleTimeoutIteratorOptions {
86
87
  */
87
88
  isProgressItem?: (item: unknown) => boolean;
88
89
  /**
89
- * Reports consumer-side local work in flight for the stream: the provider
90
- * transport is waiting on a server-requested local tool bridge (e.g. the
91
- * Cursor exec channel) before anything can flow upstream again. While it
92
- * returns true, an expired idle / first-item deadline slides forward
93
- * instead of aborting — the silence is ours, not a provider stall. The
94
- * watchdog re-arms with a full budget once the local work completes, so a
95
- * provider that stalls afterwards is still caught.
90
+ * Consumer-side local work in flight for the stream: the provider transport
91
+ * is waiting on a server-requested local tool bridge (e.g. the Cursor exec
92
+ * channel) before anything can flow upstream again. While work is pending,
93
+ * an expired idle / first-item deadline slides forward instead of aborting —
94
+ * the silence is ours, not a provider stall. The provider cannot respond
95
+ * before it receives the local result, so both deadlines are measured from
96
+ * no earlier than `localWorkSettledAt`: a tool that finishes just before the
97
+ * deadline gets a full budget, and a provider that stalls afterwards is
98
+ * still caught.
96
99
  */
97
- hasPendingLocalWork?: () => boolean;
100
+ localWork?: LocalWorkSource;
98
101
  /**
99
102
  * Cancel iteration as soon as this signal aborts. Required for caller-driven
100
103
  * cancellation (ESC) when the underlying transport does not surface signal
@@ -0,0 +1,9 @@
1
+ import type { ToolCall } from "../types.js";
2
+ /** Final arguments must not reuse the streaming parser's auto-closed preview. */
3
+ export declare function parseToolCallArguments(json: string | undefined): ToolCall["arguments"];
4
+ /** Longest raw argument text kept in the parse-error diagnostic. */
5
+ export declare const INVALID_ARGUMENTS_RAW_LIMIT = 512;
6
+ /** Bound raw argument text for a diagnostic. Text this function already bounded is returned unchanged. */
7
+ export declare function boundRawToolArguments(raw: string): string;
8
+ /** Preserve a bounded diagnostic, never any partially recovered executable keys. */
9
+ export declare function invalidToolCallArguments(raw: string, error: unknown): ToolCall["arguments"];
@@ -63,6 +63,22 @@ export declare function sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(it
63
63
  * Drop hidden-only fallback assistant replay after a native Responses snapshot is rejected.
64
64
  */
65
65
  export declare function sanitizeOpenAIResponsesAssistantFallbackItemsForReplay(items: ResponseInput): ResponseInput;
66
+ /**
67
+ * Drop each native tool call with a malformed name and only the matching
68
+ * `function_call_output` / `custom_tool_call_output` in the same window.
69
+ *
70
+ * `call_id` is not unique across a snapshot. Pair the same way
71
+ * `sanitizeMalformedToolCalls` does for transcript messages: a per-id FIFO of
72
+ * malformed-ness, keyed by function vs custom, cleared at user/developer and
73
+ * other non-result client boundaries so a missing result cannot eat the next
74
+ * window's output. Returns `items` itself when nothing is malformed.
75
+ */
76
+ export declare function dropMalformedOpenAIResponsesToolCalls<T extends {
77
+ type?: unknown;
78
+ call_id?: unknown;
79
+ name?: unknown;
80
+ role?: unknown;
81
+ }>(items: readonly T[]): T[];
66
82
  export declare function createOpenAIResponsesHistoryPayload(provider: string, items: Array<Record<string, unknown>>, incremental?: boolean): OpenAIResponsesHistoryPayload;
67
83
  export declare function getOpenAIResponsesHistoryPayload(providerPayload: ProviderPayload | undefined, currentProvider: string, fallbackProvider?: string): OpenAIResponsesHistoryPayload | undefined;
68
84
  export declare function getOpenAIResponsesHistoryItems(providerPayload: ProviderPayload | undefined, currentProvider: string, fallbackProvider?: string): Array<Record<string, unknown>> | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.4.9",
3
+ "version": "18.4.11",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.4.9",
159
- "@oh-my-pi/pi-catalog": "18.4.9",
160
- "@oh-my-pi/pi-natives": "18.4.9",
161
- "@oh-my-pi/pi-utils": "18.4.9",
162
- "@oh-my-pi/pi-wire": "18.4.9"
158
+ "@oh-my-pi/omptype": "18.4.11",
159
+ "@oh-my-pi/pi-catalog": "18.4.11",
160
+ "@oh-my-pi/pi-natives": "18.4.11",
161
+ "@oh-my-pi/pi-utils": "18.4.11",
162
+ "@oh-my-pi/pi-wire": "18.4.11"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -17,6 +17,7 @@ import {
17
17
  type OAuthCredential,
18
18
  type OAuthRefreshReason,
19
19
  REMOTE_REFRESH_SENTINEL,
20
+ type RemoteOAuthCredential,
20
21
  type StoredAuthCredential,
21
22
  type StoredCredentialBlock,
22
23
  } from "../auth/types";
@@ -849,15 +850,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
849
850
 
850
851
  async markCredentialSuspect(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<void> {
851
852
  this.#noteActivity();
852
- const { entry } = await this.#client.refreshCredential(credentialId, opts.signal, "auth-recovery");
853
- if (entry.credential.type !== "oauth") {
854
- throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
855
- }
856
- if (!this.#applyCredentialEntry(entry)) {
857
- throw new AIError.AuthBrokerError(
858
- `Broker refreshed credential id=${credentialId} outside the configured account pool`,
859
- );
860
- }
853
+ await this.#refreshThroughBroker(credentialId, opts.signal, "auth-recovery");
861
854
  this.#maybeRefreshSnapshot("suspect credential refresh");
862
855
  }
863
856
 
@@ -1168,21 +1161,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1168
1161
  reason?: OAuthRefreshReason,
1169
1162
  ): Promise<OAuthCredentials> {
1170
1163
  this.#noteActivity();
1171
- const { entry } = await this.#client.refreshCredential(credentialId, signal, reason);
1172
- if (entry.credential.type !== "oauth") {
1173
- throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
1174
- }
1175
- if (!this.#applyCredentialEntry(entry)) {
1176
- throw new AIError.AuthBrokerError(
1177
- `Broker refreshed credential id=${credentialId} outside the configured account pool`,
1178
- );
1179
- }
1164
+ const refreshed = await this.#refreshThroughBroker(credentialId, signal, reason);
1180
1165
  if (!this.#streamingActive) {
1181
1166
  await this.refreshSnapshot().catch(error => {
1182
1167
  logger.debug("auth-broker snapshot refresh after credential refresh failed", { error: String(error) });
1183
1168
  });
1184
1169
  }
1185
- const refreshed = entry.credential;
1186
1170
  return {
1187
1171
  access: refreshed.access,
1188
1172
  refresh: REMOTE_REFRESH_SENTINEL,
@@ -1194,6 +1178,38 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1194
1178
  };
1195
1179
  }
1196
1180
 
1181
+ /**
1182
+ * Refresh one credential through the broker and apply the reply. If this
1183
+ * client's copy changed to something else while the request was in flight,
1184
+ * the reply may be stale, so the broker's current row wins: a logout stays
1185
+ * logged out and a newer login is kept.
1186
+ */
1187
+ async #refreshThroughBroker(
1188
+ credentialId: number,
1189
+ signal?: AbortSignal,
1190
+ reason?: OAuthRefreshReason,
1191
+ ): Promise<RemoteOAuthCredential> {
1192
+ const local = () => this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
1193
+ const before = JSON.stringify(local()?.credential);
1194
+ let { entry } = await this.#client.refreshCredential(credentialId, signal, reason);
1195
+ const current = JSON.stringify(local()?.credential);
1196
+ if (current !== before && current !== JSON.stringify(entry.credential)) {
1197
+ await this.refreshSnapshot();
1198
+ const latest = local();
1199
+ if (!latest) throw new AIError.AuthBrokerError(`Credential id=${credentialId} was removed during refresh`);
1200
+ entry = latest;
1201
+ }
1202
+ if (entry.credential.type !== "oauth") {
1203
+ throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
1204
+ }
1205
+ if (!this.#applyCredentialEntry(entry)) {
1206
+ throw new AIError.AuthBrokerError(
1207
+ `Broker refreshed credential id=${credentialId} outside the configured account pool`,
1208
+ );
1209
+ }
1210
+ return entry.credential;
1211
+ }
1212
+
1197
1213
  /**
1198
1214
  * Store-level hook consumed by `AuthStorage.usage.reports()` — proxies
1199
1215
  * to the broker's `/v1/usage` endpoint. Shared per-credential caches and
@@ -1,5 +1,6 @@
1
1
  import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
2
2
  import type { Message, ToolCall } from "../types";
3
+ import { parseToolCallArguments } from "../utils/tool-call-arguments";
3
4
  import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
4
5
  import dialectPrompt from "./deepseek.md" with { type: "text" };
5
6
  import { assistantTranscriptParts, collectToolResultRun, messageContentText, stringifyJson } from "./rendering";
@@ -477,11 +478,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
477
478
  #parseArgs(rawArgs: string): Record<string, unknown> {
478
479
  const trimmed = rawArgs.trim();
479
480
  if (trimmed.length === 0) return {};
480
- try {
481
- return asRecord(parseJsonWithRepair<unknown>(trimmed));
482
- } catch {
483
- return {};
484
- }
481
+ return asRecord(parseToolCallArguments(trimmed));
485
482
  }
486
483
 
487
484
  #skipWhitespace(): string {
@@ -1,5 +1,5 @@
1
- import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
2
1
  import type { Message, ToolCall } from "../types";
2
+ import { parseToolCallArguments } from "../utils/tool-call-arguments";
3
3
  import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
4
4
  import dialectPrompt from "./harmony.md" with { type: "text" };
5
5
  import {
@@ -225,11 +225,7 @@ export class HarmonyInbandScanner implements InbandScanner {
225
225
  #parseArgs(): Record<string, unknown> {
226
226
  const raw = this.#toolArgs.trim();
227
227
  if (raw.length === 0) return {};
228
- try {
229
- return asRecord(parseJsonWithRepair<unknown>(raw));
230
- } catch {
231
- return {};
232
- }
228
+ return asRecord(parseToolCallArguments(raw));
233
229
  }
234
230
 
235
231
  #clearBody(resetRawBlock = true): void {
@@ -1,5 +1,6 @@
1
1
  import { parseJsonWithRepair, parseStreamingJson } from "@oh-my-pi/pi-utils";
2
2
  import type { Message, ToolCall } from "../types";
3
+ import { parseToolCallArguments } from "../utils/tool-call-arguments";
3
4
  import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
4
5
  import dialectPrompt from "./hermes.md" with { type: "text" };
5
6
  import { renderChatMlTranscript, renderDelimitedThinking, renderToolResponseResults, stringifyJson } from "./rendering";
@@ -147,11 +148,7 @@ export class HermesInbandScanner implements InbandScanner {
147
148
  if (typeof parsed.name !== "string" || parsed.name.length === 0) return undefined;
148
149
  let args = parsed.arguments;
149
150
  if (typeof args === "string") {
150
- try {
151
- args = parseJsonWithRepair<unknown>(args);
152
- } catch {
153
- args = {};
154
- }
151
+ args = parseToolCallArguments(args);
155
152
  }
156
153
  return { name: parsed.name, arguments: asRecord(args) };
157
154
  } catch {
@@ -1,5 +1,5 @@
1
- import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
2
1
  import type { Message, ToolCall } from "../types";
2
+ import { parseToolCallArguments } from "../utils/tool-call-arguments";
3
3
  import { asRecord, normalizeKimiFunctionName, partialSuffixOverlapAny } from "./coercion";
4
4
  import dialectPrompt from "./kimi.md" with { type: "text" };
5
5
  import { assistantTranscriptParts, collectToolResultRun, messageContentText, stringifyJson } from "./rendering";
@@ -212,11 +212,7 @@ export class KimiInbandScanner implements InbandScanner {
212
212
 
213
213
  #parseArgs(rawArgs: string): Record<string, unknown> {
214
214
  if (rawArgs.length === 0) return {};
215
- try {
216
- return asRecord(parseJsonWithRepair<unknown>(rawArgs));
217
- } catch {
218
- return {};
219
- }
215
+ return asRecord(parseToolCallArguments(rawArgs));
220
216
  }
221
217
 
222
218
  #nextTokenIndex(): number {
@@ -13,6 +13,7 @@ import {
13
13
  setStreamingPartialJson,
14
14
  } from "../utils/block-symbols";
15
15
  import { AssistantMessageEventStream } from "../utils/event-stream";
16
+ import { INVALID_ARGUMENTS_RAW_LIMIT, invalidToolCallArguments } from "../utils/tool-call-arguments";
16
17
  import { buildStringArgsResolver } from "./coercion";
17
18
  import { createInbandScanner } from "./factory";
18
19
  import type { Dialect, InbandScanEvent, InbandScanner, InbandTool } from "./types";
@@ -180,6 +181,9 @@ class InbandStreamProjector {
180
181
  #fedLen = 0;
181
182
  #stopped = false;
182
183
  #responsePending = "";
184
+ // Bounded tail of the text actually fed to the scanner: the diagnostic for an
185
+ // unfinished envelope, which excludes anything cut off after a response token.
186
+ #scannerTail = "";
183
187
  // Provider-native tool calls forwarded live (e.g. Gemini still returns
184
188
  // `functionCall` parts under owned mode), keyed by the inner stream's
185
189
  // `contentIndex`. `#toolChannel` records which channel produced the turn's
@@ -291,7 +295,7 @@ class InbandStreamProjector {
291
295
  const responseIndex = firstTokenIndex(combined, this.#responseOpenTokens);
292
296
  if (responseIndex !== -1) {
293
297
  this.#responsePending = "";
294
- this.#apply(this.#scanner.feed(combined.slice(0, responseIndex)));
298
+ this.#apply(this.#feedScanner(combined.slice(0, responseIndex)));
295
299
  this.#stopped = true;
296
300
  return true;
297
301
  }
@@ -303,7 +307,7 @@ class InbandStreamProjector {
303
307
 
304
308
  const emitLength = combined.length - this.#responseOverlapLength;
305
309
  this.#responsePending = combined.slice(emitLength);
306
- this.#apply(this.#scanner.feed(combined.slice(0, emitLength)));
310
+ this.#apply(this.#feedScanner(combined.slice(0, emitLength)));
307
311
  return false;
308
312
  }
309
313
 
@@ -339,10 +343,26 @@ class InbandStreamProjector {
339
343
  for (const block of message.content) if (block.type === "text") fullText += block.text;
340
344
  if (!this.#stopped && fullText.length > this.#fedLen) this.text(fullText.slice(this.#fedLen));
341
345
  if (!this.#stopped && this.#responsePending.length > 0) {
342
- this.#apply(this.#scanner.feed(this.#responsePending));
346
+ this.#apply(this.#feedScanner(this.#responsePending));
343
347
  this.#responsePending = "";
344
348
  }
345
349
  this.#apply(this.#scanner.flush());
350
+ // Scanners discard incomplete envelopes, but their toolStart previews already
351
+ // live in the message. Refuse those calls instead of executing partial fields.
352
+ // The unfinished envelope ends the scanner-fed text, so the diagnostic keeps
353
+ // that tail rather than any prose before the call.
354
+ for (const entry of this.#toolBlocks.values()) {
355
+ entry.block.arguments = invalidToolCallArguments(this.#scannerTail, "Incomplete in-band tool call");
356
+ if (this.#emitEvents) {
357
+ this.#out.push({
358
+ type: "toolcall_end",
359
+ contentIndex: entry.index,
360
+ toolCall: entry.block,
361
+ partial: this.#partial,
362
+ });
363
+ }
364
+ }
365
+ this.#toolBlocks.clear();
346
366
  this.#closeText();
347
367
  this.#closeThinking();
348
368
  const hasTools = this.#partial.content.some(block => block.type === "toolCall");
@@ -353,6 +373,11 @@ class InbandStreamProjector {
353
373
  return finalMessage;
354
374
  }
355
375
 
376
+ #feedScanner(text: string): InbandScanEvent[] {
377
+ this.#scannerTail = (this.#scannerTail + text).slice(-INVALID_ARGUMENTS_RAW_LIMIT);
378
+ return this.#scanner.feed(text);
379
+ }
380
+
356
381
  #apply(events: InbandScanEvent[]): void {
357
382
  for (const event of events) {
358
383
  switch (event.type) {
@@ -1,5 +1,6 @@
1
1
  import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
2
2
  import type { Message, ToolCall } from "../types";
3
+ import { parseToolCallArguments } from "../utils/tool-call-arguments";
3
4
  import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
4
5
  import dialectPrompt from "./qwen3.md" with { type: "text" };
5
6
  import { renderChatMlTranscript, renderToolResponseResults, stringifyJson } from "./rendering";
@@ -180,11 +181,7 @@ export class Qwen3InbandScanner implements InbandScanner {
180
181
  if (typeof parsed.name !== "string" || parsed.name.length === 0) return undefined;
181
182
  let args = parsed.arguments;
182
183
  if (typeof args === "string") {
183
- try {
184
- args = parseJsonWithRepair<unknown>(args);
185
- } catch {
186
- args = {};
187
- }
184
+ args = parseToolCallArguments(args);
188
185
  }
189
186
  return { name: parsed.name, arguments: asRecord(args) };
190
187
  } catch {
@@ -37,6 +37,12 @@ const CREDITS_EXHAUSTED_PATTERN =
37
37
  // in unrelated diagnostics ("Failed to fetch usage credits from billing
38
38
  // service"), which must not rotate a healthy credential.
39
39
  const ANTHROPIC_CREDITS_REQUIRED_PATTERN = /\busage credits are required\b|\bcredits_required\b/i;
40
+ // Prepaid-balance exhaustion: Cursor ERROR_USAGE_PRICING_REQUIRED (code 44,
41
+ // surfaced as 429) "Your prepaid balance is used up: Add funds or enable auto
42
+ // top-up …". Account-local until topped up, so rotate to a sibling. The `\b`
43
+ // after the code keeps USAGE_PRICING_REQUIRED_CHANGEABLE out.
44
+ const PREPAID_BALANCE_EXHAUSTED_PATTERN =
45
+ /\busage_pricing_required\b|\bprepaid balance\b[^\n]{0,40}\b(?:used up|exhausted|depleted)\b/i;
40
46
  // Account billing ceilings: Anthropic "monthly spend limit" (#4787) and Google
41
47
  // "Your project has exceeded its monthly spending cap" (#13090). The `\b` after
42
48
  // `cap` keeps "spending capacity" — a throttle, not a billing ceiling — out.
@@ -260,6 +266,10 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
260
266
  return "QUOTA_EXHAUSTED";
261
267
  }
262
268
 
269
+ if (PREPAID_BALANCE_EXHAUSTED_PATTERN.test(errorMessage)) {
270
+ return "QUOTA_EXHAUSTED";
271
+ }
272
+
263
273
  if (
264
274
  lower.includes("per minute") ||
265
275
  lower.includes("rate limit") ||
@@ -434,6 +444,7 @@ export function matchesUsageLimitText(errorMessage: string): boolean {
434
444
  return (
435
445
  USAGE_LIMIT_PATTERN.test(errorMessage) ||
436
446
  ANTHROPIC_CREDITS_REQUIRED_PATTERN.test(errorMessage) ||
447
+ PREPAID_BALANCE_EXHAUSTED_PATTERN.test(errorMessage) ||
437
448
  CREDITS_EXHAUSTED_PATTERN.test(errorMessage) ||
438
449
  (CN_QUOTA_EXHAUSTED_PATTERN.test(errorMessage) && !CN_TRANSIENT_CAP_PATTERN.test(errorMessage)) ||
439
450
  SPEND_LIMIT_PATTERN.test(errorMessage) ||