oira666_pi-limits-wait 0.6.6 → 0.6.8

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,11 +12,13 @@ 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.
16
+
15
17
  ## What it does
16
18
 
17
- - Targets Pi 0.80.10+ (developed and tested against Pi 0.83.0) and works with model providers that use `ModelRuntime.streamSimple`.
19
+ - Works with model providers that use `ModelRuntime.streamSimple`.
18
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.
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.
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.
20
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.
21
23
  - On rate-limit errors (`429`, `rate_limit`, `too many requests`, quota/reset messages), waits and retries in a loop.
22
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).
@@ -24,7 +26,7 @@ Restart Pi or run `/reload` after installing.
24
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.
25
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.
26
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.
27
- - 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.
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.
28
30
  - Reports retry/fallback waits in chat notifications without adding persistent TUI status lines.
29
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).
30
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.
@@ -34,9 +36,21 @@ Restart Pi or run `/reload` after installing.
34
36
 
35
37
  ### Unsupported interception limitation
36
38
 
37
- Pi 0.83 has no public stream middleware that can intercept a failed provider stream, wait, and re-enter `ModelRuntime` for another attempt. For that reason, limits-wait still installs a reload-safe trampoline on `ModelRuntime.prototype.streamSimple`. This is an unsupported internal interception point and could require changes when Pi changes `ModelRuntime`; it also assumes the interactive CLI's one active session runtime per process. SDK processes with multiple concurrent Pi sessions are not supported.
39
+ Pi 1.4.0 has no public stream middleware that can intercept a failed provider stream, wait, and re-enter `ModelRuntime` for another attempt. For that reason, limits-wait still installs a reload-safe trampoline on `ModelRuntime.prototype.streamSimple`. This is an unsupported internal interception point and could require changes when Pi changes `ModelRuntime`; it also assumes the interactive CLI's one active session runtime per process. SDK processes with multiple concurrent Pi sessions are not supported.
40
+
41
+ `after_provider_response` is preserved for observers, but cannot replace stream-error interception or own retry/fallback control flow. Pi 1.4.0 also does not expose the active header dispatcher to extensions. To preserve `before_provider_headers` on different-model fallbacks without replaying arbitrary source-bound `transformHeaders` closures, limits-wait intercepts exported `ExtensionRunner.prototype.createContext` to associate the active registry with its runner, then calls `emitBeforeProviderHeaders` on freshly resolved target headers. This is another unsupported integration point, isolated in `src/header-dispatch.ts`. Target auth, endpoint, configured headers and native client attribution remain runtime-owned; hook insertions and `null` deletions apply to the target, including attribution assembled after the hook. Client identity and provider-stream instrumentation are retained across fallback. Standalone SDK calls without an active extension runner still discard source transforms.
42
+
43
+ ## Compatibility tests
44
+
45
+ Run `npm run test:evergreen` for the strict TypeScript check, unit tests, integration tests, and required global-CLI e2e tests. Also run `npm run pack:check` before release. There is no separate build or lint task.
46
+
47
+ Evergreen e2e uses `$EVERGREEN_PI_BIN` (or `pi` on PATH) with stdin ignored. Set `$EVERGREEN_E2E_MODEL` to the live model, e.g. `openai-codex/gpt-6-luna:low`. It exercises a real rate-limit/retry transport, OAuth prompt cleaning, the kill switch, durable retry summaries outside model context, session resume, and a live two-turn conversation using normal Pi authentication. Missing CLI or model access fails rather than skips. Temporary sessions are removed afterward. See [EVERGREEN.md](EVERGREEN.md) for the intent and compatibility risks.
48
+
49
+ The separate `npm run test:e2e` suite is a legacy RPC diagnostic; unlike Evergreen's print-mode tests it requires a command pipe on stdin.
38
50
 
39
- `after_provider_response` is preserved for observers, but it is not an equivalent retry hook: it cannot replace stream-error interception, own retry/fallback control flow, or recreate a complete target-model request. In particular, Pi 0.83 still does not expose a public factory for rebuilding the SDK's target-model-aware `transformHeaders` callback. Reusing the source request's closure on a fallback would apply source-model attribution and could leak extension-added headers, so limits-wait intentionally omits it for every different-model fallback. The target `ModelRuntime` still resolves target auth, base URL, configured provider/model headers, environment, and provider implementation canonically, but `before_provider_headers` cannot be re-fired for that fallback through the current public API.
51
+ `test:e2e` requires `PI_EVERGREEN_PI_BIN` pointing to the installed global Pi executable. It explicitly loads this checkout and never installs a CLI. Loopback HTTP tests use dummy OAuth-shaped credentials in an isolated agent directory and exercise retries, tools, prompt changes, manual/overflow compaction, abort, session replacement, and reload. They also verify real cross-provider fallback headers (target insertion/deletion, native attribution, source-secret exclusion and parsed-stream events) across multiple turns and lifecycle transitions, plus OpenAI Responses workspace limits with retry-all enabled/disabled and Pi auto-retry enabled. A negative control without the extension proves the transport assertions detect prompt leaks.
52
+
53
+ The required live two-turn smoke uses normal Pi authentication without reading credential files. Configure it with `PI_EVERGREEN_E2E_PROVIDER`, `PI_EVERGREEN_E2E_MODEL`, and `PI_EVERGREEN_E2E_REASONING` (defaults: `openai`, `gpt-6-luna`, `medium`). It captures request bodies, never auth headers, and removes its repository-local temporary artifacts afterward. Missing CLI or model access fails the suite rather than skipping coverage.
40
54
 
