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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-limits-wait",
3
- "version": "0.5.3",
3
+ "version": "0.5.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": {
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(entry: FallbackModel, signal?: AbortSignal): Promise<void> {
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)) return;
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 isAbortErrorEvent(event: AssistantMessageEvent, signal?: AbortSignal): boolean {
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 (isAbortErrorEvent(event, signal)) {
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 switchPiModel(attempt, signal);
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
- err = responseError;
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 aborted = Boolean(signal?.aborted) || isAbortMessage(errMsg);
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) {