oira666_pi-limits-wait 0.6.2 → 0.6.4
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 -4
- package/package.json +5 -5
- package/src/constants.ts +1 -1
- package/src/index.ts +14 -2
- package/src/models.ts +6 -4
- package/src/state.ts +4 -0
- package/src/stream.ts +23 -4
- package/src/ui.ts +49 -4
package/README.md
CHANGED
|
@@ -27,7 +27,8 @@ Restart Pi or run `/reload` after installing.
|
|
|
27
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.
|
|
28
28
|
- Reports retry/fallback waits in chat notifications without adding persistent TUI status lines.
|
|
29
29
|
- 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
|
-
-
|
|
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
|
+
- 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.
|
|
31
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.
|
|
32
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
34
|
|
|
@@ -235,10 +236,10 @@ Top-level settings keys:
|
|
|
235
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
238
|
|
|
238
|
-
Fallback
|
|
239
|
+
Fallback priority and manual selection:
|
|
239
240
|
|
|
240
|
-
1.
|
|
241
|
-
2.
|
|
241
|
+
1. Normally, `fallback-models` sets priority from top to bottom. An unlisted startup model is tried before the list. Unrelated model calls and internal synthetic models are not rerouted.
|
|
242
|
+
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.
|
|
242
243
|
3. If the active model becomes rate-limited, the extension immediately moves down to the next usable model in the order.
|
|
243
244
|
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:
|
|
244
245
|
- the remembered limit for that model expires;
|
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.4",
|
|
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": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"repository": {
|
|
20
20
|
"type": "git",
|
|
21
|
-
"url": "https://github.com/gee666/pi-limits-wait.git"
|
|
21
|
+
"url": "git+https://github.com/gee666/pi-limits-wait.git"
|
|
22
22
|
},
|
|
23
23
|
"bugs": {
|
|
24
24
|
"url": "https://github.com/gee666/pi-limits-wait/issues"
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"check": "tsc -p tsconfig.json --noEmit",
|
|
32
|
-
"test": "tsx src/__tests__/test-extension.ts",
|
|
32
|
+
"test": "tsx src/__tests__/test-extension.ts && tsx src/__tests__/test-wait-ui.ts",
|
|
33
33
|
"pack:check": "npm pack --dry-run",
|
|
34
34
|
"prepublishOnly": "npm run check && npm test && npm run pack:check"
|
|
35
35
|
},
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@earendil-works/pi-ai": "
|
|
44
|
-
"@earendil-works/pi-coding-agent": "
|
|
43
|
+
"@earendil-works/pi-ai": "*",
|
|
44
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@earendil-works/pi-ai": "0.83.0",
|
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.4";
|
|
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
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { isInternalSyntheticModel } from "./models.js";
|
|
2
|
+
import { isInternalSyntheticModel, modelKey } from "./models.js";
|
|
3
3
|
import { sanitiseAnthropicPayloadSystem } from "./prompt.js";
|
|
4
4
|
import { RetrySummaryCoordinator } from "./retry-summary.js";
|
|
5
5
|
import { loadFallbackSettings } from "./settings.js";
|
|
6
|
-
import { allWaitingDisabled, loadUnknownErrorRetrySettings } from "./ui.js";
|
|
6
|
+
import { allWaitingDisabled, installWaitRetryEditor, loadUnknownErrorRetrySettings } from "./ui.js";
|
|
7
7
|
import { consumeExpectedModelSelection, state } from "./state.js";
|
|
8
8
|
import { disableModelRuntimeInterception, installModelRuntimeInterception } from "./stream.js";
|
|
9
9
|
|
|
@@ -125,11 +125,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
125
125
|
on(event: "cache_warming_decision", handler: () => { action: "stop" }): void;
|
|
126
126
|
}).on("cache_warming_decision", () => ({ action: "stop" }));
|
|
127
127
|
|
|
128
|
+
pi.on("session_start", (event, ctx) => {
|
|
129
|
+
state.sharedCtx = ctx;
|
|
130
|
+
installWaitRetryEditor(ctx, event.reason);
|
|
131
|
+
});
|
|
132
|
+
|
|
128
133
|
pi.on("model_select", (event, ctx) => {
|
|
129
134
|
state.sharedCtx = ctx;
|
|
130
135
|
if (consumeExpectedModelSelection(event.model)) return;
|
|
131
136
|
if (isInternalSyntheticModel(event.model)) return;
|
|
132
137
|
state.primaryModel = event.model;
|
|
138
|
+
state.manualModelOverride = event.source === "restore" ? undefined : event.model;
|
|
139
|
+
state.manualModelOverrideReleased = false;
|
|
140
|
+
// An explicit choice deserves a fresh attempt, even if previously blocked.
|
|
141
|
+
if (state.manualModelOverride) {
|
|
142
|
+
state.rateLimitMemory.delete(modelKey(event.model));
|
|
143
|
+
state.nonRetryableFailureMemory.delete(modelKey(event.model));
|
|
144
|
+
}
|
|
133
145
|
state.primaryThinkingLevel = pi.getThinkingLevel();
|
|
134
146
|
state.userModelSelectionGeneration++;
|
|
135
147
|
// A user-selected model takes precedence over a pending retry. Wake every
|
package/src/models.ts
CHANGED
|
@@ -38,14 +38,16 @@ export function configuredAttempt(model: Model<Api>): FallbackModel {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* A manual choice stays first until it fails. Otherwise use configured priority.
|
|
42
|
+
* An unlisted startup model stays first until an explicit selection has failed.
|
|
43
|
+
* Unrelated calls are excluded by isFallbackEligibleModel.
|
|
44
44
|
*/
|
|
45
45
|
export function candidateOrder(current: Model<Api>): FallbackModel[] {
|
|
46
46
|
const primary = getPrimaryModel(current);
|
|
47
47
|
const listed = state.fallbackModels.some((entry) => modelKey(entry.model) === modelKey(primary));
|
|
48
|
-
const order: FallbackModel[] =
|
|
48
|
+
const order: FallbackModel[] = state.manualModelOverride
|
|
49
|
+
? [configuredAttempt(state.manualModelOverride)]
|
|
50
|
+
: listed || state.manualModelOverrideReleased ? [] : [configuredAttempt(primary)];
|
|
49
51
|
const seen = new Set(order.map((entry) => modelKey(entry.model)));
|
|
50
52
|
for (const entry of state.fallbackModels) {
|
|
51
53
|
const key = modelKey(entry.model);
|
package/src/state.ts
CHANGED
|
@@ -21,6 +21,8 @@ export const state = {
|
|
|
21
21
|
probeIntervalMs: DEFAULT_PROBE_INTERVAL_MS,
|
|
22
22
|
primaryModel: undefined as Model<Api> | undefined,
|
|
23
23
|
primaryThinkingLevel: undefined as ModelThinkingLevel | undefined,
|
|
24
|
+
manualModelOverride: undefined as Model<Api> | undefined,
|
|
25
|
+
manualModelOverrideReleased: false,
|
|
24
26
|
expectedModelSelections: new Map<string, Set<symbol>>(),
|
|
25
27
|
userModelSelectionGeneration: 0,
|
|
26
28
|
activeWaitSkips: new Set<() => void>(),
|
|
@@ -62,6 +64,8 @@ export function resetRuntimeStateForTests(models: FallbackModel[], ctx?: Extensi
|
|
|
62
64
|
state.sharedCtx = ctx;
|
|
63
65
|
state.primaryModel = undefined;
|
|
64
66
|
state.primaryThinkingLevel = undefined;
|
|
67
|
+
state.manualModelOverride = undefined;
|
|
68
|
+
state.manualModelOverrideReleased = false;
|
|
65
69
|
state.rateLimitMemory.clear();
|
|
66
70
|
state.nonRetryableFailureMemory.clear();
|
|
67
71
|
state.expectedModelSelections.clear();
|
package/src/stream.ts
CHANGED
|
@@ -64,6 +64,8 @@ export function __configureFallbackModelsForTests(models: FallbackModel[], ctx?:
|
|
|
64
64
|
state.sharedCtx = ctx;
|
|
65
65
|
state.primaryModel = undefined;
|
|
66
66
|
state.primaryThinkingLevel = undefined;
|
|
67
|
+
state.manualModelOverride = undefined;
|
|
68
|
+
state.manualModelOverrideReleased = false;
|
|
67
69
|
state.rateLimitMemory.clear();
|
|
68
70
|
state.nonRetryableFailureMemory.clear();
|
|
69
71
|
state.ambientStatusCleanup?.();
|
|
@@ -217,6 +219,15 @@ export function streamWithLimitsRetry(
|
|
|
217
219
|
const earliestCandidateWakeup = (current: Model<Api>) => findCandidateWakeup(current, exclusions());
|
|
218
220
|
let observedUserModelSelection = state.userModelSelectionGeneration;
|
|
219
221
|
let forceAttemptAfterSkip = false;
|
|
222
|
+
const releaseManualOverride = () => {
|
|
223
|
+
// A stale failure must not revoke a newer choice, even of the same model.
|
|
224
|
+
if (observedUserModelSelection === state.userModelSelectionGeneration
|
|
225
|
+
&& state.manualModelOverride
|
|
226
|
+
&& modelKey(state.manualModelOverride) === modelKey(attempt.model)) {
|
|
227
|
+
state.manualModelOverride = undefined;
|
|
228
|
+
state.manualModelOverrideReleased = true;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
220
231
|
|
|
221
232
|
const prepareAttemptForPi = async (): Promise<boolean> => {
|
|
222
233
|
const originalKey = modelKey(model);
|
|
@@ -279,9 +290,10 @@ export function streamWithLimitsRetry(
|
|
|
279
290
|
observedUserModelSelection = state.userModelSelectionGeneration;
|
|
280
291
|
const selected = state.primaryModel ?? state.sharedCtx?.model;
|
|
281
292
|
if (selected) {
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
293
|
+
// Try the user's exact choice, not the first configured candidate.
|
|
294
|
+
attempt = configuredAttempt(selected);
|
|
295
|
+
forceAttemptAfterSkip = true;
|
|
296
|
+
nonRetryableAttempts.clear();
|
|
285
297
|
triedModels.clear();
|
|
286
298
|
lastExhaustedFailure = undefined;
|
|
287
299
|
}
|
|
@@ -503,7 +515,10 @@ export function streamWithLimitsRetry(
|
|
|
503
515
|
continue;
|
|
504
516
|
}
|
|
505
517
|
|
|
506
|
-
if (event.type === "error")
|
|
518
|
+
if (event.type === "error") {
|
|
519
|
+
if (event.reason !== "aborted") releaseManualOverride();
|
|
520
|
+
retryPeriod?.recordReason(event.error.errorMessage ?? "Provider stream error");
|
|
521
|
+
}
|
|
507
522
|
if (event.type === "done" || event.type === "error") {
|
|
508
523
|
retryPeriod?.finalize();
|
|
509
524
|
clearLivelinessStatus();
|
|
@@ -587,6 +602,10 @@ export function streamWithLimitsRetry(
|
|
|
587
602
|
return;
|
|
588
603
|
}
|
|
589
604
|
|
|
605
|
+
// A superseded attempt cannot freeze or limit the user's newer choice.
|
|
606
|
+
if (observedUserModelSelection !== state.userModelSelectionGeneration) continue;
|
|
607
|
+
releaseManualOverride();
|
|
608
|
+
|
|
590
609
|
if (probe && !committed) {
|
|
591
610
|
// A probe is exactly one request, regardless of HTTP/network/unknown
|
|
592
611
|
// classification. Abort and context overflow have already terminated.
|
package/src/ui.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CustomEditor, type ExtensionContext, type SessionStartEvent } from "@earendil-works/pi-coding-agent";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { readFile, rename, unlink } from "node:fs/promises";
|
|
3
4
|
import {
|
|
@@ -35,6 +36,51 @@ import {
|
|
|
35
36
|
} from "./telemetry.js";
|
|
36
37
|
import type { RetryReason } from "./types.js";
|
|
37
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Install at session_start, never when a wait starts or ends: replacing the
|
|
41
|
+
* editor also moves focus and would otherwise dismiss an active picker.
|
|
42
|
+
* Intercept submissions, not terminal keys, so autocomplete and other focused
|
|
43
|
+
* components get to handle Enter first. Preserve an existing editor factory.
|
|
44
|
+
*/
|
|
45
|
+
export function installWaitRetryEditor(
|
|
46
|
+
ctx: ExtensionContext,
|
|
47
|
+
reason: SessionStartEvent["reason"] = "startup",
|
|
48
|
+
): void {
|
|
49
|
+
if (ctx.mode !== "tui") return;
|
|
50
|
+
const previous = ctx.ui.getEditorComponent();
|
|
51
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
52
|
+
const editor = previous?.(tui, theme, keybindings) ?? new CustomEditor(tui, theme, keybindings);
|
|
53
|
+
// Pi resets custom editors before reload and only transfers their text.
|
|
54
|
+
// Unlike startup/session switching, reload does not populate history after
|
|
55
|
+
// session_start. Seed our new default editor from the active branch only
|
|
56
|
+
// on reload; existing custom editors remain responsible for their history.
|
|
57
|
+
if (!previous && reason === "reload") {
|
|
58
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
59
|
+
if (entry.type !== "message" || entry.message.role !== "user") continue;
|
|
60
|
+
const content = entry.message.content;
|
|
61
|
+
const text = typeof content === "string"
|
|
62
|
+
? content
|
|
63
|
+
: content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
64
|
+
if (text) editor.addToHistory?.(text);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
let onSubmit = editor.onSubmit;
|
|
68
|
+
// Pi assigns its submit callback after the factory returns.
|
|
69
|
+
Object.defineProperty(editor, "onSubmit", {
|
|
70
|
+
configurable: true,
|
|
71
|
+
get: () => (text: string) => {
|
|
72
|
+
if (!text.trim() && state.activeWaitSkips.size > 0) {
|
|
73
|
+
for (const skip of [...state.activeWaitSkips]) skip();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
onSubmit?.call(editor, text);
|
|
77
|
+
},
|
|
78
|
+
set: (handler: typeof editor.onSubmit) => { onSubmit = handler; },
|
|
79
|
+
});
|
|
80
|
+
return editor;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
38
84
|
export function formatDuration(ms: number): string {
|
|
39
85
|
const totalSecs = Math.max(0, Math.ceil(ms / 1_000));
|
|
40
86
|
const hours = Math.floor(totalSecs / 3_600);
|
|
@@ -452,10 +498,9 @@ export function waitForRetry(
|
|
|
452
498
|
return;
|
|
453
499
|
}
|
|
454
500
|
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
// channel call this skip function directly instead.
|
|
501
|
+
// The session editor's empty-submit handler, model changes, and the
|
|
502
|
+
// out-of-band control channel share this skip function. No raw input hook:
|
|
503
|
+
// it would steal Enter from whichever picker currently owns focus.
|
|
459
504
|
state.activeWaitSkips.add(skipWait);
|
|
460
505
|
|
|
461
506
|
signal?.addEventListener("abort", onAbort);
|