41
55
  ## Non-interactive (RPC) reporting
42
56
 
@@ -267,6 +281,19 @@ The extension rereads these files before every LLM call, so changes made during
267
281
  | `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
282
  | `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
283
 
284
+ ## Development
285
+
286
+ ```sh
287
+ npm ci
288
+ mkdir -p tmp
289
+ TMPDIR="$PWD/tmp" npm test
290
+ npm run pack:check
291
+ ```
292
+
293
+ `npm test` runs `npm run check` and the tests against the installed development dependencies. Tests target only the latest npm Pi release; update the pinned Pi development dependencies before testing a newer release. Before each release, update the tested version above and the Pi peer dependency baseline after all checks pass. No lint command is configured.
294
+
295
+ `npm test` covers retries against a local HTTP server and editor focus/key routing without live model calls. `npm run test:evergreen` adds integration and live global-CLI e2e coverage; README snippets are not separately executed.
296
+
270
297
  ## License
271
298
 
272
299
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-limits-wait",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
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": {
@@ -29,7 +29,12 @@
29
29
  },
30
30
  "scripts": {
31
31
  "check": "tsc -p tsconfig.json --noEmit",
32
+ "pretest": "npm run check",
32
33
  "test": "tsx src/__tests__/test-extension.ts && tsx src/__tests__/test-wait-ui.ts",
34
+ "test:e2e": "node scripts/test-e2e.mjs",
35
+ "test:integration": "tsx src/__tests__/test-integration.ts",
36
+ "test:e2e:evergreen": "node scripts/test-evergreen-e2e.mjs",
37
+ "test:evergreen": "npm test && npm run test:integration && npm run test:e2e:evergreen",
33
38
  "pack:check": "npm pack --dry-run",
34
39
  "prepublishOnly": "npm run check && npm test && npm run pack:check"
35
40
  },
@@ -38,14 +43,13 @@
38
43
  "./src/index.ts"
39
44
  ]
40
45
  },
41
- "dependencies": {},
42
46
  "peerDependencies": {
43
- "@earendil-works/pi-ai": "*",
44
- "@earendil-works/pi-coding-agent": "*"
47
+ "@earendil-works/pi-ai": ">=1.4.0",
48
+ "@earendil-works/pi-coding-agent": ">=1.4.0"
45
49
  },
