oira666_pi-limits-wait 0.4.5 → 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 +120 -27
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,38 @@ 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
|
+
|
|
319
361
|
function isAbortMessage(msg: string): boolean {
|
|
320
362
|
const lower = msg.toLowerCase();
|
|
321
363
|
return (
|
|
@@ -335,6 +377,12 @@ function isAbortErrorEvent(event: AssistantMessageEvent, signal?: AbortSignal):
|
|
|
335
377
|
return reason === "aborted" || stopReason === "aborted" || isAbortMessage(message);
|
|
336
378
|
}
|
|
337
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
|
+
|
|
338
386
|
function earliestCandidateDeadline(current: Model<Api>): number | undefined {
|
|
339
387
|
const deadlines = candidateOrder(current)
|
|
340
388
|
.map((entry) => activeLimit(entry.model)?.deadline ?? activeNonRetryableFailure(entry.model)?.deadline)
|
|
@@ -624,6 +672,19 @@ export function waitForRateLimit(
|
|
|
624
672
|
|
|
625
673
|
// ─── Early 429 observer ──────────────────────────────────────────────────────
|
|
626
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
|
+
|
|
627
688
|
function installFetchRateLimitObserver(): void {
|
|
628
689
|
if (restoreFetch || typeof globalThis.fetch !== "function") return;
|
|
629
690
|
|
|
@@ -634,7 +695,12 @@ function installFetchRateLimitObserver(): void {
|
|
|
634
695
|
if (activeProviderRequests > 0) {
|
|
635
696
|
if (response.status === 429) {
|
|
636
697
|
const waitMs = retryAfterHeaderMs(response.headers) ?? DEFAULT_RATE_LIMIT_WAIT_MS;
|
|
698
|
+
const message = await responseErrorMessage(response);
|
|
699
|
+
lastObservedRateLimitError = { at: Date.now(), message };
|
|
637
700
|
showAmbientRetryStatus("rate-limit", waitMs);
|
|
701
|
+
if (activeFallbackProviderRequests > 0) {
|
|
702
|
+
throw new Error(message);
|
|
703
|
+
}
|
|
638
704
|
} else if (ambientStatusCleanup && response.ok) {
|
|
639
705
|
clearAmbientRetryStatus();
|
|
640
706
|
}
|
|
@@ -667,6 +733,7 @@ export function __configureFallbackModelsForTests(
|
|
|
667
733
|
primaryThinkingLevel = undefined;
|
|
668
734
|
rateLimitMemory.clear();
|
|
669
735
|
nonRetryableFailureMemory.clear();
|
|
736
|
+
lastObservedRateLimitError = undefined;
|
|
670
737
|
ambientStatusCleanup?.();
|
|
671
738
|
modelStatusCleanup?.();
|
|
672
739
|
}
|
|
@@ -716,9 +783,11 @@ export function streamWithLimitsRetry(
|
|
|
716
783
|
|
|
717
784
|
while (true) {
|
|
718
785
|
activeProviderRequests++;
|
|
786
|
+
if (allowFallback) activeFallbackProviderRequests++;
|
|
719
787
|
installFetchRateLimitObserver();
|
|
720
788
|
const buffer: AssistantMessageEvent[] = [];
|
|
721
789
|
let retryable: RetryableError | undefined;
|
|
790
|
+
let retryableErrorMessage = "";
|
|
722
791
|
let nonRetryableError: { message: string; event?: AssistantMessageEvent } | undefined;
|
|
723
792
|
|
|
724
793
|
try {
|
|
@@ -753,14 +822,22 @@ export function streamWithLimitsRetry(
|
|
|
753
822
|
}
|
|
754
823
|
|
|
755
824
|
retryable = getRetryableError(errMsg);
|
|
756
|
-
if (retryable)
|
|
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
|
+
}
|
|
757
835
|
|
|
758
836
|
if (allowFallback) {
|
|
759
837
|
nonRetryableError = { message: errMsg, event };
|
|
760
838
|
break;
|
|
761
839
|
}
|
|
762
840
|
|
|
763
|
-
// Non-retryable error without fallbacks: ensure start came first, then forward error.
|
|
764
841
|
if (buffer.length > 0) {
|
|
765
842
|
flush(buffer);
|
|
766
843
|
} else {
|
|
@@ -801,9 +878,15 @@ export function streamWithLimitsRetry(
|
|
|
801
878
|
}
|
|
802
879
|
}
|
|
803
880
|
} catch (err) {
|
|
804
|
-
const errMsg =
|
|
881
|
+
const errMsg = errorMessageWithCauses(err);
|
|
805
882
|
const aborted = Boolean(options?.signal?.aborted) || isAbortMessage(errMsg);
|
|
806
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
|
+
}
|
|
807
890
|
if (!retryable) {
|
|
808
891
|
if (allowFallback && !aborted) {
|
|
809
892
|
nonRetryableError = { message: errMsg };
|
|
@@ -828,6 +911,7 @@ export function streamWithLimitsRetry(
|
|
|
828
911
|
}
|
|
829
912
|
} finally {
|
|
830
913
|
activeProviderRequests--;
|
|
914
|
+
if (allowFallback) activeFallbackProviderRequests--;
|
|
831
915
|
if (activeProviderRequests === 0) {
|
|
832
916
|
clearAmbientRetryStatus();
|
|
833
917
|
restoreFetch?.();
|
|
@@ -874,6 +958,15 @@ export function streamWithLimitsRetry(
|
|
|
874
958
|
}
|
|
875
959
|
|
|
876
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
|
+
}
|
|
877
970
|
if (!committed) {
|
|
878
971
|
if (buffer.length > 0) {
|
|
879
972
|
flush(buffer);
|
|
@@ -891,7 +984,7 @@ export function streamWithLimitsRetry(
|
|
|
891
984
|
}
|
|
892
985
|
|
|
893
986
|
if (allowFallback) {
|
|
894
|
-
rememberRateLimit(attempt.model, retryable);
|
|
987
|
+
rememberRateLimit(attempt.model, retryable, retryableErrorMessage);
|
|
895
988
|
const next = nextAvailableCandidate(attempt.model);
|
|
896
989
|
if (next) {
|
|
897
990
|
attempt = next;
|