oira666_pi-limits-wait 0.4.4 → 0.4.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/package.json +1 -1
- package/src/index.ts +174 -45
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -59,6 +59,12 @@ type RateLimitMemory = {
|
|
|
59
59
|
deadline: number;
|
|
60
60
|
};
|
|
61
61
|
|
|
62
|
+
type NonRetryableFailureMemory = {
|
|
63
|
+
failedAt: number;
|
|
64
|
+
deadline: number;
|
|
65
|
+
errorMessage: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
62
68
|
// ─── Shared UI context ────────────────────────────────────────────────────────
|
|
63
69
|
|
|
64
70
|
let sharedCtx: ExtensionContext | undefined;
|
|
@@ -67,6 +73,8 @@ let restoreFetch: (() => void) | undefined;
|
|
|
67
73
|
let ambientStatusCleanup: (() => void) | undefined;
|
|
68
74
|
let modelStatusCleanup: (() => void) | undefined;
|
|
69
75
|
let activeProviderRequests = 0;
|
|
76
|
+
let activeFallbackProviderRequests = 0;
|
|
77
|
+
let lastObservedRateLimitError: { at: number; message: string } | undefined;
|
|
70
78
|
const wrappedApis = new Set<Api>();
|
|
71
79
|
const builtinStreamSimpleByApi = new Map<Api, StreamSimpleFn>();
|
|
72
80
|
let fallbackModels: FallbackModel[] = [];
|
|
@@ -75,7 +83,7 @@ let primaryThinkingLevel: ThinkingLevel | undefined;
|
|
|
75
83
|
let suppressNextModelSelect = false;
|
|
76
84
|
let settingsSignature: string | undefined;
|
|
77
85
|
const rateLimitMemory = new Map<string, RateLimitMemory>();
|
|
78
|
-
const nonRetryableFailureMemory = new Map<string,
|
|
86
|
+
const nonRetryableFailureMemory = new Map<string, NonRetryableFailureMemory>();
|
|
79
87
|
|
|
80
88
|
// ─── Anthropic subscription prompt sanitisation ──────────────────────────────
|
|
81
89
|
|
|
@@ -250,27 +258,31 @@ function formatDuration(ms: number): string {
|
|
|
250
258
|
function updateRateLimitedModelsStatus(): void {
|
|
251
259
|
const ctx = sharedCtx;
|
|
252
260
|
if (!ctx) return;
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
261
|
+
try {
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
const lines: string[] = [];
|
|
264
|
+
for (const [key, entry] of rateLimitMemory) {
|
|
265
|
+
if (now >= entry.deadline) {
|
|
266
|
+
rateLimitMemory.delete(key);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
lines.push(`${key}: ${formatDuration(entry.deadline - now)}`);
|
|
259
270
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
271
|
+
for (const [key, entry] of nonRetryableFailureMemory) {
|
|
272
|
+
if (now >= entry.deadline) {
|
|
273
|
+
nonRetryableFailureMemory.delete(key);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
lines.push(`${key}: frozen ${formatDuration(entry.deadline - now)}`);
|
|
266
277
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
278
|
+
if (lines.length === 0) {
|
|
279
|
+
modelStatusCleanup?.();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
ctx.ui.setStatus(MODELS_STATUS_KEY, `🚦 Limited: ${lines.join(" | ")}`);
|
|
283
|
+
} catch {
|
|
270
284
|
modelStatusCleanup?.();
|
|
271
|
-
return;
|
|
272
285
|
}
|
|
273
|
-
ctx.ui.setStatus(MODELS_STATUS_KEY, `🚦 Limited: ${lines.join(" | ")}`);
|
|
274
286
|
}
|
|
275
287
|
|
|
276
288
|
function ensureRateLimitedModelsStatus(): void {
|
|
@@ -280,18 +292,23 @@ function ensureRateLimitedModelsStatus(): void {
|
|
|
280
292
|
modelStatusCleanup = () => {
|
|
281
293
|
clearInterval(ticker);
|
|
282
294
|
modelStatusCleanup = undefined;
|
|
283
|
-
|
|
295
|
+
try {
|
|
296
|
+
sharedCtx?.ui.setStatus(MODELS_STATUS_KEY, undefined);
|
|
297
|
+
} catch {
|
|
298
|
+
// The extension context can become stale after print-mode session teardown.
|
|
299
|
+
}
|
|
284
300
|
};
|
|
285
301
|
}
|
|
286
302
|
|
|
287
|
-
function rememberRateLimit(model: Model<Api>, retryable: RetryableError): void {
|
|
303
|
+
function rememberRateLimit(model: Model<Api>, retryable: RetryableError, errorMessage?: string): void {
|
|
288
304
|
const deadline = Date.now() + retryable.waitMs;
|
|
289
305
|
rateLimitMemory.set(modelKey(model), { reason: retryable.reason, limitedAt: Date.now(), deadline });
|
|
290
|
-
|
|
306
|
+
const detail = errorMessage ? ` Error: ${formatErrorDetail(errorMessage)}` : "";
|
|
307
|
+
sharedCtx?.ui.notify(`${formatModel(model)} ${reasonLabel(retryable.reason).toLowerCase()} for ${formatDuration(retryable.waitMs)}.${detail}`, "warning");
|
|
291
308
|
ensureRateLimitedModelsStatus();
|
|
292
309
|
}
|
|
293
310
|
|
|
294
|
-
function activeNonRetryableFailure(model: Model<Api>):
|
|
311
|
+
function activeNonRetryableFailure(model: Model<Api>): NonRetryableFailureMemory | undefined {
|
|
295
312
|
const entry = nonRetryableFailureMemory.get(modelKey(model));
|
|
296
313
|
if (!entry) return undefined;
|
|
297
314
|
if (Date.now() >= entry.deadline) {
|
|
@@ -309,13 +326,63 @@ function nextAvailableCandidate(current: Model<Api>): FallbackModel | undefined
|
|
|
309
326
|
return candidateOrder(current).find((entry) => !activeLimit(entry.model) && !hasNonRetryableFailure(entry.model));
|
|
310
327
|
}
|
|
311
328
|
|
|
329
|
+
function formatErrorDetail(errorMessage: string, maxLength = 500): string {
|
|
330
|
+
const oneLine = errorMessage.replace(/\s+/g, " ").trim();
|
|
331
|
+
if (!oneLine) return "unknown error";
|
|
332
|
+
return oneLine.length > maxLength ? `${oneLine.slice(0, maxLength - 1)}…` : oneLine;
|
|
333
|
+
}
|
|
334
|
+
|
|
312
335
|
function rememberNonRetryableFailure(model: Model<Api>, errorMessage: string): void {
|
|
313
336
|
const deadline = Date.now() + DEFAULT_NON_RETRYABLE_FREEZE_MS;
|
|
314
337
|
nonRetryableFailureMemory.set(modelKey(model), { failedAt: Date.now(), deadline, errorMessage });
|
|
315
|
-
sharedCtx?.ui.notify(`${formatModel(model)} failed; freezing it for ${formatDuration(DEFAULT_NON_RETRYABLE_FREEZE_MS)} and trying another configured model if available.`, "warning");
|
|
338
|
+
sharedCtx?.ui.notify(`${formatModel(model)} failed (${formatErrorDetail(errorMessage)}); freezing it for ${formatDuration(DEFAULT_NON_RETRYABLE_FREEZE_MS)} and trying another configured model if available.`, "warning");
|
|
316
339
|
ensureRateLimitedModelsStatus();
|
|
317
340
|
}
|
|
318
341
|
|
|
342
|
+
function errorMessageWithCauses(err: unknown): string {
|
|
343
|
+
const messages: string[] = [];
|
|
344
|
+
let current: unknown = err;
|
|
345
|
+
const seen = new Set<unknown>();
|
|
346
|
+
|
|
347
|
+
while (current && !seen.has(current)) {
|
|
348
|
+
seen.add(current);
|
|
349
|
+
if (current instanceof Error) {
|
|
350
|
+
if (current.message) messages.push(current.message);
|
|
351
|
+
current = (current as Error & { cause?: unknown }).cause;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
messages.push(String(current));
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return messages.filter(Boolean).join(" Cause: ") || String(err);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function isAbortMessage(msg: string): boolean {
|
|
362
|
+
const lower = msg.toLowerCase();
|
|
363
|
+
return (
|
|
364
|
+
lower.includes("operation aborted") ||
|
|
365
|
+
lower.includes("request aborted") ||
|
|
366
|
+
lower.includes("aborterror") ||
|
|
367
|
+
/\baborted\b/.test(lower)
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function isAbortErrorEvent(event: AssistantMessageEvent, signal?: AbortSignal): boolean {
|
|
372
|
+
if (signal?.aborted) return true;
|
|
373
|
+
if (event.type !== "error") return false;
|
|
374
|
+
const reason = "reason" in event ? String(event.reason) : "";
|
|
375
|
+
const stopReason = event.error.stopReason ? String(event.error.stopReason) : "";
|
|
376
|
+
const message = event.error.errorMessage ?? "";
|
|
377
|
+
return reason === "aborted" || stopReason === "aborted" || isAbortMessage(message);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function recentObservedRateLimitError(maxAgeMs = 60_000): string | undefined {
|
|
381
|
+
if (!lastObservedRateLimitError) return undefined;
|
|
382
|
+
if (Date.now() - lastObservedRateLimitError.at > maxAgeMs) return undefined;
|
|
383
|
+
return lastObservedRateLimitError.message;
|
|
384
|
+
}
|
|
385
|
+
|
|
319
386
|
function earliestCandidateDeadline(current: Model<Api>): number | undefined {
|
|
320
387
|
const deadlines = candidateOrder(current)
|
|
321
388
|
.map((entry) => activeLimit(entry.model)?.deadline ?? activeNonRetryableFailure(entry.model)?.deadline)
|
|
@@ -605,6 +672,19 @@ export function waitForRateLimit(
|
|
|
605
672
|
|
|
606
673
|
// ─── Early 429 observer ──────────────────────────────────────────────────────
|
|
607
674
|
|
|
675
|
+
async function responseErrorMessage(response: Response): Promise<string> {
|
|
676
|
+
const retryAfterMs = retryAfterHeaderMs(response.headers);
|
|
677
|
+
const retryAfter = retryAfterMs !== undefined ? ` retry-after-ms ${retryAfterMs}` : "";
|
|
678
|
+
let body = "";
|
|
679
|
+
try {
|
|
680
|
+
body = await response.clone().text();
|
|
681
|
+
} catch {
|
|
682
|
+
// Ignore body read failures; status and retry headers are enough.
|
|
683
|
+
}
|
|
684
|
+
const detail = body.trim() ? ` ${formatErrorDetail(body)}` : "";
|
|
685
|
+
return `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}${retryAfter}${detail}`;
|
|
686
|
+
}
|
|
687
|
+
|
|
608
688
|
function installFetchRateLimitObserver(): void {
|
|
609
689
|
if (restoreFetch || typeof globalThis.fetch !== "function") return;
|
|
610
690
|
|
|
@@ -615,7 +695,12 @@ function installFetchRateLimitObserver(): void {
|
|
|
615
695
|
if (activeProviderRequests > 0) {
|
|
616
696
|
if (response.status === 429) {
|
|
617
697
|
const waitMs = retryAfterHeaderMs(response.headers) ?? DEFAULT_RATE_LIMIT_WAIT_MS;
|
|
698
|
+
const message = await responseErrorMessage(response);
|
|
699
|
+
lastObservedRateLimitError = { at: Date.now(), message };
|
|
618
700
|
showAmbientRetryStatus("rate-limit", waitMs);
|
|
701
|
+
if (activeFallbackProviderRequests > 0) {
|
|
702
|
+
throw new Error(message);
|
|
703
|
+
}
|
|
619
704
|
} else if (ambientStatusCleanup && response.ok) {
|
|
620
705
|
clearAmbientRetryStatus();
|
|
621
706
|
}
|
|
@@ -648,6 +733,7 @@ export function __configureFallbackModelsForTests(
|
|
|
648
733
|
primaryThinkingLevel = undefined;
|
|
649
734
|
rateLimitMemory.clear();
|
|
650
735
|
nonRetryableFailureMemory.clear();
|
|
736
|
+
lastObservedRateLimitError = undefined;
|
|
651
737
|
ambientStatusCleanup?.();
|
|
652
738
|
modelStatusCleanup?.();
|
|
653
739
|
}
|
|
@@ -697,9 +783,11 @@ export function streamWithLimitsRetry(
|
|
|
697
783
|
|
|
698
784
|
while (true) {
|
|
699
785
|
activeProviderRequests++;
|
|
786
|
+
if (allowFallback) activeFallbackProviderRequests++;
|
|
700
787
|
installFetchRateLimitObserver();
|
|
701
788
|
const buffer: AssistantMessageEvent[] = [];
|
|
702
789
|
let retryable: RetryableError | undefined;
|
|
790
|
+
let retryableErrorMessage = "";
|
|
703
791
|
let nonRetryableError: { message: string; event?: AssistantMessageEvent } | undefined;
|
|
704
792
|
|
|
705
793
|
try {
|
|
@@ -717,15 +805,39 @@ export function streamWithLimitsRetry(
|
|
|
717
805
|
if (!committed) {
|
|
718
806
|
if (event.type === "error") {
|
|
719
807
|
const errMsg = event.error.errorMessage ?? "";
|
|
720
|
-
|
|
721
|
-
if (
|
|
808
|
+
|
|
809
|
+
if (isAbortErrorEvent(event, options?.signal)) {
|
|
810
|
+
if (buffer.length > 0) {
|
|
811
|
+
flush(buffer);
|
|
812
|
+
} else {
|
|
813
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
814
|
+
committed = true;
|
|
815
|
+
}
|
|
816
|
+
const error = freshMessage(attempt.model);
|
|
817
|
+
error.stopReason = "aborted";
|
|
818
|
+
error.errorMessage = errMsg || "Operation aborted";
|
|
819
|
+
output.push({ type: "error", reason: "aborted", error });
|
|
820
|
+
output.end();
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
retryable = getRetryableError(errMsg);
|
|
825
|
+
if (retryable) {
|
|
826
|
+
retryableErrorMessage = errMsg;
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
829
|
+
const observedRateLimit = allowFallback ? recentObservedRateLimitError() : undefined;
|
|
830
|
+
retryable = observedRateLimit ? getRetryableError(observedRateLimit) : undefined;
|
|
831
|
+
if (retryable && observedRateLimit) {
|
|
832
|
+
retryableErrorMessage = observedRateLimit;
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
722
835
|
|
|
723
836
|
if (allowFallback) {
|
|
724
837
|
nonRetryableError = { message: errMsg, event };
|
|
725
838
|
break;
|
|
726
839
|
}
|
|
727
840
|
|
|
728
|
-
// Non-retryable error without fallbacks: ensure start came first, then forward error.
|
|
729
841
|
if (buffer.length > 0) {
|
|
730
842
|
flush(buffer);
|
|
731
843
|
} else {
|
|
@@ -766,32 +878,40 @@ export function streamWithLimitsRetry(
|
|
|
766
878
|
}
|
|
767
879
|
}
|
|
768
880
|
} catch (err) {
|
|
769
|
-
const errMsg =
|
|
770
|
-
|
|
881
|
+
const errMsg = errorMessageWithCauses(err);
|
|
882
|
+
const aborted = Boolean(options?.signal?.aborted) || isAbortMessage(errMsg);
|
|
883
|
+
retryable = !aborted ? getRetryableError(errMsg) : undefined;
|
|
884
|
+
if (retryable) retryableErrorMessage = errMsg;
|
|
885
|
+
if (!retryable && allowFallback) {
|
|
886
|
+
const observedRateLimit = recentObservedRateLimitError();
|
|
887
|
+
retryable = observedRateLimit ? getRetryableError(observedRateLimit) : undefined;
|
|
888
|
+
if (retryable && observedRateLimit) retryableErrorMessage = observedRateLimit;
|
|
889
|
+
}
|
|
771
890
|
if (!retryable) {
|
|
772
|
-
if (allowFallback && !
|
|
891
|
+
if (allowFallback && !aborted) {
|
|
773
892
|
nonRetryableError = { message: errMsg };
|
|
774
893
|
} else {
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
894
|
+
if (!committed) {
|
|
895
|
+
// Synthetic start+error so the stream protocol stays valid.
|
|
896
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
897
|
+
committed = true;
|
|
898
|
+
}
|
|
899
|
+
const error = freshMessage(attempt.model);
|
|
900
|
+
error.stopReason = aborted ? "aborted" : "error";
|
|
901
|
+
error.errorMessage = errMsg;
|
|
902
|
+
output.push({
|
|
903
|
+
type: "error",
|
|
904
|
+
reason: error.stopReason as "error" | "aborted",
|
|
905
|
+
error,
|
|
906
|
+
});
|
|
907
|
+
output.end();
|
|
908
|
+
return;
|
|
790
909
|
}
|
|
791
910
|
}
|
|
792
911
|
}
|
|
793
912
|
} finally {
|
|
794
913
|
activeProviderRequests--;
|
|
914
|
+
if (allowFallback) activeFallbackProviderRequests--;
|
|
795
915
|
if (activeProviderRequests === 0) {
|
|
796
916
|
clearAmbientRetryStatus();
|
|
797
917
|
restoreFetch?.();
|
|
@@ -838,6 +958,15 @@ export function streamWithLimitsRetry(
|
|
|
838
958
|
}
|
|
839
959
|
|
|
840
960
|
if (!retryable) {
|
|
961
|
+
if (allowFallback && !committed) {
|
|
962
|
+
const message = "Provider stream ended without a terminal event.";
|
|
963
|
+
rememberNonRetryableFailure(attempt.model, message);
|
|
964
|
+
const next = nextAvailableCandidate(attempt.model);
|
|
965
|
+
if (next) {
|
|
966
|
+
attempt = next;
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
841
970
|
if (!committed) {
|
|
842
971
|
if (buffer.length > 0) {
|
|
843
972
|
flush(buffer);
|
|
@@ -855,7 +984,7 @@ export function streamWithLimitsRetry(
|
|
|
855
984
|
}
|
|
856
985
|
|
|
857
986
|
if (allowFallback) {
|
|
858
|
-
rememberRateLimit(attempt.model, retryable);
|
|
987
|
+
rememberRateLimit(attempt.model, retryable, retryableErrorMessage);
|
|
859
988
|
const next = nextAvailableCandidate(attempt.model);
|
|
860
989
|
if (next) {
|
|
861
990
|
attempt = next;
|