@monotykamary/pi-retry 0.6.9 → 0.7.0

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/README.md CHANGED
@@ -23,7 +23,8 @@ This extension automatically detects and retries **all** errors by default, with
23
23
  |------------|----------------|----------|
24
24
  | **Any retryable error** (catch-all) | **Indefinite** with capped backoff | Everything else — provider hiccups, stream exhaustion, credit issues, unknown errors |
25
25
  | HTTP 400/413 | **Indefinite** with capped backoff, NO compaction | Transient context overflow that might resolve |
26
- | Credit / payment errors | **Indefinite** with capped backoff | "Not Enough Credits", insufficient balance, 402 |
26
+ | Credit / payment errors | **Indefinite** with capped backoff | "Not Enough Credits", insufficient balance, 402 — top up and the retry loop auto-resumes |
27
+ | **Quota / session-limit / budget exhaustion** | **Not retried** — notify + stop | "You've hit your limit", `insufficient_quota`, "out of budget", suspended accounts |
27
28
  | Connection errors | **Indefinite** with capped backoff | Network hiccups, connection drops, socket errors, stream exhaustion |
28
29
  | Max tokens (`stopReason: "length"`) | **Auto-continue** indefinitely with hidden continuation turns | Model hits output token limit mid-generation |
29
30
 
@@ -50,6 +51,7 @@ This extension provides **automatic** infinite retry with sensible exponential b
50
51
  - Automatic detection of 400/413, connection, credit, and stream exhaustion errors
51
52
  - **Auto-continuation** when the model hits its max output tokens (`stopReason: "length"`) — indefinite, no cap, hidden from the TUI
52
53
  - **Indefinite retry** — Keeps retrying until success
54
+ - **Auto-stop on quota/budget exhaustion** — Session limits, plan quotas, and budget caps ("You've hit your limit", "out of budget", `insufficient_quota`, suspended accounts) are detected and **not** retried, with a notification explaining why
53
55
  - Exponential backoff with cap: max 60s between retries
54
56
  - **Hidden triggers** — provider-valid custom messages use `display: false`, so retries do not add TUI clutter
55
57
  - Manual controls via unified `/retry` command
@@ -133,7 +135,7 @@ const BACKOFF_MULTIPLIER = 2; // Double each time
133
135
 
134
136
  1. **Listen to `agent_end` event** — Fires after each agent turn completes
135
137
  2. **Check for any error** — Examine the last assistant message for `stopReason === "error"`
136
- 3. **Blacklist check** — Skip known permanent failures (invalid API key, model not found, etc.)
138
+ 3. **Blacklist check** — Skip known permanent failures (invalid API key, model not found, quota/session-limit/budget exhaustion, suspended accounts, etc.)
137
139
  4. **Categorize for messaging** — Classify into 400/413, credit, connection, or other for nice UI notifications
138
140
  5. **Retry or continue with hidden turns** — Wait with exponential backoff, then trigger a provider-valid custom user turn via `pi.sendMessage()` with `display: false` and `triggerTurn: true`
139
141
  6. **Valid provider context** — Hidden retry and continuation messages remain in context so providers never receive a trailing assistant message
@@ -158,6 +160,16 @@ These are explicitly **not** retried:
158
160
  - Model not found / unknown model / no such model / model does not exist
159
161
  - Unsupported model
160
162
 
