oira666_pi-limits-wait 0.5.3 → 0.5.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 +1 -0
- package/package.json +1 -1
- package/src/models.ts +13 -2
- package/src/stream.ts +102 -13
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ Restart Pi or run `/reload` after installing.
|
|
|
22
22
|
- If no retry timing is available for a rate limit, waits 30 minutes before retrying.
|
|
23
23
|
- 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.
|
|
24
24
|
- 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.
|
|
25
|
+
- 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.
|
|
25
26
|
- 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.
|
|
26
27
|
- Reports retry/fallback waits in chat notifications without adding persistent TUI status lines.
|
|
27
28
|
- Press Enter during a retry wait to skip the wait and retry immediately.
|
package/package.json
CHANGED
package/src/models.ts
CHANGED
|
@@ -121,13 +121,22 @@ export function initialAttempt(model: Model<Api>): FallbackModel {
|
|
|
121
121
|
|
|
122
122
|
let modelSwitchQueue = Promise.resolve();
|
|
123
123
|
|
|
124
|
-
export async function switchPiModel(
|
|
124
|
+
export async function switchPiModel(
|
|
125
|
+
entry: FallbackModel,
|
|
126
|
+
signal?: AbortSignal,
|
|
127
|
+
canSelect?: (current: Model<Api> | undefined) => boolean,
|
|
128
|
+
): Promise<boolean> {
|
|
129
|
+
let selected = false;
|
|
125
130
|
const operation = modelSwitchQueue.then(async () => {
|
|
126
131
|
if (signal?.aborted) return;
|
|
127
132
|
const pi = state.extensionApi;
|
|
128
133
|
if (!pi) return;
|
|
129
134
|
const current = state.sharedCtx?.model;
|
|
130
|
-
if (current && modelKey(current) === modelKey(entry.model))
|
|
135
|
+
if (current && modelKey(current) === modelKey(entry.model)) {
|
|
136
|
+
selected = true;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (canSelect && !canSelect(current)) return;
|
|
131
140
|
if (signal?.aborted) return;
|
|
132
141
|
|
|
133
142
|
const cancelExpectedSelection = expectModelSelection(entry.model);
|
|
@@ -138,6 +147,7 @@ export async function switchPiModel(entry: FallbackModel, signal?: AbortSignal):
|
|
|
138
147
|
return;
|
|
139
148
|
}
|
|
140
149
|
if (signal?.aborted) return;
|
|
150
|
+
selected = true;
|
|
141
151
|
const level = entry.reasoningEffort ?? state.primaryThinkingLevel;
|
|
142
152
|
if (level) pi.setThinkingLevel(level);
|
|
143
153
|
if (!signal?.aborted) {
|
|
@@ -152,4 +162,5 @@ export async function switchPiModel(entry: FallbackModel, signal?: AbortSignal):
|
|
|
152
162
|
});
|
|
153
163
|
modelSwitchQueue = operation.catch(() => undefined);
|
|
154
164
|
await operation;
|
|
165
|
+
return selected;
|
|
155
166
|
}
|
package/src/stream.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ModelRuntime, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import {
|
|
3
3
|
createAssistantMessageEventStream,
|
|
4
|
+
isContextOverflow,
|
|
4
5
|
type Api,
|
|
5
6
|
type AssistantMessage,
|
|
6
7
|
type AssistantMessageEvent,
|
|
@@ -66,12 +67,10 @@ function freshMessage(model: Model<Api>): AssistantMessage {
|
|
|
66
67
|
};
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
function
|
|
70
|
+
function isExplicitAbortErrorEvent(event: AssistantMessageEvent, signal?: AbortSignal): boolean {
|
|
70
71
|
if (signal?.aborted) return true;
|
|
71
72
|
if (event.type !== "error") return false;
|
|
72
|
-
return event.reason === "aborted"
|
|
73
|
-
|| event.error.stopReason === "aborted"
|
|
74
|
-
|| isAbortMessage(event.error.errorMessage ?? "");
|
|
73
|
+
return event.reason === "aborted" || event.error.stopReason === "aborted";
|
|
75
74
|
}
|
|
76
75
|
|
|
77
76
|
function responseErrorMessage(response: ProviderResponse): string {
|
|
@@ -191,6 +190,21 @@ export function streamWithLimitsRetry(
|
|
|
191
190
|
const nonRetryableAttempts = new Map<string, number>();
|
|
192
191
|
const triedModels = new Set<string>();
|
|
193
192
|
|
|
193
|
+
const prepareAttemptForPi = async (): Promise<boolean> => {
|
|
194
|
+
const originalKey = modelKey(model);
|
|
195
|
+
const attemptKey = modelKey(attempt.model);
|
|
196
|
+
if (attemptKey === originalKey) return true;
|
|
197
|
+
const canSelect = (current: Model<Api> | undefined) => {
|
|
198
|
+
if (!current) return true;
|
|
199
|
+
const currentKey = modelKey(current);
|
|
200
|
+
// Never undo a newer, unrelated user model selection. Pi deliberately
|
|
201
|
+
// treats the resulting overflow as stale instead of compacting for it.
|
|
202
|
+
return currentKey === originalKey || currentKey === attemptKey;
|
|
203
|
+
};
|
|
204
|
+
if (!canSelect(state.sharedCtx?.model)) return false;
|
|
205
|
+
return switchPiModel(attempt, signal, canSelect);
|
|
206
|
+
};
|
|
207
|
+
|
|
194
208
|
const managedWait = async (reason: RetryReason, waitMs: number, error: string) =>
|
|
195
209
|
waitForRetry(reason, waitMs, signal, {
|
|
196
210
|
periodId: retryPeriod?.periodId,
|
|
@@ -329,14 +343,45 @@ export function streamWithLimitsRetry(
|
|
|
329
343
|
if (!committed) {
|
|
330
344
|
if (event.type === "error") {
|
|
331
345
|
const errMsg = enrichError(event.error.errorMessage ?? "", observedHttpError);
|
|
332
|
-
if (
|
|
346
|
+
if (isExplicitAbortErrorEvent(event, signal)) {
|
|
347
|
+
if (buffer.length > 0) flush(buffer);
|
|
348
|
+
pushAbort(errMsg || "Operation aborted");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const surfacedEvent = errMsg === (event.error.errorMessage ?? "")
|
|
352
|
+
? event
|
|
353
|
+
: { ...event, error: { ...event.error, errorMessage: errMsg } };
|
|
354
|
+
// Pi owns context-overflow recovery. Use Pi's canonical detector
|
|
355
|
+
// before this extension's message heuristics so mixed transport/
|
|
356
|
+
// abort wording cannot hide a real overflow from the host.
|
|
357
|
+
if (isContextOverflow(surfacedEvent.error, attempt.model.contextWindow)) {
|
|
358
|
+
// If this request had already moved to a fallback attempt,
|
|
359
|
+
// commit that attempted model when safe. Pi intentionally
|
|
360
|
+
// ignores overflow errors from a non-current model.
|
|
361
|
+
await prepareAttemptForPi();
|
|
362
|
+
if (signal?.aborted) {
|
|
363
|
+
pushAbort("Request aborted while switching model for context-overflow recovery.");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
retryPeriod?.finalize();
|
|
367
|
+
if (buffer.length > 0) flush(buffer);
|
|
368
|
+
else {
|
|
369
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
370
|
+
committed = true;
|
|
371
|
+
}
|
|
372
|
+
output.push(surfacedEvent);
|
|
373
|
+
finished = true;
|
|
374
|
+
output.end();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (isAbortMessage(errMsg)) {
|
|
333
378
|
if (buffer.length > 0) flush(buffer);
|
|
334
379
|
pushAbort(errMsg || "Operation aborted");
|
|
335
380
|
return;
|
|
336
381
|
}
|
|
337
382
|
retryable = getRetryableError(errMsg);
|
|
338
383
|
if (retryable) retryableErrorMessage = errMsg;
|
|
339
|
-
else nonRetryableError = { message: errMsg, event };
|
|
384
|
+
else nonRetryableError = { message: errMsg, event: surfacedEvent };
|
|
340
385
|
break;
|
|
341
386
|
}
|
|
342
387
|
|
|
@@ -350,7 +395,7 @@ export function streamWithLimitsRetry(
|
|
|
350
395
|
pushAbort("Request aborted before switching model.");
|
|
351
396
|
return;
|
|
352
397
|
}
|
|
353
|
-
await
|
|
398
|
+
await prepareAttemptForPi();
|
|
354
399
|
if (signal?.aborted) {
|
|
355
400
|
pushAbort("Request aborted while switching model.");
|
|
356
401
|
return;
|
|
@@ -381,17 +426,61 @@ export function streamWithLimitsRetry(
|
|
|
381
426
|
}
|
|
382
427
|
}
|
|
383
428
|
});
|
|
384
|
-
await deliverObservedResponses();
|
|
385
|
-
if (finished) return;
|
|
386
|
-
} catch (caught) {
|
|
387
|
-
let err = caught;
|
|
388
429
|
try {
|
|
389
430
|
await deliverObservedResponses();
|
|
390
431
|
} catch (responseError) {
|
|
391
|
-
|
|
432
|
+
// A terminal provider event has already been surfaced. Observer
|
|
433
|
+
// callback failures must not start a hidden retry behind that event.
|
|
434
|
+
if (finished) return;
|
|
435
|
+
throw responseError;
|
|
436
|
+
}
|
|
437
|
+
if (finished) return;
|
|
438
|
+
} catch (caught) {
|
|
439
|
+
let err = caught;
|
|
440
|
+
const caughtMessage = errorMessageWithCauses(caught);
|
|
441
|
+
const caughtAssistant = freshMessage(attempt.model);
|
|
442
|
+
caughtAssistant.stopReason = "error";
|
|
443
|
+
caughtAssistant.errorMessage = caughtMessage;
|
|
444
|
+
const caughtIsOverflow = isContextOverflow(caughtAssistant, attempt.model.contextWindow);
|
|
445
|
+
if (caughtIsOverflow) {
|
|
446
|
+
// Response callbacks are observational here. Never let a slow or
|
|
447
|
+
// failing callback delay a provider overflow from reaching Pi.
|
|
448
|
+
void deliverObservedResponses().catch(() => undefined);
|
|
449
|
+
} else {
|
|
450
|
+
try {
|
|
451
|
+
await deliverObservedResponses();
|
|
452
|
+
} catch (responseError) {
|
|
453
|
+
err = responseError;
|
|
454
|
+
}
|
|
392
455
|
}
|
|
393
456
|
const errMsg = enrichError(errorMessageWithCauses(err), observedHttpError ?? errorHttpMetadata(err));
|
|
394
|
-
const
|
|
457
|
+
const explicitlyAborted = Boolean(signal?.aborted);
|
|
458
|
+
const error = freshMessage(attempt.model);
|
|
459
|
+
error.stopReason = "error";
|
|
460
|
+
error.errorMessage = errMsg;
|
|
461
|
+
// Synchronous provider throws do not include an AssistantMessage event,
|
|
462
|
+
// so build the same terminal shape Pi receives and apply Pi's detector
|
|
463
|
+
// before classifying network or message-based abort heuristics.
|
|
464
|
+
if (!explicitlyAborted && isContextOverflow(error, attempt.model.contextWindow)) {
|
|
465
|
+
await prepareAttemptForPi();
|
|
466
|
+
if (signal?.aborted) {
|
|
467
|
+
pushAbort("Request aborted while switching model for context-overflow recovery.");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
retryPeriod?.finalize();
|
|
471
|
+
if (!committed) {
|
|
472
|
+
if (buffer.length > 0) flush(buffer);
|
|
473
|
+
else {
|
|
474
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
475
|
+
committed = true;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
output.push({ type: "error", reason: "error", error });
|
|
479
|
+
finished = true;
|
|
480
|
+
output.end();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const aborted = explicitlyAborted || isAbortMessage(errMsg);
|
|
395
484
|
retryable = aborted ? undefined : getRetryableError(errMsg);
|
|
396
485
|
if (retryable) retryableErrorMessage = errMsg;
|
|
397
486
|
else if (aborted) {
|