oira666_pi-limits-wait 0.6.11 → 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 +5 -5
- package/package.json +1 -1
- package/src/constants.ts +1 -1
- package/src/models.ts +3 -4
- package/src/state.ts +1 -1
- package/src/stream.ts +58 -35
- package/src/ui.ts +1 -1
package/README.md
CHANGED
|
@@ -20,13 +20,13 @@ Tested against Pi 1.5.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 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, matching Pi's non-retryable classification; the provider's error 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
|
|
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.
|
|
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
|
|
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
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.
|
|
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
|
-
*
|
|
43
|
-
*
|
|
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
|
-
:
|
|
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);
|
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
|
-
|
|
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
|
-
|
|
221
|
-
//
|
|
222
|
-
|
|
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)
|
|
482
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
653
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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,
|