oira666_pi-limits-wait 0.6.6 → 0.6.7
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 +28 -5
- package/package.json +7 -6
- package/src/header-dispatch.ts +35 -0
- package/src/index.ts +2 -7
- package/src/retry-errors.ts +5 -0
- package/src/stream.ts +7 -0
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
|
-
-
|
|
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
|
-
-
|
|
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,17 @@ Restart Pi or run `/reload` after installing.
|
|
|
34
36
|
|
|
35
37
|
### Unsupported interception limitation
|
|
36
38
|
|
|
37
|
-
Pi 0
|
|
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 test`, `npm run test:e2e`, and `npm run pack:check`. There is no separate build or lint task; `npm test` includes the strict TypeScript check.
|
|
46
|
+
|
|
47
|
+
`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.
|
|
38
48
|
|
|
39
|
-
|
|
49
|
+
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
50
|
|
|
41
51
|
## Non-interactive (RPC) reporting
|
|
42
52
|
|
|
@@ -267,6 +277,19 @@ The extension rereads these files before every LLM call, so changes made during
|
|
|
267
277
|
| `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
278
|
| `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
279
|
|
|
280
|
+
## Development
|
|
281
|
+
|
|
282
|
+
```sh
|
|
283
|
+
npm ci
|
|
284
|
+
mkdir -p tmp
|
|
285
|
+
TMPDIR="$PWD/tmp" npm test
|
|
286
|
+
npm run pack:check
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
`npm test` installs the direct Pi development dependencies at `@latest` with install scripts disabled, then runs `npm run check` and the tests. Tests target only the latest npm Pi release. Before each release, update the tested version above and the Pi peer dependency baseline after all checks pass. No lint command is configured.
|
|
290
|
+
|
|
291
|
+
Tests cover retries against a local HTTP server and editor focus/key routing. They do not call live model providers or separately execute README snippets.
|
|
292
|
+
|
|
270
293
|
## License
|
|
271
294
|
|
|
272
295
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oira666_pi-limits-wait",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.7",
|
|
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,9 @@
|
|
|
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",
|
|
33
35
|
"pack:check": "npm pack --dry-run",
|
|
34
36
|
"prepublishOnly": "npm run check && npm test && npm run pack:check"
|
|
35
37
|
},
|
|
@@ -38,14 +40,13 @@
|
|
|
38
40
|
"./src/index.ts"
|
|
39
41
|
]
|
|
40
42
|
},
|
|
41
|
-
"dependencies": {},
|
|
42
43
|
"peerDependencies": {
|
|
43
|
-
"@earendil-works/pi-ai": "
|
|
44
|
-
"@earendil-works/pi-coding-agent": "
|
|
44
|
+
"@earendil-works/pi-ai": ">=1.4.0",
|
|
45
|
+
"@earendil-works/pi-coding-agent": ">=1.4.0"
|
|
45
46
|
},
|
|
46
47
|
"devDependencies": {
|
|
47
|
-
"@earendil-works/pi-ai": "
|
|
48
|
-
"@earendil-works/pi-coding-agent": "
|
|
48
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
49
50
|
"tsx": "^4.20.6",
|
|
50
51
|
"typescript": "^5.9.3"
|
|
51
52
|
}
|
|
@@ -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
|
-
//
|
|
121
|
-
|
|
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;
|
package/src/retry-errors.ts
CHANGED
|
@@ -215,6 +215,11 @@ export function retryAfterHeaderMs(headers: Headers): number | undefined {
|
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
export function getRetryableError(msg: string): RetryableError | undefined {
|
|
218
|
+
// Business workspace limits require an administrator, not a timed reset.
|
|
219
|
+
// Keep retry-all configurable, but use its bounded unknown-error budget even
|
|
220
|
+
// when transport metadata adds HTTP 429 / Retry-After to the provider text.
|
|
221
|
+
if (msg.includes("subscription_sharing_workplace_individual_usage_limit_exceeded")
|
|
222
|
+
|| msg.includes("You've reached your ChatGPT workspace's usage limit.")) return undefined;
|
|
218
223
|
if (isServerOverloadedError(msg)) {
|
|
219
224
|
return { reason: "overloaded", waitMs: parseRetryDelayMs(msg) ?? DEFAULT_OVERLOADED_WAIT_MS };
|
|
220
225
|
}
|
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();
|