oira666_pi-limits-wait 0.4.8 → 0.4.10
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 +8 -0
- package/package.json +1 -1
- package/src/index.ts +232 -92
package/README.md
CHANGED
|
@@ -19,6 +19,8 @@ Restart Pi or run `/reload` after installing.
|
|
|
19
19
|
- Uses provider retry timing when available (`retry-after`, `retry-after-ms`, `retry in ...`, reset messages).
|
|
20
20
|
- If no retry timing is available for a rate limit, waits 30 minutes before retrying.
|
|
21
21
|
- 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.
|
|
22
|
+
- 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.
|
|
23
|
+
- For any other (unclassified) non-retryable error, retries the same model a few times (default 3) before falling back / freezing it, so a one-off hiccup does not immediately sideline a model.
|
|
22
24
|
- Shows a countdown in the Pi status/working line.
|
|
23
25
|
- Press Enter during the countdown to skip the wait and retry immediately.
|
|
24
26
|
- Optionally falls back to configured models when the current/default model is rate-limited.
|
|
@@ -74,6 +76,12 @@ Fallback behavior:
|
|
|
74
76
|
|
|
75
77
|
When the settings are loaded, the extension shows the full usable fallback model list. When models become rate-limited, it shows a live countdown for each limited model.
|
|
76
78
|
|
|
79
|
+
## Environment variables
|
|
80
|
+
|
|
81
|
+
| Variable | Default | Description |
|
|
82
|
+
|----------|---------|-------------|
|
|
83
|
+
| `PI_LIMITS_WAIT_FREEZING_ENABLED` | `true` | When a model keeps failing with a non-retryable error, it is normally "frozen" for 1 hour 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. |
|
|
84
|
+
|
|
77
85
|
## License
|
|
78
86
|
|
|
79
87
|
MIT
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -31,15 +31,22 @@ const PI_IDENTITY_SENTENCE_PATTERN =
|
|
|
31
31
|
|
|
32
32
|
const DEFAULT_RATE_LIMIT_WAIT_MS = 30 * 60 * 1_000; // 30 minutes
|
|
33
33
|
const DEFAULT_OVERLOADED_WAIT_MS = 5 * 60 * 1_000; // 5 minutes
|
|
34
|
-
const
|
|
34
|
+
const DEFAULT_NETWORK_WAIT_MS = 15 * 1_000; // 15 seconds
|
|
35
|
+
const DEFAULT_NON_RETRYABLE_FREEZE_MS = 10 * 60 * 1_000; // 1 hour
|
|
36
|
+
/** How many times a non-retryable error is retried before we freeze/fallback. */
|
|
37
|
+
const NON_RETRYABLE_MAX_ATTEMPTS = 3;
|
|
38
|
+
/** Short backoff between non-retryable retries. */
|
|
39
|
+
const NON_RETRYABLE_RETRY_DELAY_MS = 5 * 1_000; // 5 seconds
|
|
35
40
|
const SETTINGS_FILE_NAME = "limits-wait.json";
|
|
36
41
|
const FALLBACK_MODELS_KEY = "fallback-models";
|
|
42
|
+
/** Env var to toggle the 1-hour model-freeze behaviour. Default: enabled. */
|
|
43
|
+
const FREEZING_ENV_VAR = "PI_LIMITS_WAIT_FREEZING_ENABLED";
|
|
37
44
|
|
|
38
45
|
/** Key used with ctx.ui.setStatus() for the countdown line. */
|
|
39
46
|
const STATUS_KEY = "limits-wait";
|
|
40
47
|
const MODELS_STATUS_KEY = "limits-wait-models";
|
|
41
48
|
|
|
42
|
-
type RetryReason = "rate-limit" | "overloaded" | "authentication" | "model-frozen";
|
|
49
|
+
type RetryReason = "rate-limit" | "overloaded" | "authentication" | "model-frozen" | "network" | "retry";
|
|
43
50
|
|
|
44
51
|
type RetryableError = {
|
|
45
52
|
reason: RetryReason;
|
|
@@ -78,10 +85,12 @@ let ambientStatusCleanup: (() => void) | undefined;
|
|
|
78
85
|
let modelStatusCleanup: (() => void) | undefined;
|
|
79
86
|
let activeProviderRequests = 0;
|
|
80
87
|
let activeFallbackProviderRequests = 0;
|
|
81
|
-
let
|
|
88
|
+
let lastObservedHttpError: { at: number; message: string } | undefined;
|
|
82
89
|
const wrappedApis = new Set<Api>();
|
|
83
90
|
const builtinStreamSimpleByApi = new Map<Api, StreamSimpleFn>();
|
|
84
91
|
let fallbackModels: FallbackModel[] = [];
|
|
92
|
+
let nonRetryableMaxAttempts = NON_RETRYABLE_MAX_ATTEMPTS;
|
|
93
|
+
let nonRetryableRetryDelayMs = NON_RETRYABLE_RETRY_DELAY_MS;
|
|
85
94
|
let primaryModel: Model<Api> | undefined;
|
|
86
95
|
let primaryThinkingLevel: ThinkingLevel | undefined;
|
|
87
96
|
let suppressNextModelSelect = false;
|
|
@@ -346,11 +355,15 @@ function ensureRateLimitedModelsStatus(): void {
|
|
|
346
355
|
};
|
|
347
356
|
}
|
|
348
357
|
|
|
358
|
+
function notifyRetryableError(model: Model<Api>, retryable: RetryableError, errorMessage?: string): void {
|
|
359
|
+
const detail = errorMessage ? ` Error: ${formatErrorDetail(errorMessage)}` : "";
|
|
360
|
+
sharedCtx?.ui.notify(`${formatModel(model)} ${reasonLabel(retryable.reason).toLowerCase()} for ${formatDuration(retryable.waitMs)}.${detail}`, "warning");
|
|
361
|
+
}
|
|
362
|
+
|
|
349
363
|
function rememberRateLimit(model: Model<Api>, retryable: RetryableError, errorMessage?: string): void {
|
|
350
364
|
const deadline = Date.now() + retryable.waitMs;
|
|
351
365
|
rateLimitMemory.set(modelKey(model), { reason: retryable.reason, limitedAt: Date.now(), deadline });
|
|
352
|
-
|
|
353
|
-
sharedCtx?.ui.notify(`${formatModel(model)} ${reasonLabel(retryable.reason).toLowerCase()} for ${formatDuration(retryable.waitMs)}.${detail}`, "warning");
|
|
366
|
+
notifyRetryableError(model, retryable, errorMessage);
|
|
354
367
|
ensureRateLimitedModelsStatus();
|
|
355
368
|
}
|
|
356
369
|
|
|
@@ -423,10 +436,21 @@ function isAbortErrorEvent(event: AssistantMessageEvent, signal?: AbortSignal):
|
|
|
423
436
|
return reason === "aborted" || stopReason === "aborted" || isAbortMessage(message);
|
|
424
437
|
}
|
|
425
438
|
|
|
439
|
+
function recentObservedHttpError(maxAgeMs = 60_000): string | undefined {
|
|
440
|
+
if (!lastObservedHttpError) return undefined;
|
|
441
|
+
if (Date.now() - lastObservedHttpError.at > maxAgeMs) return undefined;
|
|
442
|
+
return lastObservedHttpError.message;
|
|
443
|
+
}
|
|
444
|
+
|
|
426
445
|
function recentObservedRateLimitError(maxAgeMs = 60_000): string | undefined {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
446
|
+
const message = recentObservedHttpError(maxAgeMs);
|
|
447
|
+
return message && isRateLimitError(message) ? message : undefined;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function errorMessageWithRecentHttpStatus(errorMessage: string): string {
|
|
451
|
+
const observed = recentObservedHttpError();
|
|
452
|
+
if (!observed || /(?:^|\D)[1-5]\d\d(?:\D|$)/.test(errorMessage) || errorMessage.includes(observed)) return errorMessage;
|
|
453
|
+
return `${observed}; Error: ${errorMessage}`;
|
|
430
454
|
}
|
|
431
455
|
|
|
432
456
|
function earliestCandidateDeadline(current: Model<Api>): number | undefined {
|
|
@@ -481,7 +505,7 @@ async function switchPiModel(entry: FallbackModel): Promise<void> {
|
|
|
481
505
|
|
|
482
506
|
// ─── Retryable error detection ────────────────────────────────────────────────
|
|
483
507
|
|
|
484
|
-
|
|
508
|
+
function isExplicitRateLimitError(msg: string): boolean {
|
|
485
509
|
const lower = msg.toLowerCase();
|
|
486
510
|
return (
|
|
487
511
|
/(?:^|\D)429(?:\D|$)/.test(msg) ||
|
|
@@ -489,7 +513,14 @@ export function isRateLimitError(msg: string): boolean {
|
|
|
489
513
|
/rate\s*limit/.test(lower) ||
|
|
490
514
|
lower.includes("too many requests") ||
|
|
491
515
|
lower.includes("quota exceeded") ||
|
|
492
|
-
lower.includes("quota will reset")
|
|
516
|
+
lower.includes("quota will reset")
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function isRateLimitError(msg: string): boolean {
|
|
521
|
+
const lower = msg.toLowerCase();
|
|
522
|
+
return (
|
|
523
|
+
isExplicitRateLimitError(msg) ||
|
|
493
524
|
lower.includes("retry delay") ||
|
|
494
525
|
lower.includes("retry-after")
|
|
495
526
|
);
|
|
@@ -504,6 +535,40 @@ export function isServerOverloadedError(msg: string): boolean {
|
|
|
504
535
|
);
|
|
505
536
|
}
|
|
506
537
|
|
|
538
|
+
/**
|
|
539
|
+
* Transient network / HTTP transport failures. These are NOT API rate-limit or
|
|
540
|
+
* overload responses, but they are still worth retrying with a short backoff
|
|
541
|
+
* instead of treating them as a hard non-retryable failure (which would freeze
|
|
542
|
+
* the model for an hour). Most importantly this covers undici's idle-timeout
|
|
543
|
+
* aborts (`UND_ERR_HEADERS_TIMEOUT` / `UND_ERR_BODY_TIMEOUT`) that fire when a
|
|
544
|
+
* streaming request stalls without a response.
|
|
545
|
+
*/
|
|
546
|
+
export function isTransientNetworkError(msg: string): boolean {
|
|
547
|
+
const lower = msg.toLowerCase();
|
|
548
|
+
return (
|
|
549
|
+
lower.includes("und_err_headers_timeout") ||
|
|
550
|
+
lower.includes("und_err_body_timeout") ||
|
|
551
|
+
lower.includes("und_err_connect_timeout") ||
|
|
552
|
+
lower.includes("und_err_socket") ||
|
|
553
|
+
lower.includes("headers timeout") ||
|
|
554
|
+
lower.includes("body timeout") ||
|
|
555
|
+
lower.includes("connect timeout") ||
|
|
556
|
+
lower.includes("fetch failed") ||
|
|
557
|
+
lower.includes("terminated") ||
|
|
558
|
+
lower.includes("socket hang up") ||
|
|
559
|
+
lower.includes("other side closed") ||
|
|
560
|
+
lower.includes("network_error") ||
|
|
561
|
+
lower.includes("network error") ||
|
|
562
|
+
lower.includes("econnreset") ||
|
|
563
|
+
lower.includes("econnrefused") ||
|
|
564
|
+
lower.includes("etimedout") ||
|
|
565
|
+
lower.includes("enetunreach") ||
|
|
566
|
+
lower.includes("ehostunreach") ||
|
|
567
|
+
lower.includes("eai_again") ||
|
|
568
|
+
lower.includes("enotfound")
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
507
572
|
export function isAuthenticationRefreshError(msg: string): boolean {
|
|
508
573
|
const lower = msg.toLowerCase();
|
|
509
574
|
return (
|
|
@@ -586,9 +651,15 @@ export function getRetryableError(msg: string): RetryableError | undefined {
|
|
|
586
651
|
if (isAuthenticationRefreshError(msg)) {
|
|
587
652
|
return { reason: "authentication", waitMs: parseRetryDelayMs(msg) ?? DEFAULT_RATE_LIMIT_WAIT_MS };
|
|
588
653
|
}
|
|
654
|
+
if (isTransientNetworkError(msg) && !isExplicitRateLimitError(msg)) {
|
|
655
|
+
return { reason: "network", waitMs: parseRetryDelayMs(msg) ?? DEFAULT_NETWORK_WAIT_MS };
|
|
656
|
+
}
|
|
589
657
|
if (isRateLimitError(msg)) {
|
|
590
658
|
return { reason: "rate-limit", waitMs: rateLimitWaitMs(msg) };
|
|
591
659
|
}
|
|
660
|
+
if (isTransientNetworkError(msg)) {
|
|
661
|
+
return { reason: "network", waitMs: parseRetryDelayMs(msg) ?? DEFAULT_NETWORK_WAIT_MS };
|
|
662
|
+
}
|
|
592
663
|
return undefined;
|
|
593
664
|
}
|
|
594
665
|
|
|
@@ -598,9 +669,23 @@ function reasonLabel(reason: RetryReason): string {
|
|
|
598
669
|
if (reason === "overloaded") return "Server overloaded";
|
|
599
670
|
if (reason === "authentication") return "Authentication refresh pending";
|
|
600
671
|
if (reason === "model-frozen") return "Model frozen after error";
|
|
672
|
+
if (reason === "network") return "Network/timeout error";
|
|
673
|
+
if (reason === "retry") return "Retrying after error";
|
|
601
674
|
return "Rate limited";
|
|
602
675
|
}
|
|
603
676
|
|
|
677
|
+
/**
|
|
678
|
+
* Whether the 1-hour model-freeze behaviour is enabled. Controlled by the
|
|
679
|
+
* `PI_LIMITS_WAIT_FREEZING_ENABLED` env var (default enabled). Set it to
|
|
680
|
+
* `false`/`0`/`no`/`off` to disable freezing entirely.
|
|
681
|
+
*/
|
|
682
|
+
export function freezingEnabled(): boolean {
|
|
683
|
+
const raw = process.env[FREEZING_ENV_VAR];
|
|
684
|
+
if (raw === undefined) return true;
|
|
685
|
+
const value = raw.trim().toLowerCase();
|
|
686
|
+
return !(value === "false" || value === "0" || value === "no" || value === "off");
|
|
687
|
+
}
|
|
688
|
+
|
|
604
689
|
function statusText(reason: RetryReason, deadline: number, allowSkip: boolean): string {
|
|
605
690
|
const remaining = Math.max(0, deadline - Date.now());
|
|
606
691
|
const totalSecs = Math.ceil(remaining / 1_000);
|
|
@@ -692,14 +777,19 @@ export function waitForRetry(
|
|
|
692
777
|
};
|
|
693
778
|
|
|
694
779
|
tick();
|
|
780
|
+
// The interval only drives the visible 1s countdown; the actual wait is
|
|
781
|
+
// resolved precisely at the deadline by `timer` below (so a 200ms backoff
|
|
782
|
+
// resolves in ~200ms instead of being rounded up to the next 1s tick).
|
|
695
783
|
const ticker = setInterval(() => {
|
|
696
784
|
if (Date.now() >= deadline) { cleanup(); resolve("waited"); return; }
|
|
697
785
|
tick();
|
|
698
786
|
}, 1_000);
|
|
787
|
+
const timer = setTimeout(() => { if (!done) { cleanup(); resolve("waited"); } }, Math.max(0, deadline - Date.now()));
|
|
699
788
|
|
|
700
789
|
function cleanup() {
|
|
701
790
|
done = true;
|
|
702
791
|
clearInterval(ticker);
|
|
792
|
+
clearTimeout(timer);
|
|
703
793
|
signal?.removeEventListener("abort", onAbort);
|
|
704
794
|
unsubInput?.();
|
|
705
795
|
ctx?.ui.setStatus(STATUS_KEY, undefined);
|
|
@@ -739,13 +829,15 @@ function installFetchRateLimitObserver(): void {
|
|
|
739
829
|
const response = await originalFetch(...args);
|
|
740
830
|
|
|
741
831
|
if (activeProviderRequests > 0) {
|
|
742
|
-
if (response.
|
|
743
|
-
const waitMs = retryAfterHeaderMs(response.headers) ?? DEFAULT_RATE_LIMIT_WAIT_MS;
|
|
832
|
+
if (!response.ok) {
|
|
744
833
|
const message = await responseErrorMessage(response);
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
834
|
+
lastObservedHttpError = { at: Date.now(), message };
|
|
835
|
+
if (response.status === 429) {
|
|
836
|
+
const waitMs = retryAfterHeaderMs(response.headers) ?? DEFAULT_RATE_LIMIT_WAIT_MS;
|
|
837
|
+
showAmbientRetryStatus("rate-limit", waitMs);
|
|
838
|
+
if (activeFallbackProviderRequests > 0) {
|
|
839
|
+
throw new Error(message);
|
|
840
|
+
}
|
|
749
841
|
}
|
|
750
842
|
} else if (ambientStatusCleanup && response.ok) {
|
|
751
843
|
clearAmbientRetryStatus();
|
|
@@ -769,6 +861,11 @@ type StreamSimpleFn = (
|
|
|
769
861
|
options?: SimpleStreamOptions,
|
|
770
862
|
) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
|
|
771
863
|
|
|
864
|
+
export function __setNonRetryableTuningForTests(maxAttempts: number, retryDelayMs: number): void {
|
|
865
|
+
nonRetryableMaxAttempts = maxAttempts;
|
|
866
|
+
nonRetryableRetryDelayMs = retryDelayMs;
|
|
867
|
+
}
|
|
868
|
+
|
|
772
869
|
export function __configureFallbackModelsForTests(
|
|
773
870
|
models: FallbackModel[],
|
|
774
871
|
ctx?: ExtensionContext,
|
|
@@ -779,7 +876,7 @@ export function __configureFallbackModelsForTests(
|
|
|
779
876
|
primaryThinkingLevel = undefined;
|
|
780
877
|
rateLimitMemory.clear();
|
|
781
878
|
nonRetryableFailureMemory.clear();
|
|
782
|
-
|
|
879
|
+
lastObservedHttpError = undefined;
|
|
783
880
|
ambientStatusCleanup?.();
|
|
784
881
|
modelStatusCleanup?.();
|
|
785
882
|
}
|
|
@@ -821,6 +918,11 @@ export function streamWithLimitsRetry(
|
|
|
821
918
|
let committed = false;
|
|
822
919
|
const allowFallback = fallbackEnabled() && isFallbackEligibleModel(model);
|
|
823
920
|
let attempt: FallbackModel = allowFallback ? initialAttempt(model) : { model, reasoningEffort: primaryThinkingLevel };
|
|
921
|
+
// Per-stream bookkeeping: how many times each model has hit a non-retryable
|
|
922
|
+
// error, and which models we've exhausted (used to advance fallbacks when
|
|
923
|
+
// freezing is disabled).
|
|
924
|
+
const nonRetryableAttempts = new Map<string, number>();
|
|
925
|
+
const triedModels = new Set<string>();
|
|
824
926
|
|
|
825
927
|
const flush = (buffer: AssistantMessageEvent[]) => {
|
|
826
928
|
for (const event of buffer) output.push(event);
|
|
@@ -850,7 +952,8 @@ export function streamWithLimitsRetry(
|
|
|
850
952
|
for await (const event of inner) {
|
|
851
953
|
if (!committed) {
|
|
852
954
|
if (event.type === "error") {
|
|
853
|
-
const
|
|
955
|
+
const rawErrMsg = event.error.errorMessage ?? "";
|
|
956
|
+
const errMsg = errorMessageWithRecentHttpStatus(rawErrMsg);
|
|
854
957
|
|
|
855
958
|
if (isAbortErrorEvent(event, options?.signal)) {
|
|
856
959
|
if (buffer.length > 0) {
|
|
@@ -879,20 +982,11 @@ export function streamWithLimitsRetry(
|
|
|
879
982
|
break;
|
|
880
983
|
}
|
|
881
984
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
if (buffer.length > 0) {
|
|
888
|
-
flush(buffer);
|
|
889
|
-
} else {
|
|
890
|
-
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
891
|
-
committed = true;
|
|
892
|
-
}
|
|
893
|
-
output.push(event);
|
|
894
|
-
output.end();
|
|
895
|
-
return;
|
|
985
|
+
// Defer handling to the post-loop block, which applies the
|
|
986
|
+
// bounded non-retryable retry / fallback / freeze logic. This
|
|
987
|
+
// runs whether or not fallbacks are configured.
|
|
988
|
+
nonRetryableError = { message: errMsg, event };
|
|
989
|
+
break;
|
|
896
990
|
}
|
|
897
991
|
|
|
898
992
|
if (event.type === "start") {
|
|
@@ -924,7 +1018,7 @@ export function streamWithLimitsRetry(
|
|
|
924
1018
|
}
|
|
925
1019
|
}
|
|
926
1020
|
} catch (err) {
|
|
927
|
-
const errMsg = errorMessageWithCauses(err);
|
|
1021
|
+
const errMsg = errorMessageWithRecentHttpStatus(errorMessageWithCauses(err));
|
|
928
1022
|
const aborted = Boolean(options?.signal?.aborted) || isAbortMessage(errMsg);
|
|
929
1023
|
retryable = !aborted ? getRetryableError(errMsg) : undefined;
|
|
930
1024
|
if (retryable) retryableErrorMessage = errMsg;
|
|
@@ -934,25 +1028,21 @@ export function streamWithLimitsRetry(
|
|
|
934
1028
|
if (retryable && observedRateLimit) retryableErrorMessage = observedRateLimit;
|
|
935
1029
|
}
|
|
936
1030
|
if (!retryable) {
|
|
937
|
-
if (
|
|
938
|
-
nonRetryableError = { message: errMsg };
|
|
939
|
-
} else {
|
|
1031
|
+
if (aborted) {
|
|
940
1032
|
if (!committed) {
|
|
941
1033
|
// Synthetic start+error so the stream protocol stays valid.
|
|
942
1034
|
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
943
1035
|
committed = true;
|
|
944
1036
|
}
|
|
945
1037
|
const error = freshMessage(attempt.model);
|
|
946
|
-
error.stopReason =
|
|
1038
|
+
error.stopReason = "aborted";
|
|
947
1039
|
error.errorMessage = errMsg;
|
|
948
|
-
output.push({
|
|
949
|
-
type: "error",
|
|
950
|
-
reason: error.stopReason as "error" | "aborted",
|
|
951
|
-
error,
|
|
952
|
-
});
|
|
1040
|
+
output.push({ type: "error", reason: "aborted", error });
|
|
953
1041
|
output.end();
|
|
954
1042
|
return;
|
|
955
1043
|
}
|
|
1044
|
+
// Defer to the post-loop block for bounded retries / fallback.
|
|
1045
|
+
nonRetryableError = { message: errMsg };
|
|
956
1046
|
}
|
|
957
1047
|
}
|
|
958
1048
|
} finally {
|
|
@@ -964,72 +1054,122 @@ export function streamWithLimitsRetry(
|
|
|
964
1054
|
}
|
|
965
1055
|
}
|
|
966
1056
|
|
|
967
|
-
if (
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
attempt = next;
|
|
973
|
-
continue;
|
|
974
|
-
}
|
|
1057
|
+
if (!retryable) {
|
|
1058
|
+
// Both explicit non-retryable error events/exceptions and a provider
|
|
1059
|
+
// stream that ended without a terminal event land here.
|
|
1060
|
+
const failureEvent = nonRetryableError?.event;
|
|
1061
|
+
const failureMessage = nonRetryableError?.message ?? "Provider stream ended without a terminal event.";
|
|
975
1062
|
|
|
976
|
-
|
|
977
|
-
if (
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1063
|
+
const emitFailure = () => {
|
|
1064
|
+
if (!committed) {
|
|
1065
|
+
if (buffer.length > 0) {
|
|
1066
|
+
flush(buffer);
|
|
1067
|
+
} else {
|
|
1068
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
1069
|
+
committed = true;
|
|
982
1070
|
}
|
|
983
1071
|
}
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
if (!committed) {
|
|
987
|
-
if (buffer.length > 0) {
|
|
988
|
-
flush(buffer);
|
|
1072
|
+
if (failureEvent) {
|
|
1073
|
+
output.push(failureEvent);
|
|
989
1074
|
} else {
|
|
1075
|
+
const error = freshMessage(attempt.model);
|
|
1076
|
+
error.stopReason = "error";
|
|
1077
|
+
error.errorMessage = failureMessage;
|
|
1078
|
+
output.push({ type: "error", reason: "error", error });
|
|
1079
|
+
}
|
|
1080
|
+
output.end();
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
const emitAborted = () => {
|
|
1084
|
+
if (!committed) {
|
|
990
1085
|
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
991
1086
|
committed = true;
|
|
992
1087
|
}
|
|
993
|
-
}
|
|
994
|
-
if (nonRetryableError.event) {
|
|
995
|
-
output.push(nonRetryableError.event);
|
|
996
|
-
} else {
|
|
997
1088
|
const error = freshMessage(attempt.model);
|
|
998
|
-
error.stopReason = "
|
|
999
|
-
error.errorMessage =
|
|
1000
|
-
output.push({ type: "error", reason: "
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
return;
|
|
1004
|
-
}
|
|
1089
|
+
error.stopReason = "aborted";
|
|
1090
|
+
error.errorMessage = "Request aborted while retrying after error.";
|
|
1091
|
+
output.push({ type: "error", reason: "aborted", error });
|
|
1092
|
+
output.end();
|
|
1093
|
+
};
|
|
1005
1094
|
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
const next = nextAvailableCandidate(attempt.model);
|
|
1011
|
-
if (next) {
|
|
1012
|
-
attempt = next;
|
|
1013
|
-
continue;
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1095
|
+
// Nothing has been streamed yet: give the same model a few quick retries
|
|
1096
|
+
// before we freeze it or fall back. This rides out transient failures
|
|
1097
|
+
// that aren't recognised as retryable (stalls, odd 5xx, malformed
|
|
1098
|
+
// errors) instead of immediately freezing the model for an hour.
|
|
1016
1099
|
if (!committed) {
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1100
|
+
const key = modelKey(attempt.model);
|
|
1101
|
+
const attempts = (nonRetryableAttempts.get(key) ?? 0) + 1;
|
|
1102
|
+
nonRetryableAttempts.set(key, attempts);
|
|
1103
|
+
|
|
1104
|
+
if (attempts < nonRetryableMaxAttempts) {
|
|
1105
|
+
const waitResult = await waitForRetry("retry", nonRetryableRetryDelayMs, options?.signal);
|
|
1106
|
+
if (waitResult === "aborted") {
|
|
1107
|
+
emitAborted();
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
continue; // retry the same model
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Retries for this model are exhausted.
|
|
1114
|
+
triedModels.add(key);
|
|
1115
|
+
|
|
1116
|
+
if (allowFallback) {
|
|
1117
|
+
if (freezingEnabled()) {
|
|
1118
|
+
rememberNonRetryableFailure(attempt.model, failureMessage);
|
|
1119
|
+
const next = nextAvailableCandidate(attempt.model);
|
|
1120
|
+
if (next) {
|
|
1121
|
+
attempt = next;
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const deadline = earliestCandidateDeadline(attempt.model);
|
|
1126
|
+
if (deadline) {
|
|
1127
|
+
const waitResult = await waitForRetry("model-frozen", Math.max(0, deadline - Date.now()), options?.signal);
|
|
1128
|
+
if (waitResult !== "aborted") {
|
|
1129
|
+
attempt = nextAvailableCandidate(attempt.model) ?? { model: getPrimaryModel(attempt.model), reasoningEffort: primaryThinkingLevel };
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
} else {
|
|
1134
|
+
// Freezing disabled: walk through the remaining configured
|
|
1135
|
+
// candidates (without freezing) and only give up once they are
|
|
1136
|
+
// all exhausted. Never block on a long "model-frozen" wait.
|
|
1137
|
+
const next = candidateOrder(attempt.model).find((entry) => !triedModels.has(modelKey(entry.model)));
|
|
1138
|
+
if (next) {
|
|
1139
|
+
attempt = next;
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1022
1143
|
}
|
|
1023
1144
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
error.errorMessage = "Provider stream ended without a terminal event.";
|
|
1027
|
-
output.push({ type: "error", reason: "error", error });
|
|
1028
|
-
output.end();
|
|
1145
|
+
|
|
1146
|
+
emitFailure();
|
|
1029
1147
|
return;
|
|
1030
1148
|
}
|
|
1031
1149
|
|
|
1032
1150
|
if (allowFallback) {
|
|
1151
|
+
// Network/transport failures usually mean the user's connection dropped
|
|
1152
|
+
// or a socket timed out. Do not mark the model as unavailable, switch
|
|
1153
|
+
// to another model, or enter model-freeze handling; keep retrying the
|
|
1154
|
+
// same model with the short network backoff until it works again.
|
|
1155
|
+
if (retryable.reason === "network") {
|
|
1156
|
+
notifyRetryableError(attempt.model, retryable, retryableErrorMessage);
|
|
1157
|
+
const waitResult = await waitForRetry(retryable.reason, retryable.waitMs, options?.signal);
|
|
1158
|
+
if (waitResult === "aborted") {
|
|
1159
|
+
if (!committed) {
|
|
1160
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
1161
|
+
committed = true;
|
|
1162
|
+
}
|
|
1163
|
+
const error = freshMessage(attempt.model);
|
|
1164
|
+
error.stopReason = "aborted";
|
|
1165
|
+
error.errorMessage = `Request aborted during ${reasonLabel(retryable.reason).toLowerCase()} wait.`;
|
|
1166
|
+
output.push({ type: "error", reason: "aborted", error });
|
|
1167
|
+
output.end();
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1033
1173
|
rememberRateLimit(attempt.model, retryable, retryableErrorMessage);
|
|
1034
1174
|
const next = nextAvailableCandidate(attempt.model);
|
|
1035
1175
|
if (next) {
|