46
50
  "devDependencies": {
47
- "@earendil-works/pi-ai": "0.83.0",
48
- "@earendil-works/pi-coding-agent": "0.83.0",
51
+ "@earendil-works/pi-ai": "1.4.0",
52
+ "@earendil-works/pi-coding-agent": "1.4.0",
49
53
  "tsx": "^4.20.6",
50
54
  "typescript": "^5.9.3"
51
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.6";
46
+ export const EXTENSION_VERSION = "0.6.8";
47
47
 
48
48
  export const SETTINGS_FILE_NAME = "limits-wait.json";
49
49
  export const FALLBACK_MODELS_KEY = "fallback-models";
@@ -0,0 +1,35 @@
1
+ import { ExtensionRunner, type ModelRegistry } from "@earendil-works/pi-coding-agent";
2
+ import { state } from "./state.js";
3
+ import type { ModelsSimpleStreamOptions } from "@earendil-works/pi-ai";
4
+
5
+ // Pi does not expose its header dispatcher to extensions. Recover the runner
6
+ // from the contexts it creates, rather than replaying a source-model closure
7
+ // (which may inject source credentials) against another provider's headers.
8
+ // This exported-class interception is unsupported; keep it isolated and cover
9
+ // actual CLI fallback, reload and session replacement at the transport boundary.
10
+ const SLOT = Symbol.for("oira666.pi-limits-wait.header-dispatch.v1");
11
+ interface DispatchSlot {
12
+ runners: WeakMap<ModelRegistry, ExtensionRunner>;
13
+ wrapper: ExtensionRunner["createContext"];
14
+ }
15
+ type RunnerPrototype = ExtensionRunner & { [SLOT]?: DispatchSlot };
16
+
17
+ export function installHeaderDispatch(): void {
18
+ const prototype = ExtensionRunner.prototype as RunnerPrototype;
19
+ if (prototype[SLOT]?.wrapper === prototype.createContext) return;
20
+ const previous = prototype.createContext;
21
+ const runners = new WeakMap<ModelRegistry, ExtensionRunner>();
22
+ const wrapper = function (this: ExtensionRunner) {
23
+ const context = previous.call(this);
24
+ runners.set(context.modelRegistry, this);
25
+ return context;
26
+ };
27
+ Object.defineProperty(prototype, SLOT, { configurable: true, value: { runners, wrapper } });
28
+ prototype.createContext = wrapper;
29
+ }
30
+
31
+ export function fallbackHeaderTransform(): ModelsSimpleStreamOptions["transformHeaders"] {
32
+ const registry = state.sharedCtx?.modelRegistry;
33
+ const runner = registry && (ExtensionRunner.prototype as RunnerPrototype)[SLOT]?.runners.get(registry);
34
+ return runner ? (headers) => runner.emitBeforeProviderHeaders(headers) : undefined;
35
+ }
package/src/index.ts CHANGED
@@ -117,13 +117,8 @@ export default function (pi: ExtensionAPI) {
117
117
  const retrySummaries = new RetrySummaryCoordinator(pi);
118
118
  const releaseInterception = installModelRuntimeInterception(() => retrySummaries.createStream());
119
119
 
120
- // Pi 0.86+ warms caches through the same runtime as foreground requests.
121
- // Stop background refreshes before they can enter our retry/fallback logic.
122
- // Keep the older Pi dependency range: its event registry accepts this hook
123
- // but never emits it. The narrow overload bridges the pre-0.86 typings.
124
- (pi as ExtensionAPI & {
125
- on(event: "cache_warming_decision", handler: () => { action: "stop" }): void;
126
- }).on("cache_warming_decision", () => ({ action: "stop" }));
120
+ // Background refreshes must not enter foreground retry/fallback logic.
121
+ pi.on("cache_warming_decision", () => ({ action: "stop" }));
127
122
 
128
123
  pi.on("session_start", (event, ctx) => {
129
124
  state.sharedCtx = ctx;
@@ -51,6 +51,8 @@ export function isTransientNetworkError(msg: string): boolean {
51
51
  lower.includes("terminated") ||
52
52
  lower.includes("socket hang up") ||
53
53
  lower.includes("other side closed") ||
54
+ /websocket closed 1006\b/.test(lower) ||
55
+ lower.includes("connection ended") ||
54
56
  lower.includes("network_error") ||
55
57
  lower.includes("network error") ||
56
58
  lower.includes("econnreset") ||
@@ -215,6 +217,11 @@ export function retryAfterHeaderMs(headers: Headers): number | undefined {
215
217
  }
216
218
 
217
219
  export function getRetryableError(msg: string): RetryableError | undefined {
220
+ // Business workspace limits require an administrator, not a timed reset.
221
+ // Keep retry-all configurable, but use its bounded unknown-error budget even
222
+ // when transport metadata adds HTTP 429 / Retry-After to the provider text.
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;
218
225
  if (isServerOverloadedError(msg)) {
219
226
  return { reason: "overloaded", waitMs: parseRetryDelayMs(msg) ?? DEFAULT_OVERLOADED_WAIT_MS };
220
227
  }
package/src/stream.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  switchPiModel,
35
35
  } from "./models.js";
36
36
  import { withAttemptResponseObserver } from "./response-observer.js";
37
+ import { fallbackHeaderTransform, installHeaderDispatch } from "./header-dispatch.js";
37
38
  import { errorMessageWithCauses, getRetryableError, isAbortMessage, parseRetryDelayMs, retryAfterHeaderMs } from "./retry-errors.js";
38
39
  import type { RetryPeriodTracker } from "./retry-summary.js";
39
40
  import { loadFallbackSettings } from "./settings.js";
@@ -166,6 +167,11 @@ function attemptOptions(
166
167
  signal,
167
168
  onPayload,
168
169
  onResponse,
170
+ // Dispatch the host's hooks on freshly resolved TARGET headers. Do not
171
+ // replay options.transformHeaders: arbitrary SDK callers may bind secrets.
172
+ transformHeaders: fallbackHeaderTransform(),
173
+ identity: options?.identity,
174
+ onProviderStreamEvent: options?.onProviderStreamEvent,
169
175
  cacheRetention: options?.cacheRetention,
170
176
  sessionId: options?.sessionId,
171
177
  timeoutMs: options?.timeoutMs,
@@ -815,6 +821,7 @@ export function disableModelRuntimeInterception(): void {
815
821
  export function installModelRuntimeInterception(
816
822
  createRetryPeriod?: () => RetryPeriodTracker,
817
823
  ): () => boolean {
824
+ installHeaderDispatch();
818
825
  const prototype = ModelRuntime.prototype as InterceptablePrototype;
819
826
  const owner = {};
820
827
  const controller = new AbortController();