oira666_pi-limits-wait 0.6.10 → 0.6.13

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
@@ -12,7 +12,7 @@ pi install npm:oira666_pi-limits-wait
12
12
 
13
13
  Restart Pi or run `/reload` after installing.
14
14
 
15
- Tested against Pi 1.4.0.
15
+ Tested against Pi 1.5.0.
16
16
 
17
17
  ## What it does
18
18
 
@@ -20,13 +20,13 @@ Tested against Pi 1.4.0.
20
20
  - Intercepts the current runtime without registering synthetic providers; every retry and fallback re-enters Pi's normal provider/auth/base URL/configured-header composition, including OAuth refresh checks.
21
21
  - Stops background cache warming via `cache_warming_decision` while retry interception is enabled, preventing background refreshes from triggering foreground retries or fallbacks. Normal request caching is unchanged. With `PI_LIMITS_WAIT_DISABLE_ALL_WAITING=true`, Pi's cache-warming policy is left untouched. As with any handler for this event, a later extension can override the decision.
22
22
  - Reload-safe ownership ensures `/reload` transfers interception cleanly and aborts waits still owned by the previous extension instance. The interception has one process-global active extension owner, matching the interactive Pi CLI's single active session runtime lifecycle; SDK processes running multiple concurrent Pi sessions are not supported.