163
+ ### Non-Retryable (Quota / Session Limit / Budget)
164
+ Exhausted quotas, session limits, and budgets are auto-detected and stop the retry loop (with an explanatory notification), because retrying is pointless until you act or the reset window passes:
165
+ - **Usage / session limits with reset windows** — "You've hit your limit · resets …" and "5-hour limit reached" (Claude Code), "You've hit your usage limit" / "You've exceeded your usage limit" (Codex)
166
+ - **Plan / billing quotas** — OpenAI `insufficient_quota`, "You exceeded your current quota, please check your plan and billing details" (OpenAI, Gemini — reached only after pi's built-in 429 retry gives up)
167
+ - **Hard allotments** — OpenRouter `free-models-per-day`, Alibaba "Allocated quota exceeded" (`Throttling.AllocationQuota`), GitHub Copilot "premium request allowance"
168
+ - **Budget exhaustion** — "out of budget", "Budget has been exceeded" (LiteLLM-style proxies), max/spending/monthly limits
169
+ - **Suspended accounts** — "Your account … is suspended" (Kimi `exceeded_current_quota_error`)
170
+
171
+ Deliberate distinction: plain pay-as-you-go **balance** errors stay retryable — DeepSeek 402 "Insufficient Balance", OpenRouter 402 "Insufficient credits", Kimi "exceeded your current token quota". A mid-session top-up lets the retry loop auto-resume, whereas session limits and budgets do not self-resolve for hours.
172
+
161
173
  ### Max Tokens (stopReason: "length")
162
174
  - The model hit its `max_tokens` / output token limit
163
175
  - The model's response was truncated mid-generation
@@ -291,7 +303,7 @@ pi install npm:@georgebashi/pi-retry
291
303
  - Error messages remain in the session history (but are invisible to the LLM)
292
304
  - May hit the same error repeatedly if the issue is persistent (use `Ctrl+C` to abort)
293
305
  - **Warning**: Retrying 400/413 without reducing context may fail repeatedly if the payload is genuinely too large
294
- - Non-retryable errors (invalid API key, missing model) are logged but not retried — you'll need to fix the underlying issue
306
+ - Non-retryable errors (invalid API key, missing model, quota/session-limit/budget exhaustion) are logged but not retried — you'll need to fix the underlying issue, then use `/retry`
295
307
 
296
308
  ---
297
309
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-retry",
3
- "version": "0.6.9",
3
+ "version": "0.7.0",
4
4
  "description": "Extension suite for pi coding agent that handles 400/413 errors and connection errors with automatic retry",
5
5
  "type": "module",
6
6
  "author": "Tom X Nguyen",
@@ -32,9 +32,15 @@
32
32
  "src/",
33
33
  "README.md"
34
34
  ],
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-agent-core": "*",
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "@earendil-works/pi-tui": "*"
39
+ },
35
40
  "devDependencies": {
36
41
  "@earendil-works/pi-agent-core": "^0.84.0",
37
42
  "@earendil-works/pi-coding-agent": "^0.84.0",
43
+ "@earendil-works/pi-tui": "^0.84.0",
38
44
  "@types/node": "25.9.1",
39
45
  "@vitest/coverage-v8": "4.1.7",
40
46
  "knip": "6.14.1",
package/retry.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey } from "@earendil-works/pi-tui";
2
3
  import { Agent } from "@earendil-works/pi-agent-core";
3
4
  import { AgentSession } from "@earendil-works/pi-coding-agent";
