oira666_pi-limits-wait 0.6.4 → 0.6.6
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 +6 -6
- package/package.json +1 -1
- package/src/constants.ts +3 -3
- package/src/index.ts +3 -3
- package/src/models.ts +13 -20
- package/src/prompt.ts +48 -2
- package/src/stream.ts +2 -2
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Restart Pi or run `/reload` after installing.
|
|
|
19
19
|
- On Pi 0.86+, 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.
|
|
20
20
|
- 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.
|
|
21
21
|
- On rate-limit errors (`429`, `rate_limit`, `too many requests`, quota/reset messages), waits and retries in a loop.
|
|
22
|
-
- Remembers provider retry timing exposed through response callbacks or provider errors (`retry-after`, `retry-after-ms`, `retry in ...`, reset messages).
|
|
22
|
+
- 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).
|
|
23
23
|
- If no retry timing is available for a rate limit, waits 30 minutes before retrying.
|
|
24
24
|
- 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.
|
|
25
25
|
- 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.
|
|
@@ -30,7 +30,7 @@ Restart Pi or run `/reload` after installing.
|
|
|
30
30
|
- 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.
|
|
31
31
|
- Keeps a manually chosen model ahead of the configured priority list until it fails. After failure, normal retry/fallback rules apply and subsequent calls use the configured order again.
|
|
32
32
|
- Optionally falls back to configured models when the current/default model is rate-limited. Every fallback to a different model—even on the same provider—deliberately discards request-owned credentials, headers, environment overrides, and provider-specific options so the target runtime resolves its own configuration.
|
|
33
|
-
- Preserves Pi's `before_provider_request` / `after_provider_response` callback flow
|
|
33
|
+
- Preserves Pi's `before_provider_request` / `after_provider_response` callback flow. When Pi's exact Anthropic OAuth identity block is present, sanitises both the initial prompt and later system-message updates, repeating the Claude Code identity in textual updates. User/assistant messages and tool blocks stay untouched. Pi remains responsible for initial OAuth identity insertion and authentication; this does not repair invalid credentials.
|
|
34
34
|
|
|
35
35
|
### Unsupported interception limitation
|
|
36
36
|
|
|
@@ -234,7 +234,7 @@ Each `fallback-models` entry supports:
|
|
|
234
234
|
|
|
235
235
|
Top-level settings keys:
|
|
236
236
|
|
|
237
|
-
- `probe-interval-seconds` — optional. How often a
|
|
237
|
+
- `probe-interval-seconds` — optional. How often a higher-priority model with an estimated limit/freeze is silently re-probed. Explicit provider retry/reset deadlines are never probed early. Default `60`, `0` disables probing. `PI_LIMITS_WAIT_PROBE_INTERVAL` overrides it. Both sources accept only safe non-negative integers (numeric strings are also accepted); values above 2147483 seconds are capped. Invalid file values use the default, and invalid environment values use the file value or default.
|
|
238
238
|
|
|
239
239
|
Fallback priority and manual selection:
|
|
240
240
|
|
|
@@ -245,10 +245,10 @@ Fallback priority and manual selection:
|
|
|
245
245
|
- the remembered limit for that model expires;
|
|
246
246
|
- the model answers a probe (see below);
|
|
247
247
|
- successful output clears the limit/freeze known when its attempt began, but never a newer concurrent failure. Committed streams are never interrupted to reclaim a model.
|
|
248
|
-
5. A
|
|
248
|
+
5. A higher-priority model with an estimated limit/freeze (no explicit provider timing) is eligible for a probe every **`probe-interval-seconds`** (default 60s), on the next LLM call or while waiting with all candidates blocked. Explicit provider `Retry-After` and reset deadlines always block early probes, regardless of this interval; `0` disables early probes altogether. Manual wait skipping can still retry immediately. Probes and their recoverable failures are silent: no probe notices or retry/freeze warnings are shown. A probe is one normal request sent to that model instead of the current fallback:
|
|
249
249
|
- if it succeeds, the model is reclaimed immediately and the request continues on it;
|
|
250
250
|
- if it fails before output is committed, it costs just one attempt—even for HTTP 403, network or unknown errors—then falls back down the order and postpones the next probe by another interval. Abort and context-overflow errors remain terminal rather than triggering fallback. With freezing disabled, unknown/HTTP 403 probe failures only postpone probes of the existing limit (without extending its deadline or creating a freeze); that candidate is exhausted for the current request. Exhausted and not-yet-due blocked candidates are skipped, and if no eligible candidate remains the unknown error is surfaced rather than looping or waiting on a freeze.
|
|
251
|
-
6. When *every* configured model is unavailable, the extension waits — but only until the earliest deadline **or the next
|
|
251
|
+
6. When *every* configured model is unavailable, the extension waits — but only until the earliest deadline **or the next eligible probe**, whichever comes first (never probing before an explicit provider deadline), so recovery is never delayed by a long remembered deadline.
|
|
252
252
|
7. Rate-limit/freeze memory lives only in memory and is cleared when Pi restarts.
|
|
253
253
|
|
|
254
254
|
The extension rereads these files before every LLM call, so changes made during a long-running task take effect on the next LLM call without restarting Pi or using `/reload`. Tool calls do not trigger a settings read. When the settings are loaded, the extension shows the full usable fallback model list. When models become rate-limited, it reports the wait in chat notifications.
|
|
@@ -263,7 +263,7 @@ The extension rereads these files before every LLM call, so changes made during
|
|
|
263
263
|
| `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`. |
|
|
264
264
|
| `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. |
|
|
265
265
|
| `PI_LIMITS_WAIT_CONTROL_FILE` | *(unset)* | Absolute path of the out-of-band control file polled once per second **while waiting**, used by the host to skip the current wait and retry immediately. Unset disables the feature and all filesystem access. See [Skipping a wait from the host](#skipping-a-wait-from-the-host). |
|
|
266
|
-
| `PI_LIMITS_WAIT_PROBE_INTERVAL` | `60` | Seconds between re-probes of a higher-priority model
|
|
266
|
+
| `PI_LIMITS_WAIT_PROBE_INTERVAL` | `60` | Seconds between silent re-probes of a higher-priority model with an estimated rate limit or freeze. Explicit provider retry/reset deadlines always block early probes. Lower values return to the preferred model sooner when no provider timing is known, at the cost of more rejected probe requests; `0` disables probing, so a model is only reconsidered when its remembered deadline expires. Overrides the `probe-interval-seconds` settings key. Must be a safe non-negative integer; fractions, Infinity and unsafe values are rejected. Both file and environment values above 2147483 seconds are capped to Node's reliable timeout range. Invalid environment values use the file setting (or default). |
|
|
267
267
|
| `PI_LIMITS_WAIT_RETRY_INTERVAL` | `5` | Seconds to wait between unclassified/unknown provider-error retries. Must be a non-negative integer (values above 2147483 are capped to Node's maximum reliable timer delay). This does not affect model-limit wait intervals. |
|
|
268
268
|
| `PI_LIMITS_WAIT_FREEZING_ENABLED` | `true` | When a model keeps failing with a non-retryable error, it is normally "frozen" for 10 minutes and skipped in favour of other configured models. Set this to `false` (also accepts `0`, `no`, `off`) to disable freezing entirely: the extension will instead try each configured candidate once (after the bounded retries) and then surface the error, never blocking on a long "model-frozen" wait. Useful for non-interactive / subagent runs where no one can press Enter to skip. |
|
|
269
269
|
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -25,8 +25,8 @@ export const DEFAULT_OVERLOADED_WAIT_MS = 5 * 60 * 1_000; // 5 minutes
|
|
|
25
25
|
export const DEFAULT_NETWORK_WAIT_MS = 15 * 1_000; // 15 seconds
|
|
26
26
|
export const DEFAULT_NON_RETRYABLE_FREEZE_MS = 10 * 60 * 1_000; // 10 minutes
|
|
27
27
|
|
|
28
|
-
//
|
|
29
|
-
//
|
|
28
|
+
// Estimated limits/freezes may be silently re-probed this often. Explicit
|
|
29
|
+
// provider retry/reset deadlines always prevent early probes.
|
|
30
30
|
export const DEFAULT_PROBE_INTERVAL_MS = 60 * 1_000; // 1 minute
|
|
31
31
|
|
|
32
32
|
// Unknown provider errors are retried separately from rate-limit, overload,
|
|
@@ -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.6";
|
|
47
47
|
|
|
48
48
|
export const SETTINGS_FILE_NAME = "limits-wait.json";
|
|
49
49
|
export const FALLBACK_MODELS_KEY = "fallback-models";
|
package/src/index.ts
CHANGED
|
@@ -100,9 +100,9 @@ export type { FallbackModel } from "./types.js";
|
|
|
100
100
|
|
|
101
101
|
export default function (pi: ExtensionAPI) {
|
|
102
102
|
// Current pi-ai inserts the Claude Code identity as the first system block
|
|
103
|
-
// for Anthropic OAuth.
|
|
104
|
-
//
|
|
105
|
-
// remain owned
|
|
103
|
+
// for Anthropic OAuth. Use that exact shape to guard sanitisation of the
|
|
104
|
+
// initial prompt and later system updates, which repeat the identity.
|
|
105
|
+
// Authentication, initial identity insertion, and headers remain runtime-owned.
|
|
106
106
|
pi.on("before_provider_request", (event) => sanitiseAnthropicPayloadSystem(event.payload));
|
|
107
107
|
|
|
108
108
|
// Keep prompt sanitisation and the runtime's normal Anthropic header path,
|
package/src/models.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
2
|
import { DEFAULT_NON_RETRYABLE_FREEZE_MS } from "./constants.js";
|
|
3
3
|
import { expectModelSelection, state } from "./state.js";
|
|
4
|
+
import { parseRetryDelayMs } from "./retry-errors.js";
|
|
4
5
|
import type { FallbackModel, RetryableError } from "./types.js";
|
|
5
6
|
import { formatErrorDetail, formatDuration, notifyLiveliness, reasonLabel } from "./ui.js";
|
|
6
7
|
|
|
@@ -82,19 +83,13 @@ export function notifyRetryingAfterError(model: Model<Api>, waitMs: number, erro
|
|
|
82
83
|
notifyLiveliness(`${formatModel(model)} retrying after error in ${formatDuration(waitMs)}. Error: ${formatErrorDetail(errorMessage, Number.MAX_SAFE_INTEGER)}`);
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
function
|
|
86
|
-
const ctx = state.sharedCtx;
|
|
87
|
-
if (!ctx) return;
|
|
88
|
-
const remaining = remainingMs > 0 ? ` (${formatDuration(remainingMs)} left on its remembered limit)` : "";
|
|
89
|
-
try {
|
|
90
|
-
ctx.ui.notify(`Probing higher-priority model ${formatModel(model)}${remaining}.`, "info");
|
|
91
|
-
} catch { /* UI unavailable */ }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function rememberRateLimit(model: Model<Api>, retryable: RetryableError, errorMessage?: string): void {
|
|
86
|
+
export function rememberRateLimit(model: Model<Api>, retryable: RetryableError, errorMessage?: string, silent = false): void {
|
|
95
87
|
const deadline = Date.now() + retryable.waitMs;
|
|
96
|
-
state.rateLimitMemory.set(modelKey(model), {
|
|
97
|
-
|
|
88
|
+
state.rateLimitMemory.set(modelKey(model), {
|
|
89
|
+
reason: retryable.reason, limitedAt: Date.now(), deadline,
|
|
90
|
+
explicitDeadline: errorMessage !== undefined && parseRetryDelayMs(errorMessage) !== undefined,
|
|
91
|
+
});
|
|
92
|
+
if (!silent) notifyRetryableError(model, retryable, errorMessage);
|
|
98
93
|
ensureRateLimitedModelsStatus();
|
|
99
94
|
}
|
|
100
95
|
|
|
@@ -118,9 +113,8 @@ export function probeIntervalMs(): number {
|
|
|
118
113
|
/**
|
|
119
114
|
* Remembered unusability of a model, merged across the rate-limit and frozen
|
|
120
115
|
* memories, plus the moment it becomes worth re-probing. Remembered deadlines
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* its deadline.
|
|
116
|
+
* without provider timing are estimates and may be re-probed periodically.
|
|
117
|
+
* Explicit provider retry/reset deadlines must expire before any early probe.
|
|
124
118
|
*/
|
|
125
119
|
export function blockedState(model: Model<Api>): { deadline: number; probeAt: number } | undefined {
|
|
126
120
|
const limit = activeLimit(model);
|
|
@@ -134,7 +128,8 @@ export function blockedState(model: Model<Api>): { deadline: number; probeAt: nu
|
|
|
134
128
|
frozen ? frozen.lastProbeAt ?? frozen.failedAt : 0,
|
|
135
129
|
);
|
|
136
130
|
const interval = probeIntervalMs();
|
|
137
|
-
const
|
|
131
|
+
const estimatedProbeAt = interval > 0 ? Math.min(lastEvidence + interval, deadline) : deadline;
|
|
132
|
+
const probeAt = Math.max(limit?.explicitDeadline ? limit.deadline : 0, estimatedProbeAt);
|
|
138
133
|
return { deadline, probeAt };
|
|
139
134
|
}
|
|
140
135
|
|
|
@@ -158,9 +153,7 @@ export function clearUnavailability(model: Model<Api>, snapshot: ReturnType<type
|
|
|
158
153
|
|
|
159
154
|
/** Record that a blocked model was just re-probed, so probes stay rate-limited. */
|
|
160
155
|
export function markProbeAttempt(model: Model<Api>, now = Date.now()): void {
|
|
161
|
-
const remainingMs = (blockedState(model)?.deadline ?? now) - now;
|
|
162
156
|
postponeProbe(model, now);
|
|
163
|
-
notifyProbe(model, remainingMs);
|
|
164
157
|
}
|
|
165
158
|
|
|
166
159
|
/** Postpone probing existing evidence without creating or extending a freeze. */
|
|
@@ -198,10 +191,10 @@ export function nextAvailableCandidate(current: Model<Api>, excluded?: ReadonlyS
|
|
|
198
191
|
return nextAttemptCandidate(current, excluded)?.entry;
|
|
199
192
|
}
|
|
200
193
|
|
|
201
|
-
export function rememberNonRetryableFailure(model: Model<Api>, errorMessage: string): void {
|
|
194
|
+
export function rememberNonRetryableFailure(model: Model<Api>, errorMessage: string, silent = false): void {
|
|
202
195
|
const deadline = Date.now() + DEFAULT_NON_RETRYABLE_FREEZE_MS;
|
|
203
196
|
state.nonRetryableFailureMemory.set(modelKey(model), { failedAt: Date.now(), deadline, errorMessage });
|
|
204
|
-
notifyLiveliness(`${formatModel(model)} failed (${formatErrorDetail(errorMessage)}); freezing it for ${formatDuration(DEFAULT_NON_RETRYABLE_FREEZE_MS)} and trying another configured model if available.`);
|
|
197
|
+
if (!silent) notifyLiveliness(`${formatModel(model)} failed (${formatErrorDetail(errorMessage)}); freezing it for ${formatDuration(DEFAULT_NON_RETRYABLE_FREEZE_MS)} and trying another configured model if available.`);
|
|
205
198
|
ensureRateLimitedModelsStatus();
|
|
206
199
|
}
|
|
207
200
|
|
package/src/prompt.ts
CHANGED
|
@@ -34,7 +34,8 @@ function isPiClaudeCodeIdentityBlock(block: unknown): block is SystemTextBlock {
|
|
|
34
34
|
* current pi-ai runtime: the Claude Code identity is the first system block.
|
|
35
35
|
* Looking only at that exact first block avoids treating a later user-authored
|
|
36
36
|
* identity mention as proof of OAuth. The runtime remains solely responsible
|
|
37
|
-
* for inserting the identity and selecting OAuth headers.
|
|
37
|
+
* for inserting the initial identity and selecting OAuth headers. Later system
|
|
38
|
+
* updates are sanitised too and repeat the identity alongside their instructions.
|
|
38
39
|
*/
|
|
39
40
|
export function sanitiseAnthropicPayloadSystem(payload: unknown): unknown {
|
|
40
41
|
if (!payload || typeof payload !== "object") return payload;
|
|
@@ -60,5 +61,50 @@ export function sanitiseAnthropicPayloadSystem(payload: unknown): unknown {
|
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
// Pi 0.86+ can send prompt updates as system messages in the transcript.
|
|
65
|
+
// Only transform their text; tool additions/removals and conversation content
|
|
66
|
+
// must remain unchanged. Keep the OAuth guard above for every transformation.
|
|
67
|
+
const messages = (payload as { messages?: unknown }).messages;
|
|
68
|
+
const newMessages = Array.isArray(messages) ? messages.map((message: unknown) => {
|
|
69
|
+
if (!message || typeof message !== "object") return message;
|
|
70
|
+
const candidate = message as { role?: unknown; content?: unknown };
|
|
71
|
+
if (candidate.role !== "system") return message;
|
|
72
|
+
|
|
73
|
+
if (typeof candidate.content === "string") {
|
|
74
|
+
const cleaned = sanitiseSystemPrompt(candidate.content);
|
|
75
|
+
const content = cleaned ? `${CLAUDE_CODE_IDENTITY}\n\n${cleaned}` : CLAUDE_CODE_IDENTITY;
|
|
76
|
+
if (content === candidate.content) return message;
|
|
77
|
+
changed = true;
|
|
78
|
+
return { ...message, content };
|
|
79
|
+
}
|
|
80
|
+
if (!Array.isArray(candidate.content)) return message;
|
|
81
|
+
|
|
82
|
+
let messageChanged = false;
|
|
83
|
+
let hasIdentity = false;
|
|
84
|
+
const content = candidate.content.map((block: unknown) => {
|
|
85
|
+
if (!block || typeof block !== "object") return block;
|
|
86
|
+
const textBlock = block as SystemTextBlock;
|
|
87
|
+
if (textBlock.type !== "text" || typeof textBlock.text !== "string") return block;
|
|
88
|
+
const cleaned = sanitiseSystemPrompt(textBlock.text);
|
|
89
|
+
const text = hasIdentity ? cleaned
|
|
90
|
+
: cleaned ? `${CLAUDE_CODE_IDENTITY}\n\n${cleaned}` : CLAUDE_CODE_IDENTITY;
|
|
91
|
+
hasIdentity = true;
|
|
92
|
+
if (text === textBlock.text) return block;
|
|
93
|
+
messageChanged = true;
|
|
94
|
+
return { ...textBlock, text };
|
|
95
|
+
}).filter((block: unknown) => {
|
|
96
|
+
if (!block || typeof block !== "object") return true;
|
|
97
|
+
const candidate = block as SystemTextBlock;
|
|
98
|
+
return candidate.type !== "text" || candidate.text !== "";
|
|
99
|
+
});
|
|
100
|
+
if (!messageChanged) return message;
|
|
101
|
+
changed = true;
|
|
102
|
+
return { ...message, content };
|
|
103
|
+
}) : undefined;
|
|
104
|
+
|
|
105
|
+
return changed ? {
|
|
106
|
+
...payload,
|
|
107
|
+
system: newSystem,
|
|
108
|
+
...(newMessages ? { messages: newMessages } : {}),
|
|
109
|
+
} : payload;
|
|
64
110
|
}
|
package/src/stream.ts
CHANGED
|
@@ -611,8 +611,8 @@ export function streamWithLimitsRetry(
|
|
|
611
611
|
// classification. Abort and context overflow have already terminated.
|
|
612
612
|
const failure = retryableErrorMessage || nonRetryableError?.message || "Provider stream ended without a terminal event.";
|
|
613
613
|
if (retryable || freezingEnabled()) {
|
|
614
|
-
if (retryable) rememberRateLimit(attempt.model, retryable, failure);
|
|
615
|
-
else rememberNonRetryableFailure(attempt.model, failure);
|
|
614
|
+
if (retryable) rememberRateLimit(attempt.model, retryable, failure, true);
|
|
615
|
+
else rememberNonRetryableFailure(attempt.model, failure, true);
|
|
616
616
|
retryPeriod?.recordImmediateRetry(failure);
|
|
617
617
|
attempt = nextAvailableCandidate(attempt.model) ?? attempt;
|
|
618
618
|
continue;
|
package/src/types.ts
CHANGED
|
@@ -30,6 +30,8 @@ export type RateLimitMemory = {
|
|
|
30
30
|
reason: RetryReason;
|
|
31
31
|
limitedAt: number;
|
|
32
32
|
deadline: number;
|
|
33
|
+
/** Provider supplied retry/reset timing; never probe before this deadline. */
|
|
34
|
+
explicitDeadline?: boolean;
|
|
33
35
|
/** Last time this model was re-probed while still remembered as limited. */
|
|
34
36
|
lastProbeAt?: number;
|
|
35
37
|
};
|