23
- - On rate-limit errors (`429`, `rate_limit`, `too many requests`, quota/reset messages), waits and retries in a loop.
23
+ - On rate-limit errors (`429`, `rate_limit`, `too many requests`, quota/reset messages), waits and retries in a loop. With `PI_LIMITS_WAIT_DEFAULT_WAITING=false` it never waits: it only switches between fallback models (see [Environment variables](#environment-variables)).
24
24
  - Remembers provider retry timing exposed through response callbacks or provider errors (`retry-after`, `retry-after-ms`, `retry in ...`, reset messages). Explicit provider retry/reset deadlines are always honored: no early probes occur during that period (unless a wait is manually skipped).
25
25
  - If no retry timing is available for a rate limit, waits 30 minutes before retrying.
26
26
  - On `server_is_overloaded`, waits 5 minutes, then retries. If the provider is still overloaded after Pi's normal retries, it waits another 5 minutes and repeats.
27
27
  - On transient network/transport failures — including undici idle-timeout aborts (`UND_ERR_HEADERS_TIMEOUT` / `UND_ERR_BODY_TIMEOUT`), `fetch failed`, `terminated`, `ECONNRESET`, `ETIMEDOUT`, etc. — treats the error as retryable with a short backoff (default 15s, or the provider's `retry-after`) instead of giving up. This prevents a stalled streaming request from turning into a silent hang.
28
28
  - Leaves context-overflow recovery entirely to Pi. The extension uses Pi's own `isContextOverflow()` detector and immediately surfaces matching failures without waiting, retrying, or selecting another fallback. If the overflow came from a fallback already attempted for the request, the extension makes that attempted model current when Pi is still on the request's original model and accepts the switch; it never overrides a newer user model selection. Pi intentionally ignores overflow errors from non-current models. Successful over-window responses and zero-output length stops also pass through unchanged, allowing Pi to compact and perform its built-in recovery.
29
- - For any other (unclassified) provider error — including an API `Internal server error` and HTTP `400`/`403` failures — waits and retries the same model. There is deliberately no permanent-HTTP-status allowlist. This retry-all behavior is enabled by default and uses a configurable retry limit and interval; it does not alter model-limit wait intervals. ChatGPT Business workspace-admin usage limits use this bounded policy, even when accompanied by HTTP `429` or `Retry-After`, rather than an endless timed-limit loop. Disable `PI_LIMITS_WAIT_DEFAULT_WAITING` to surface them immediately, matching Pi's non-retryable classification; the provider's admin guidance is preserved.
29
+ - For any other (unclassified) provider error — including an API `Internal server error` and HTTP `400`/`403` failures — waits and retries the same model. There is deliberately no permanent-HTTP-status allowlist. This retry-all behavior is enabled by default and uses a configurable retry limit and interval; it does not alter model-limit wait intervals. ChatGPT Business workspace-admin usage limits and Zhipu/Z.ai plan-exhaustion code `1308` use this bounded policy, even when accompanied by HTTP `429` or `Retry-After`, rather than an endless timed-limit loop. Disable `PI_LIMITS_WAIT_DEFAULT_WAITING` to surface them immediately (after trying any fallback models), matching Pi's non-retryable classification; the provider's error guidance is preserved.
30
30
  - Reports retry/fallback waits in chat notifications without adding persistent TUI status lines.
31
31
  - In non-interactive hosts (`rpc` and `json` modes) the extension never reports a planned retry as an error. While it is waiting to retry, it publishes `info`-level liveliness notifications (plus a `setStatus` entry) describing what it is doing, how long it will wait, and why. An `error`-level notification is emitted only when the extension stops retrying and surfaces the terminal failure. See [Non-interactive (RPC) reporting](#non-interactive-rpc-reporting).
32
32
  - During a wait, pressing Enter in an empty editor retries immediately. Typed commands, autocomplete, and model-picker navigation still work normally. Choosing another model with `/model` immediately ends the pending wait and tries that exact model, even if it was previously marked unavailable.
@@ -238,7 +238,7 @@ Example:
238
238
  }
239
239
  ```
240
240
 
241
- `fallback-models` is an ordered priority list: the entry closest to the top is preferred whenever it is usable.
241
+ The model the chat starts with is always tried first. `fallback-models` is an ordered list of alternatives used only when that model is unavailable; listing the startup model lower does not demote it or try it twice.
242
242
 
243
243
  Each `fallback-models` entry supports:
244
244
 
@@ -252,7 +252,7 @@ Top-level settings keys:
252
252
 
253
253
  Fallback priority and manual selection:
254
254
 
255
- 1. Normally, `fallback-models` sets priority from top to bottom. An unlisted startup model is tried before the list. Unrelated model calls and internal synthetic models are not rerouted.
255
+ 1. The startup model comes first, followed by `fallback-models` from top to bottom with duplicates removed. A healthy startup model is never replaced just because another model appears earlier in the configured list. Unrelated model calls and internal synthetic models are not rerouted.
256
256
  2. A manual model change overrides that priority, whether the chosen model is listed or not. Calls stay on that model without probing higher-priority models while it works. If it fails, the override ends and normal retry/fallback rules apply. After failover, the configured list controls priority again; recovery of the manually chosen model does not restore its override. Abort and context-overflow recovery do not cancel the override. Without an override, each LLM call starts at the top of the order and uses the highest-priority available model.
257
257
  3. If the active model becomes rate-limited, the extension immediately moves down to the next usable model in the order.
258
258
  4. As soon as a higher-priority model works again, the next call goes back to it and Pi's active model is switched back automatically. That happens in three ways:
@@ -272,7 +272,7 @@ The extension rereads these files before every LLM call, so changes made during
272
272
  | Variable | Default | Description |
273
273
  |----------|---------|-------------|
274
274
  | `PI_LIMITS_WAIT_DISABLE_ALL_WAITING` | `false` | Master kill switch for this extension's error handling, retries, fallbacks, freezing, waiting, notifications, telemetry, and retry summaries. Set to `true` (also accepts `1`, `yes`, `on`) to leave only Anthropic OAuth system-prompt cleaning enabled; requests continue through Pi's normal runtime, including its normal Anthropic headers. This takes precedence over every setting below. |
275
- | `PI_LIMITS_WAIT_DEFAULT_WAITING` | `true` | Whether unclassified/unknown provider errors enter the wait-and-retry cycle. Set to `false` (also accepts `0`, `no`, `off`) to surface the error immediately. Rate-limit, overload, authentication, and network retry behavior is unchanged. |
275
+ | `PI_LIMITS_WAIT_DEFAULT_WAITING` | `true` | Whether the extension ever waits. Set to `false` (also accepts `0`, `no`, `off`) to never wait or retry the same model: on a rate limit, overload, authentication, network or unknown error it immediately moves to the next usable `fallback-models` entry, and surfaces the last error once none is left (or at once without fallback models). Model switching itself is controlled by `fallback-models`. Useful for hosts such as proxies that must answer promptly. |
276
276
  | `PI_LIMITS_WAIT_MAX_RETRY` | `999999` | Maximum extension-created retries for an unclassified/unknown provider error. The initial failed request and provider-internal retries do not count. HTTP 400/403 remain eligible. Must be a non-negative integer. |
277
277
  | `PI_LIMITS_WAIT_LIVELINESS_INTERVAL` | `15` | Seconds between liveliness notifications published while waiting in non-interactive (`rpc`/`json`) modes. Must be a non-negative integer; values below 1 second are clamped to 1 second. Ignored in TUI mode. Also drives the structured status channel and is reported inside it as `livelinessIntervalMs`. |
278
278
  | `PI_LIMITS_WAIT_STATUS_JSON` | `true` | Whether the machine-readable status channel (`statusKey` `oira666.pi-limits-wait.json`) is published in non-interactive modes. Set to `false` (also accepts `0`, `no`, `off`) to restore exact 0.5.6 wire behavior. The prose channel is never affected. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-limits-wait",
3
- "version": "0.6.10",
3
+ "version": "0.6.13",
4
4
  "description": "Extension for PI that allows to automatically wait for limits to reset and retry the request",
5
5
  "type": "module",
6
6
  "exports": {
@@ -48,8 +48,8 @@
48
48
  "@earendil-works/pi-coding-agent": ">=1.4.0"
49
49
  },
50
50
  "devDependencies": {
51
- "@earendil-works/pi-ai": "1.4.0",
52
- "@earendil-works/pi-coding-agent": "1.4.0",
51
+ "@earendil-works/pi-ai": "1.5.0",
52
+ "@earendil-works/pi-coding-agent": "1.5.0",
53
53
  "tsx": "^4.23.15",
54
54
  "typescript": "^7.0.2"
55
55
  }
package/src/constants.ts CHANGED
@@ -43,7 +43,7 @@ export const DEFAULT_LIVELINESS_INTERVAL_MS = 15 * 1_000; // 15 seconds
43
43
  export const LIVELINESS_JSON_STATUS_KEY = "oira666.pi-limits-wait.json";
44
44
  /** Payload schema version for LIVELINESS_JSON_STATUS_KEY. Bump only on a breaking change. */
45
45
  export const LIVELINESS_JSON_VERSION = 1;
46
- export const EXTENSION_VERSION = "0.6.8";
46
+ export const EXTENSION_VERSION = "0.6.13";
47
47
 
48
48
  export const SETTINGS_FILE_NAME = "limits-wait.json";
49
49
  export const FALLBACK_MODELS_KEY = "fallback-models";
package/src/models.ts CHANGED
@@ -39,16 +39,15 @@ export function configuredAttempt(model: Model<Api>): FallbackModel {
39
39
  }
40
40
 
41
41
  /**
42
- * A manual choice stays first until it fails. Otherwise use configured priority.
43
- * An unlisted startup model stays first until an explicit selection has failed.
42
+ * The startup model stays ahead of configured fallbacks, even if listed lower.
43
+ * A manual choice stays first until it fails, then releases priority to the list.
44
44
  * Unrelated calls are excluded by isFallbackEligibleModel.
45
45
  */
46
46
  export function candidateOrder(current: Model<Api>): FallbackModel[] {
47
47
  const primary = getPrimaryModel(current);
48
- const listed = state.fallbackModels.some((entry) => modelKey(entry.model) === modelKey(primary));
49
48
  const order: FallbackModel[] = state.manualModelOverride
50
49
  ? [configuredAttempt(state.manualModelOverride)]
51
- : listed || state.manualModelOverrideReleased ? [] : [configuredAttempt(primary)];
50
+ : state.manualModelOverrideReleased ? [] : [configuredAttempt(primary)];
52
51
  const seen = new Set(order.map((entry) => modelKey(entry.model)));
53
52
  for (const entry of state.fallbackModels) {
54
53
  const key = modelKey(entry.model);
@@ -217,11 +217,12 @@ export function retryAfterHeaderMs(headers: Headers): number | undefined {
217
217
  }
218
218
 
219
219
  export function getRetryableError(msg: string): RetryableError | undefined {
220
- // Business workspace limits require an administrator, not a timed reset.
220
+ // Workspace-admin limits and Z.ai plan exhaustion are not transient throttles.
221
221
  // Keep retry-all configurable, but use its bounded unknown-error budget even
222
222
  // when transport metadata adds HTTP 429 / Retry-After to the provider text.
223
223
  if (msg.includes("subscription_sharing_workplace_individual_usage_limit_exceeded")
224
- || msg.includes("You've reached your ChatGPT workspace's usage limit.")) return undefined;
224
+ || msg.includes("You've reached your ChatGPT workspace's usage limit.")
225
+ || /"code"\s*:\s*"?1308(?![0-9])/i.test(msg)) return undefined;
225
226
  if (isServerOverloadedError(msg)) {
226
227
  return { reason: "overloaded", waitMs: parseRetryDelayMs(msg) ?? DEFAULT_OVERLOADED_WAIT_MS };
227
228
  }
package/src/state.ts CHANGED
@@ -13,7 +13,7 @@ export const state = {
13
13
  ambientStatusCleanup: undefined as (() => void) | undefined,
14
14
  modelStatusCleanup: undefined as (() => void) | undefined,
15
15
  fallbackModels: [] as FallbackModel[],
16
- unknownErrorWaitingEnabled: true,
16
+ waitingEnabled: true,
17
17
  // Includes the initial request, so the default is one more than max retries.
18
18
  nonRetryableMaxAttempts: DEFAULT_UNKNOWN_ERROR_MAX_RETRIES + 1,
19
19
  nonRetryableRetryDelayMs: DEFAULT_UNKNOWN_ERROR_RETRY_INTERVAL_MS,
package/src/stream.ts CHANGED
@@ -217,9 +217,10 @@ export function streamWithLimitsRetry(
217
217
  const nonRetryableAttempts = new Map<string, number>();
218
218
  const triedModels = new Set<string>();
219
219
  let lastExhaustedFailure: string | undefined;
220
- // Exhaustion is request-local when freezing is disabled. Every selection,
221
- // including invocation-time re-selection, must use the same exclusions.
222
- const exclusions = () => freezingEnabled() ? undefined : triedModels;
220
+ let lastFailure: string | undefined;
221
+ // Exhaustion is request-local when freezing or waiting is disabled. Every
222
+ // selection, including invocation-time re-selection, must use the same exclusions.
223
+ const exclusions = () => freezingEnabled() && state.waitingEnabled ? undefined : triedModels;
223
224
  const nextAttemptCandidate = (current: Model<Api>) => findAttemptCandidate(current, exclusions());
224
225
  const nextAvailableCandidate = (current: Model<Api>) => nextAttemptCandidate(current)?.entry;
225
226
  const earliestCandidateWakeup = (current: Model<Api>) => findCandidateWakeup(current, exclusions());
@@ -317,6 +318,7 @@ export function streamWithLimitsRetry(
317
318
  const next = nextAttemptCandidate(attempt.model);
318
319
  if (!next) {
319
320
  if (!freezingEnabled() && lastExhaustedFailure) throw new Error(lastExhaustedFailure);
321
+ if (!state.waitingEnabled) throw new Error(lastFailure ?? "All configured models are unavailable.");
320
322
  const wakeup = earliestCandidateWakeup(attempt.model)!;
321
323
  const wait = await managedWait("model-frozen", Math.max(0, wakeup - Date.now()), "All configured models are unavailable.");
322
324
  if (wait === "aborted" || signal?.aborted) {
@@ -334,6 +336,7 @@ export function streamWithLimitsRetry(
334
336
  const buffer: AssistantMessageEvent[] = [];
335
337
  let retryable: RetryableError | undefined;
336
338
  let retryableErrorMessage = "";
339
+ let retryableEvent: AssistantMessageEvent | undefined;
337
340
  let nonRetryableError: { message: string; event?: AssistantMessageEvent } | undefined;
338
341
  let observedHttpError: string | undefined;
339
342
  let providerPayloadReady = false;
@@ -478,8 +481,10 @@ export function streamWithLimitsRetry(
478
481
  return;
479
482
  }
480
483
  retryable = getRetryableError(errMsg);
481
- if (retryable) retryableErrorMessage = errMsg;
482
- else nonRetryableError = { message: errMsg, event: surfacedEvent };
484
+ if (retryable) {
485
+ retryableErrorMessage = errMsg;
486
+ retryableEvent = surfacedEvent;
487
+ } else nonRetryableError = { message: errMsg, event: surfacedEvent };
483
488
  break;
484
489
  }
485
490
 
@@ -608,6 +613,8 @@ export function streamWithLimitsRetry(
608
613
  return;
609
614
  }
610
615
 
616
+ lastFailure = retryableErrorMessage || nonRetryableError?.message || lastFailure;
617
+
611
618
  // A superseded attempt cannot freeze or limit the user's newer choice.
612
619
  if (observedUserModelSelection !== state.userModelSelectionGeneration) continue;
613
620
  releaseManualOverride();
@@ -628,40 +635,40 @@ export function streamWithLimitsRetry(
628
635
  postponeProbe(attempt.model);
629
636
  }
630
637
 
631
- if (!retryable) {
632
- const failureEvent = nonRetryableError?.event;
633
- const failureMessage = nonRetryableError?.message ?? "Provider stream ended without a terminal event.";
634
- const emitFailure = () => {
635
- retryPeriod?.recordReason(failureMessage);
636
- retryPeriod?.finalize();
637
- // No further wait/retry is planned: this is the only error surfaced
638
- // to non-interactive hosts for this request.
639
- notifyFinalFailure(
640
- `${formatModel(attempt.model)} failed and pi-limits-wait is not retrying: ${formatErrorDetail(failureMessage, Number.MAX_SAFE_INTEGER)}`,
641
- failureMessage,
642
- );
643
- if (!committed) {
644
- if (buffer.length > 0) flush(buffer);
645
- else {
646
- output.push({ type: "start", partial: freshMessage(attempt.model) });
647
- committed = true;
648
- }
649
- }
650
- if (failureEvent) output.push(failureEvent);
638
+ const emitFailure = (failureMessage: string, failureEvent?: AssistantMessageEvent) => {
639
+ retryPeriod?.recordReason(failureMessage);
640
+ retryPeriod?.finalize();
641
+ // No further wait/retry is planned: this is the only error surfaced
642
+ // to non-interactive hosts for this request.
643
+ notifyFinalFailure(
644
+ `${formatModel(attempt.model)} failed and pi-limits-wait is not retrying: ${formatErrorDetail(failureMessage, Number.MAX_SAFE_INTEGER)}`,
645
+ failureMessage,
646
+ );
647
+ if (!committed) {
648
+ if (buffer.length > 0) flush(buffer);
651
649
  else {
652
- const error = freshMessage(attempt.model);
653
- error.stopReason = "error";
654
- error.errorMessage = failureMessage;
655
- output.push({ type: "error", reason: "error", error });
650
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
651
+ committed = true;
656
652
  }
657
- output.end();
658
- };
653
+ }
654
+ if (failureEvent) output.push(failureEvent);
655
+ else {
656
+ const error = freshMessage(attempt.model);
657
+ error.stopReason = "error";
658
+ error.errorMessage = failureMessage;
659
+ output.push({ type: "error", reason: "error", error });
660
+ }
661
+ output.end();
662
+ };
663
+
664
+ if (!retryable) {
665
+ const failureMessage = nonRetryableError?.message ?? "Provider stream ended without a terminal event.";
659
666
 
660
- if (!committed && (state.unknownErrorWaitingEnabled || probe)) {
667
+ if (!committed) {
661
668
  const key = modelKey(attempt.model);
662
669
  const attempts = (nonRetryableAttempts.get(key) ?? 0) + 1;
663
670
  nonRetryableAttempts.set(key, attempts);
664
- if (!probe && attempts < state.nonRetryableMaxAttempts) {
671
+ if (!probe && state.waitingEnabled && attempts < state.nonRetryableMaxAttempts) {
665
672
  notifyRetryingAfterError(attempt.model, state.nonRetryableRetryDelayMs, failureMessage);
666
673
  retryPeriod?.beginRetry(failureMessage);
667
674
  const wait = await managedWait("retry", state.nonRetryableRetryDelayMs, failureMessage, {
@@ -687,7 +694,7 @@ export function streamWithLimitsRetry(
687
694
  attempt = next;
688
695
  continue;
689
696
  }
690
- const wakeup = earliestCandidateWakeup(attempt.model);
697
+ const wakeup = state.waitingEnabled ? earliestCandidateWakeup(attempt.model) : undefined;
691
698
  if (wakeup) {
692
699
  retryPeriod?.beginRetry(failureMessage);
693
700
  const wait = await managedWait("model-frozen", Math.max(0, wakeup - Date.now()), failureMessage);
@@ -710,7 +717,23 @@ export function streamWithLimitsRetry(
710
717
  }
711
718
  }
712
719
  }
713
- emitFailure();
720
+ emitFailure(failureMessage, nonRetryableError?.event);
721
+ return;
722
+ }
723
+
724
+ if (!state.waitingEnabled) {
725
+ // Waiting is off: move on to the next usable model now, or surface the error.
726
+ if (allowFallback) {
727
+ if (retryable.reason !== "network") rememberRateLimit(attempt.model, retryable, retryableErrorMessage, true);
728
+ triedModels.add(modelKey(attempt.model));
729
+ const next = nextAvailableCandidate(attempt.model);
730
+ if (next) {
731
+ retryPeriod?.recordImmediateRetry(retryableErrorMessage);
732
+ attempt = next;
733
+ continue;
734
+ }
735
+ }
736
+ emitFailure(retryableErrorMessage, retryableEvent);
714
737
  return;
715
738
  }
716
739
 
package/src/ui.ts CHANGED
@@ -126,7 +126,7 @@ export function freezingEnabled(): boolean {
126
126
 
127
127
  /** Load unknown-error retry settings once per extension instance/reload. */
128
128
  export function loadUnknownErrorRetrySettings(): void {
129
- state.unknownErrorWaitingEnabled = envBoolean(process.env[DEFAULT_WAITING_ENV_VAR], true);
129
+ state.waitingEnabled = envBoolean(process.env[DEFAULT_WAITING_ENV_VAR], true);
130
130
  const maxRetries = envNonNegativeInteger(
131
131
  process.env[MAX_RETRY_ENV_VAR],
132
132
  DEFAULT_UNKNOWN_ERROR_MAX_RETRIES,