4
5
  import {
@@ -8,6 +9,7 @@ import {
8
9
  hasRetryableError,
9
10
  isNonRetryableError,
10
11
  isSilencedError,
12
+ hasQuotaExhaustedError,
11
13
  hasMaxTokensStop,
12
14
  isContextOverflowError,
13
15
  isAssistantMessage,
@@ -29,8 +31,9 @@ const RETRY_CANCELLED_EVENT = "pi-retry:cancelled";
29
31
  * Unified retry extension — retries EVERY error by default.
30
32
  *
31
33
  * Philosophy: any assistant message with stopReason === "error" is retried
32
- * indefinitely with exponential backoff, except a tiny blacklist of known
33
- * permanent failures (invalid API key, model not found, etc.).
34
+ * indefinitely with exponential backoff, except a small blacklist of known
35
+ * permanent failures and hard-stop conditions (invalid API key, model not
36
+ * found, quota/session-limit/budget exhaustion, suspended accounts, etc.).
34
37
  *
35
38
  * Specific categories (400/413, credit, connection, stream exhaustion, etc.)
36
39
  * are tracked for diagnostics but all share the same retry mechanism.
@@ -102,9 +105,10 @@ const stateOther = new RetryState();
102
105
  // Max_tokens continuation state (indefinite — no cap needed)
103
106
  const stateContinuation = new ContinuationState();
104
107
 
105
- // Abort flag: set when turn_end reports stopReason "aborted", cleared on
106
- // session_start and on fresh user activity. Prevents triggerInvisibleContinue()
107
- // from starting a hidden retry turn after the user explicitly cancelled.
108
+ // Abort flag: set when Pi's active signal is aborted or turn_end reports
109
+ // stopReason "aborted", cleared on session_start and fresh user activity.
110
+ // Prevents triggerInvisibleContinue() from starting a hidden retry turn after
111
+ // the user explicitly cancelled, even if a tool/provider reports an error.
108
112
  let _userAborted = false;
109
113
 
110
114
  // Mutex: only one triggerInvisibleContinue may be in-flight at a time.
@@ -186,25 +190,32 @@ export default function (pi: ExtensionAPI) {
186
190
 
187
191
  pi.on("input", () => {
188
192
  _inputGeneration++;
193
+ // The generation change cancels any older loop; the new user request gets
194
+ // its own retry eligibility even if the previous request was aborted.
195
+ _userAborted = false;
189
196
  });
190
197
 
191
198
  // Reset retry counters on successful completion (not max_tokens, not error)
192
199
  pi.on("turn_end", async (event, ctx) => {
193
200
  const msg = event.message as any;
201
+ if (
202
+ ctx.signal?.aborted ||
203
+ (msg.role === "assistant" && msg.stopReason === "aborted")
204
+ ) {
205
+ // User cancelled — reset retry state so it doesn't leak into other
206
+ // branches of the session tree. The signal check matters for tool calls:
207
+ // some tools/providers finish with an error-shaped result after abort.
208
+ state400.reset();
209
+ stateCredit.reset();
210
+ stateConnection.reset();
211
+ stateOther.reset();
212
+ stateContinuation.endContinuation();
213
+ // Signal to any in-flight triggerInvisibleContinue or pending retry
214
+ // that the user has cancelled — do not queue another retry turn.
215
+ _userAborted = true;
216
+ return;
217
+ }
194
218
  if (msg.role === "assistant" && msg.stopReason !== "error") {
195
- if (msg.stopReason === "aborted") {
196
- // User cancelled — reset retry state so it doesn't leak into other
197
- // branches of the session tree.
198
- state400.reset();
199
- stateCredit.reset();
200
- stateConnection.reset();
201
- stateOther.reset();
202
- stateContinuation.endContinuation();
203
- // Signal to any in-flight triggerInvisibleContinue or pending retry
204
- // that the user has cancelled — do not queue another retry turn.
205
- _userAborted = true;
206
- return;
207
- }
208
219
  if (msg.stopReason !== "length") {
209
220
  // Normal completion — reset everything including continuation count
210
221
  state400.succeed();
@@ -230,6 +241,14 @@ export default function (pi: ExtensionAPI) {
230
241
  // triggerInvisibleContinue(), which owns the retry loop with backoff
231
242
  // sleeps that happen AFTER processEvents returns (outside the agent run).
232
243
  pi.on("agent_end", async (event, ctx) => {
244
+ // Prefer Pi's run signal over provider-specific stop-reason mapping. An
245
+ // Escape during a Fabric/core tool may settle as an error-shaped result,
246
+ // but it is still a user cancellation and must never schedule a retry.
247
+ if (ctx.signal?.aborted) {
248
+ _userAborted = true;
249
+ return;
250
+ }
251
+
233
252
  const entries = ctx.sessionManager.getEntries();
234
253
  const lastAssistant = getLastAssistantMessage(entries);
235
254
 
@@ -309,7 +328,12 @@ export default function (pi: ExtensionAPI) {
309
328
  // (silenced errors are neither retried nor shown)
310
329
  if (isNonRetryableError(lastAssistant) && !isSilencedError(lastAssistant)) {
311
330
  const errorMsg = lastAssistant.errorMessage || "Unknown error";
312
- ctx.ui.notify(`Non-retryable error (not retried): ${errorMsg.substring(0, 100)}`, "error");
331
+ ctx.ui.notify(
332
+ hasQuotaExhaustedError(lastAssistant)
333
+ ? `Quota/limit exhausted — not retrying (fix plan/billing or wait for the reset window, then /retry): ${errorMsg.substring(0, 100)}`
334
+ : `Non-retryable error (not retried): ${errorMsg.substring(0, 100)}`,
335
+ "error",
336
+ );
313
337
  }
314
338
  });
315
339
 
@@ -422,6 +446,19 @@ export default function (pi: ExtensionAPI) {
422
446
  return;
423
447
  }
424
448
 
449
+ // Non-retryable errors (permanent failures + quota/budget
450
+ // exhaustion): report clearly instead of the generic fallback.
451
+ if (isNonRetryableError(lastAssistant)) {
452
+ const errorMsg = lastAssistant.errorMessage || "Unknown error";
453
+ ctx.ui.notify(
454
+ hasQuotaExhaustedError(lastAssistant)
455
+ ? `Quota/limit exhausted — resolve the plan/billing issue or wait for the reset window first: ${errorMsg.substring(0, 100)}`
456
+ : `Non-retryable error (fix the underlying issue first, then /retry): ${errorMsg.substring(0, 100)}`,
457
+ "warning",
458
+ );
459
+ return;
460
+ }
461
+
425
462
  // Auto-detect error type and trigger appropriate retry
426
463
  if (has400or413Error(lastAssistant)) {
427
464
  ctx.ui.notify("Manually retrying 400/413 error...", "info");
@@ -480,16 +517,20 @@ export default function (pi: ExtensionAPI) {
480
517
  if (ctx.mode === "tui") {
481
518
  _terminalInputUnsubscribe = ctx.ui.onTerminalInput(data => {
482
519
  if (
483
- data !== "\x1b" ||
520
+ !matchesKey(data, "escape") ||
484
521
  !_continueInProgress ||
485
522
  _continueGeneration !== _sessionGeneration
486
523
  ) {
487
524
  return undefined;
488
525
  }
489
526
 
527
+ // Cancel pi-retry's out-of-band loop, but let Pi's native interrupt
528
+ // handler receive the same key. Pi owns the active turn and queue: its
529
+ // handler aborts the current tool/model call and clears any queued
530
+ // steer/follow-up messages. Consuming Escape here bypasses that cleanup
531
+ // and can let AgentSession continue after the user asked it to stop.
490
532
  _userAborted = true;
491
- ctx.abort();
492
- return { consume: true };
533
+ return undefined;
493
534
  });
494
535
  }
495
536
  });
@@ -131,6 +131,56 @@ const SILENCED_PATTERNS = [
131
131
  /cannot continue from message role/i,
132
132
  ];
133
133
 
134
+ // Quota / session-limit / budget exhaustion — hard stops:
135
+ // retrying is pointless until the user upgrades, tops up a budget, or waits
136
+ // out a reset window measured in hours/days. Distinct from per-minute rate
137
+ // limits and pay-as-you-go balance errors, which stay retryable.
138
+ //
139
+ // Evidence (real provider messages):
140
+ // - Claude Code: "You've hit your limit · resets 4pm (Asia/Kuala_Lumpur)"
141
+ // "Claude usage limit reached. Your limit will reset at 3pm"
142
+ // "5-hour limit reached · resets 12pm"
143
+ // - Codex: "You've hit your usage limit. Upgrade to Plus"
144
+ // "You've exceeded your usage limit."
145
+ // - OpenAI: code "insufficient_quota" — "You exceeded your current
146
+ // quota, please check your plan and billing details"
147
+ // - Gemini: same sentence in 429 RESOURCE_EXHAUSTED responses; only
148
+ // reaches us after pi's built-in 429 retry gives up
149
+ // - OpenRouter: "Rate limit exceeded: free-models-per-day. ..."
150
+ // - Alibaba: "Allocated quota exceeded, please increase your quota limit"
151
+ // (Throttling.AllocationQuota — hard cap; RateQuota is
152
+ // transient and stays retryable)
153
+ // - Copilot: "You have exceeded your premium request allowance"
154
+ // - LiteLLM: "Budget has been exceeded! Current cost: …, Max budget: …"
155
+ // - Kimi: "Your account {org}<{ak}> is suspended, please check your
156
+ // plan and billing details" (exceeded_current_quota_error)
157
+ export const QUOTA_EXHAUSTED_PATTERNS = [
158
+ // Session / usage limits with reset windows (Claude, Codex)
159
+ /hit your (usage )?limit/i,
160
+ /usage\s*limit\s*(has\s*been\s*)?reached/i,
161
+ /hour\s*limit\s*reached/i, // "5-hour limit reached" — must NOT hit DeepSeek 429 "Rate Limit Reached"
162
+ /limit\s*will\s*reset\s*at/i,
163
+ /session\s*(limit|quota)/i,
164
+ /exceeded your usage limit/i,
165
+ // Billing / plan quotas (OpenAI insufficient_quota, Gemini RESOURCE_EXHAUSTED)
166
+ /insufficient[_\s]quota/i,
167
+ /exceeded your current quota/i, // Kimi's "...current token quota" (balance, retryable) intentionally not matched
168
+ // Hard allotments
169
+ /free.models.per.day/i, // OpenRouter free-tier daily pool
170
+ /allocated\s*quota/i, // Alibaba Throttling.AllocationQuota
171
+ /premium\s*request\s*allowance/i, // GitHub Copilot monthly allowance
172
+ /monthly\s*(limit|quota|budget|allowance)/i,
173
+ // Budget exhaustion (LiteLLM and similar proxies/gateways)
174
+ /out of budget/i,
175
+ /budget\s*(has\s*been\s*)?(exceeded|exhausted|limit)/i,
176
+ /max(imum)?\s*budget\s*(exceeded|reached|limit)/i,
177
+ /spending\s*limit/i,
178
+ // Suspended accounts (Kimi exceeded_current_quota_error suspended form)
179
+ /account\b[^.]*\bis\s*suspended/i,
180
+ // Generic
181
+ /quota\s*(exhausted|depleted)/i,
182
+ ];
183
+
134
184
  // ── Type guard ──
135
185
 
136
186
  export function isAssistantMessage(message: AgentMessage): message is Extract<AgentMessage, { role: "assistant" }> {
@@ -184,7 +234,7 @@ export function isContextOverflowError(message: AgentMessage): boolean {
184
234
  export function hasRetryableError(message: AgentMessage): boolean {
185
235
  if (!isAssistantMessage(message)) return false;
186
236
  if (message.stopReason !== "error" || !message.errorMessage) return false;
187
- return !NON_RETRYABLE_PATTERNS.some(p => p.test(message.errorMessage!));
237
+ return !isNonRetryableError(message);
188
238
  }
189
239
 
190
240
  /**
@@ -193,7 +243,26 @@ export function hasRetryableError(message: AgentMessage): boolean {
193
243
  export function isNonRetryableError(message: AgentMessage): boolean {
194
244
  if (!isAssistantMessage(message)) return false;
195
245
  if (message.stopReason !== "error" || !message.errorMessage) return false;
196
- return NON_RETRYABLE_PATTERNS.some(p => p.test(message.errorMessage!));
246
+ return (
247
+ NON_RETRYABLE_PATTERNS.some(p => p.test(message.errorMessage!)) ||
248
+ QUOTA_EXHAUSTED_PATTERNS.some(p => p.test(message.errorMessage!))
249
+ );
250
+ }
251
+
252
+ /**
253
+ * Returns true for quota / session-limit / budget exhaustion errors where
254
+ * retrying is pointless until the user acts (upgrade, top up a budget) or a
255
+ * long reset window passes (hours/days). Treated as non-retryable.
256
+ *
257
+ * Deliberately NOT matched: per-minute rate limits (429s) and pay-as-you-go
258
+ * balance errors (DeepSeek "Insufficient Balance", OpenRouter "Insufficient
259
+ * credits", Kimi "exceeded your current token quota") — those stay retryable
260
+ * so a mid-session top-up auto-resumes.
261
+ */
262
+ export function hasQuotaExhaustedError(message: AgentMessage): boolean {
263
+ if (!isAssistantMessage(message)) return false;
264
+ if (message.stopReason !== "error" || !message.errorMessage) return false;
265
+ return QUOTA_EXHAUSTED_PATTERNS.some(p => p.test(message.errorMessage!));
197
266
  }
198
267
 
199
268
  /**
@@ -209,7 +278,8 @@ export function isSilencedError(message: AgentMessage): boolean {
209
278
 
210
279
  // ── Categorisation (for UI messages) ──
211
280
 
212
- export function getErrorCategory(errorMessage: string): '400-413' | 'credit' | 'connection' | 'builtin' | 'other' {
281
+ export function getErrorCategory(errorMessage: string): '400-413' | 'credit' | 'connection' | 'builtin' | 'quota' | 'other' {
282
+ if (QUOTA_EXHAUSTED_PATTERNS.some(p => p.test(errorMessage))) return 'quota';
213
283
  if (ERROR_400_413_PATTERNS.some(p => p.test(errorMessage))) return '400-413';
214
284
  if (CREDIT_ERROR_PATTERNS.some(p => p.test(errorMessage))) return 'credit';
215
285
  if (CONNECTION_ERROR_PATTERNS.some(p => p.test(errorMessage))) return 'connection';