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 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). With fallback probing enabled, explicit Retry-After deadlines may intentionally be probed early; set the probe interval to `0` to honor those deadlines (unless a wait is manually skipped).
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 and sanitises only the exact Anthropic OAuth system-block shape generated by current Pi; Pi remains responsible for OAuth identity insertion.
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 limited/frozen higher-priority model is re-probed. 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.
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 still-limited higher-priority model is eligible for a probe every **`probe-interval-seconds`** (default 60s), on the next LLM call or while waiting with all candidates blocked. This intentionally permits early probes even before an explicit provider `Retry-After` deadline; `0` disables early probes and honors remembered deadlines unless manually skipped. A probe is one normal request sent to that model instead of the current fallback:
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 due probe**, whichever comes first, so recovery is never delayed by a long remembered deadline.
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 that is still remembered as rate-limited or frozen. Lower values return to the preferred model sooner 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). |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-limits-wait",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
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": {
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
- // A model that is currently limited/frozen is re-probed this often, so a
29
- // higher-priority model can be reclaimed long before its remembered deadline.
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.4";
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. Sanitise only that exact payload shape; authentication,
104
- // identity insertion, Anthropic headers, and all other request composition
105
- // remain owned by ModelRuntime.
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 notifyProbe(model: Model<Api>, remainingMs: number): void {
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), { reason: retryable.reason, limitedAt: Date.now(), deadline });
97
- notifyRetryableError(model, retryable, errorMessage);
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
- * are estimates (`retry-after` is often pessimistic, or absent and defaulted),
122
- * so a blocked model is re-probed periodically instead of being trusted until
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 probeAt = interval > 0 ? Math.min(lastEvidence + interval, deadline) : deadline;
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
- return changed ? { ...payload, system: newSystem } : payload;
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
  };