@monotykamary/pi-retry 0.6.10 → 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.10",
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",
package/retry.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  hasRetryableError,
10
10
  isNonRetryableError,
11
11
  isSilencedError,
12
+ hasQuotaExhaustedError,
12
13
  hasMaxTokensStop,
13
14
  isContextOverflowError,
14
15
  isAssistantMessage,
@@ -30,8 +31,9 @@ const RETRY_CANCELLED_EVENT = "pi-retry:cancelled";
30
31
  * Unified retry extension — retries EVERY error by default.
31
32
  *
32
33
  * Philosophy: any assistant message with stopReason === "error" is retried
33
- * indefinitely with exponential backoff, except a tiny blacklist of known
34
- * 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.).
35
37
  *
36
38
  * Specific categories (400/413, credit, connection, stream exhaustion, etc.)
37
39
  * are tracked for diagnostics but all share the same retry mechanism.
@@ -326,7 +328,12 @@ export default function (pi: ExtensionAPI) {
326
328
  // (silenced errors are neither retried nor shown)
327
329
  if (isNonRetryableError(lastAssistant) && !isSilencedError(lastAssistant)) {
328
330
  const errorMsg = lastAssistant.errorMessage || "Unknown error";
329
- 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
+ );
330
337
  }
331
338
  });
332
339
 
@@ -439,6 +446,19 @@ export default function (pi: ExtensionAPI) {
439
446
  return;
440
447
  }
441
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
+
442
462
  // Auto-detect error type and trigger appropriate retry
443
463
  if (has400or413Error(lastAssistant)) {
444
464
  ctx.ui.notify("Manually retrying 400/413 error...", "info");
@@ -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';