oira666_pi-limits-wait 0.4.11 → 0.4.13

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/src/stream.ts CHANGED
@@ -1,352 +1,354 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
- import {
3
- createAssistantMessageEventStream,
4
- getApiProviders,
5
- type Api,
6
- type AssistantMessage,
7
- type AssistantMessageEvent,
8
- type AssistantMessageEventStream,
9
- type Context,
10
- type Model,
11
- type SimpleStreamOptions,
12
- } from "@mariozechner/pi-ai";
13
- import { installFetchRateLimitObserver } from "./fetch-observer.js";
14
- import {
15
- candidateOrder,
16
- earliestCandidateDeadline,
17
- errorMessageWithRecentHttpStatus,
18
- fallbackEnabled,
19
- getPrimaryModel,
20
- initialAttempt,
21
- isFallbackEligibleModel,
22
- modelKey,
23
- nextAvailableCandidate,
24
- notifyRetryableError,
25
- notifyRetryingAfterError,
26
- optionsForModel,
27
- recentObservedRateLimitError,
28
- rememberNonRetryableFailure,
29
- rememberRateLimit,
30
- switchPiModel,
31
- } from "./models.js";
32
- import { errorMessageWithCauses, getRetryableError, isAbortMessage } from "./retry-errors.js";
33
- import { state } from "./state.js";
34
- import type { FallbackModel, RetryableError, StreamSimpleFn } from "./types.js";
35
- import { clearAmbientRetryStatus, clearModelStatus, freezingEnabled, reasonLabel, waitForRetry } from "./ui.js";
36
-
37
- export function __setNonRetryableTuningForTests(maxAttempts: number, retryDelayMs: number): void {
38
- state.nonRetryableMaxAttempts = maxAttempts;
39
- state.nonRetryableRetryDelayMs = retryDelayMs;
40
- }
41
-
42
- export function __configureFallbackModelsForTests(
43
- models: FallbackModel[],
44
- ctx?: ExtensionContext,
45
- ): void {
46
- state.fallbackModels = models;
47
- state.sharedCtx = ctx;
48
- state.primaryModel = undefined;
49
- state.primaryThinkingLevel = undefined;
50
- state.rateLimitMemory.clear();
51
- state.nonRetryableFailureMemory.clear();
52
- state.lastObservedHttpError = undefined;
53
- state.ambientStatusCleanup?.();
54
- clearModelStatus();
55
- }
56
-
57
- function freshMessage(model: Model<Api>): AssistantMessage {
58
- return {
59
- role: "assistant",
60
- content: [],
61
- api: model.api,
62
- provider: model.provider,
63
- model: model.id,
64
- usage: {
65
- input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
66
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
67
- },
68
- stopReason: "stop",
69
- timestamp: Date.now(),
70
- };
71
- }
72
-
73
- function isAbortErrorEvent(event: AssistantMessageEvent, signal?: AbortSignal): boolean {
74
- if (signal?.aborted) return true;
75
- if (event.type !== "error") return false;
76
- const reason = "reason" in event ? String(event.reason) : "";
77
- const stopReason = event.error.stopReason ? String(event.error.stopReason) : "";
78
- const message = event.error.errorMessage ?? "";
79
- return reason === "aborted" || stopReason === "aborted" || isAbortMessage(message);
80
- }
81
-
82
- export function streamWithLimitsRetry(
83
- delegate: StreamSimpleFn,
84
- model: Model<Api>,
85
- context: Context,
86
- options?: SimpleStreamOptions,
87
- ): AssistantMessageEventStream {
88
- const output = createAssistantMessageEventStream();
89
-
90
- void (async () => {
91
- let committed = false;
92
- const allowFallback = fallbackEnabled() && isFallbackEligibleModel(model);
93
- let attempt: FallbackModel = allowFallback ? initialAttempt(model) : { model, reasoningEffort: state.primaryThinkingLevel };
94
- const nonRetryableAttempts = new Map<string, number>();
95
- const triedModels = new Set<string>();
96
-
97
- const flush = (buffer: AssistantMessageEvent[]) => {
98
- for (const event of buffer) output.push(event);
99
- committed = true;
100
- };
101
-
102
- const pushAbort = (message: string) => {
103
- if (!committed) {
104
- output.push({ type: "start", partial: freshMessage(attempt.model) });
105
- committed = true;
106
- }
107
- const error = freshMessage(attempt.model);
108
- error.stopReason = "aborted";
109
- error.errorMessage = message;
110
- output.push({ type: "error", reason: "aborted", error });
111
- output.end();
112
- };
113
-
114
- while (true) {
115
- state.activeProviderRequests++;
116
- if (allowFallback) state.activeFallbackProviderRequests++;
117
- installFetchRateLimitObserver();
118
- const buffer: AssistantMessageEvent[] = [];
119
- let retryable: RetryableError | undefined;
120
- let retryableErrorMessage = "";
121
- let nonRetryableError: { message: string; event?: AssistantMessageEvent } | undefined;
122
-
123
- try {
124
- try {
125
- const attemptOptions = allowFallback
126
- ? await optionsForModel(model, attempt, options)
127
- : options;
128
- const attemptDelegate = attempt.model.api === model.api
129
- ? delegate
130
- : state.builtinStreamSimpleByApi.get(attempt.model.api)
131
- ?? getApiProviders().find((provider) => provider.api === attempt.model.api)?.streamSimple;
132
- if (!attemptDelegate) throw new Error(`No stream handler registered for API ${attempt.model.api}.`);
133
- const inner = await attemptDelegate(attempt.model, context, attemptOptions);
134
- for await (const event of inner) {
135
- if (!committed) {
136
- if (event.type === "error") {
137
- const rawErrMsg = event.error.errorMessage ?? "";
138
- const errMsg = errorMessageWithRecentHttpStatus(rawErrMsg);
139
-
140
- if (isAbortErrorEvent(event, options?.signal)) {
141
- if (buffer.length > 0) {
142
- flush(buffer);
143
- } else {
144
- output.push({ type: "start", partial: freshMessage(attempt.model) });
145
- committed = true;
146
- }
147
- const error = freshMessage(attempt.model);
148
- error.stopReason = "aborted";
149
- error.errorMessage = errMsg || "Operation aborted";
150
- output.push({ type: "error", reason: "aborted", error });
151
- output.end();
152
- return;
153
- }
154
-
155
- retryable = getRetryableError(errMsg);
156
- if (retryable) {
157
- retryableErrorMessage = errMsg;
158
- break;
159
- }
160
- const observedRateLimit = allowFallback ? recentObservedRateLimitError() : undefined;
161
- retryable = observedRateLimit ? getRetryableError(observedRateLimit) : undefined;
162
- if (retryable && observedRateLimit) {
163
- retryableErrorMessage = observedRateLimit;
164
- break;
165
- }
166
-
167
- nonRetryableError = { message: errMsg, event };
168
- break;
169
- }
170
-
171
- if (event.type === "start") {
172
- buffer.push(event);
173
- continue;
174
- }
175
-
176
- if (allowFallback) await switchPiModel(attempt);
177
- if (buffer.length > 0) {
178
- flush(buffer);
179
- } else {
180
- output.push({ type: "start", partial: freshMessage(attempt.model) });
181
- committed = true;
182
- }
183
- output.push(event);
184
- if (event.type === "done") {
185
- output.end();
186
- return;
187
- }
188
- continue;
189
- }
190
-
191
- output.push(event);
192
- if (event.type === "done" || event.type === "error") {
193
- output.end();
194
- return;
195
- }
196
- }
197
- } catch (err) {
198
- const errMsg = errorMessageWithRecentHttpStatus(errorMessageWithCauses(err));
199
- const aborted = Boolean(options?.signal?.aborted) || isAbortMessage(errMsg);
200
- retryable = !aborted ? getRetryableError(errMsg) : undefined;
201
- if (retryable) retryableErrorMessage = errMsg;
202
- if (!retryable && allowFallback) {
203
- const observedRateLimit = recentObservedRateLimitError();
204
- retryable = observedRateLimit ? getRetryableError(observedRateLimit) : undefined;
205
- if (retryable && observedRateLimit) retryableErrorMessage = observedRateLimit;
206
- }
207
- if (!retryable) {
208
- if (aborted) {
209
- pushAbort(errMsg);
210
- return;
211
- }
212
- nonRetryableError = { message: errMsg };
213
- }
214
- }
215
- } finally {
216
- state.activeProviderRequests--;
217
- if (allowFallback) state.activeFallbackProviderRequests--;
218
- if (state.activeProviderRequests === 0) {
219
- clearAmbientRetryStatus();
220
- state.restoreFetch?.();
221
- }
222
- }
223
-
224
- if (!retryable) {
225
- const failureEvent = nonRetryableError?.event;
226
- const failureMessage = nonRetryableError?.message ?? "Provider stream ended without a terminal event.";
227
-
228
- const emitFailure = () => {
229
- if (!committed) {
230
- if (buffer.length > 0) {
231
- flush(buffer);
232
- } else {
233
- output.push({ type: "start", partial: freshMessage(attempt.model) });
234
- committed = true;
235
- }
236
- }
237
- if (failureEvent) {
238
- output.push(failureEvent);
239
- } else {
240
- const error = freshMessage(attempt.model);
241
- error.stopReason = "error";
242
- error.errorMessage = failureMessage;
243
- output.push({ type: "error", reason: "error", error });
244
- }
245
- output.end();
246
- };
247
-
248
- if (!committed) {
249
- const key = modelKey(attempt.model);
250
- const attempts = (nonRetryableAttempts.get(key) ?? 0) + 1;
251
- nonRetryableAttempts.set(key, attempts);
252
-
253
- if (attempts < state.nonRetryableMaxAttempts) {
254
- notifyRetryingAfterError(attempt.model, state.nonRetryableRetryDelayMs, failureMessage);
255
- const waitResult = await waitForRetry("retry", state.nonRetryableRetryDelayMs, options?.signal);
256
- if (waitResult === "aborted") {
257
- pushAbort("Request aborted while retrying after error.");
258
- return;
259
- }
260
- continue;
261
- }
262
-
263
- triedModels.add(key);
264
-
265
- if (allowFallback) {
266
- if (freezingEnabled()) {
267
- rememberNonRetryableFailure(attempt.model, failureMessage);
268
- const next = nextAvailableCandidate(attempt.model);
269
- if (next) {
270
- attempt = next;
271
- continue;
272
- }
273
-
274
- const deadline = earliestCandidateDeadline(attempt.model);
275
- if (deadline) {
276
- const waitResult = await waitForRetry("model-frozen", Math.max(0, deadline - Date.now()), options?.signal);
277
- if (waitResult !== "aborted") {
278
- attempt = nextAvailableCandidate(attempt.model) ?? { model: getPrimaryModel(attempt.model), reasoningEffort: state.primaryThinkingLevel };
279
- continue;
280
- }
281
- }
282
- } else {
283
- const next = candidateOrder(attempt.model).find((entry) => !triedModels.has(modelKey(entry.model)));
284
- if (next) {
285
- attempt = next;
286
- continue;
287
- }
288
- }
289
- }
290
- }
291
-
292
- emitFailure();
293
- return;
294
- }
295
-
296
- if (allowFallback) {
297
- if (retryable.reason === "network") {
298
- notifyRetryableError(attempt.model, retryable, retryableErrorMessage);
299
- const waitResult = await waitForRetry(retryable.reason, retryable.waitMs, options?.signal);
300
- if (waitResult === "aborted") {
301
- pushAbort(`Request aborted during ${reasonLabel(retryable.reason).toLowerCase()} wait.`);
302
- return;
303
- }
304
- continue;
305
- }
306
-
307
- rememberRateLimit(attempt.model, retryable, retryableErrorMessage);
308
- const next = nextAvailableCandidate(attempt.model);
309
- if (next) {
310
- attempt = next;
311
- continue;
312
- }
313
-
314
- const deadline = earliestCandidateDeadline(attempt.model);
315
- const waitMs = deadline ? Math.max(0, deadline - Date.now()) : retryable.waitMs;
316
- const waitResult = await waitForRetry(retryable.reason, waitMs, options?.signal);
317
- if (waitResult === "aborted") {
318
- pushAbort(`Request aborted during ${reasonLabel(retryable.reason).toLowerCase()} wait.`);
319
- return;
320
- }
321
- attempt = nextAvailableCandidate(attempt.model) ?? { model: getPrimaryModel(attempt.model), reasoningEffort: state.primaryThinkingLevel };
322
- continue;
323
- }
324
-
325
- notifyRetryableError(attempt.model, retryable, retryableErrorMessage);
326
- const waitResult = await waitForRetry(retryable.reason, retryable.waitMs, options?.signal);
327
- if (waitResult === "aborted") {
328
- pushAbort(`Request aborted during ${reasonLabel(retryable.reason).toLowerCase()} wait.`);
329
- return;
330
- }
331
- }
332
- })();
333
-
334
- return output;
335
- }
336
-
337
- export const streamWithRateLimitRetry = streamWithLimitsRetry;
338
-
339
- export function registerWrappedApi(pi: ExtensionAPI, api: Api): void {
340
- if (state.wrappedApis.has(api)) return;
341
-
342
- const builtinStreamSimple = getApiProviders().find((provider) => provider.api === api)?.streamSimple;
343
- if (!builtinStreamSimple) return;
344
-
345
- state.builtinStreamSimpleByApi.set(api, builtinStreamSimple);
346
- state.wrappedApis.add(api);
347
- pi.registerProvider(`limits-wait-${api}`, {
348
- api,
349
- streamSimple: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) =>
350
- streamWithLimitsRetry(builtinStreamSimple, model, context, options),
351
- });
352
- }
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import {
3
+ createAssistantMessageEventStream,
4
+ getApiProviders,
5
+ type Api,
6
+ type AssistantMessage,
7
+ type AssistantMessageEvent,
8
+ type AssistantMessageEventStream,
9
+ type Context,
10
+ type Model,
11
+ type SimpleStreamOptions,
12
+ } from "@mariozechner/pi-ai";
13
+ import { installFetchRateLimitObserver } from "./fetch-observer.js";
14
+ import {
15
+ candidateOrder,
16
+ earliestCandidateDeadline,
17
+ errorMessageWithRecentHttpStatus,
18
+ fallbackEnabled,
19
+ getPrimaryModel,
20
+ initialAttempt,
21
+ isFallbackEligibleModel,
22
+ modelKey,
23
+ nextAvailableCandidate,
24
+ notifyRetryableError,
25
+ notifyRetryingAfterError,
26
+ optionsForModel,
27
+ recentObservedRateLimitError,
28
+ rememberNonRetryableFailure,
29
+ rememberRateLimit,
30
+ switchPiModel,
31
+ } from "./models.js";
32
+ import { anthropicSubscriptionContext } from "./prompt.js";
33
+ import { errorMessageWithCauses, getRetryableError, isAbortMessage } from "./retry-errors.js";
34
+ import { state } from "./state.js";
35
+ import type { FallbackModel, RetryableError, StreamSimpleFn } from "./types.js";
36
+ import { clearAmbientRetryStatus, clearModelStatus, freezingEnabled, reasonLabel, waitForRetry } from "./ui.js";
37
+
38
+ export function __setNonRetryableTuningForTests(maxAttempts: number, retryDelayMs: number): void {
39
+ state.nonRetryableMaxAttempts = maxAttempts;
40
+ state.nonRetryableRetryDelayMs = retryDelayMs;
41
+ }
42
+
43
+ export function __configureFallbackModelsForTests(
44
+ models: FallbackModel[],
45
+ ctx?: ExtensionContext,
46
+ ): void {
47
+ state.fallbackModels = models;
48
+ state.sharedCtx = ctx;
49
+ state.primaryModel = undefined;
50
+ state.primaryThinkingLevel = undefined;
51
+ state.rateLimitMemory.clear();
52
+ state.nonRetryableFailureMemory.clear();
53
+ state.lastObservedHttpError = undefined;
54
+ state.ambientStatusCleanup?.();
55
+ clearModelStatus();
56
+ }
57
+
58
+ function freshMessage(model: Model<Api>): AssistantMessage {
59
+ return {
60
+ role: "assistant",
61
+ content: [],
62
+ api: model.api,
63
+ provider: model.provider,
64
+ model: model.id,
65
+ usage: {
66
+ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
67
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
68
+ },
69
+ stopReason: "stop",
70
+ timestamp: Date.now(),
71
+ };
72
+ }
73
+
74
+ function isAbortErrorEvent(event: AssistantMessageEvent, signal?: AbortSignal): boolean {
75
+ if (signal?.aborted) return true;
76
+ if (event.type !== "error") return false;
77
+ const reason = "reason" in event ? String(event.reason) : "";
78
+ const stopReason = event.error.stopReason ? String(event.error.stopReason) : "";
79
+ const message = event.error.errorMessage ?? "";
80
+ return reason === "aborted" || stopReason === "aborted" || isAbortMessage(message);
81
+ }
82
+
83
+ export function streamWithLimitsRetry(
84
+ delegate: StreamSimpleFn,
85
+ model: Model<Api>,
86
+ context: Context,
87
+ options?: SimpleStreamOptions,
88
+ ): AssistantMessageEventStream {
89
+ const output = createAssistantMessageEventStream();
90
+
91
+ void (async () => {
92
+ let committed = false;
93
+ const allowFallback = fallbackEnabled() && isFallbackEligibleModel(model);
94
+ let attempt: FallbackModel = allowFallback ? initialAttempt(model) : { model, reasoningEffort: state.primaryThinkingLevel };
95
+ const nonRetryableAttempts = new Map<string, number>();
96
+ const triedModels = new Set<string>();
97
+
98
+ const flush = (buffer: AssistantMessageEvent[]) => {
99
+ for (const event of buffer) output.push(event);
100
+ committed = true;
101
+ };
102
+
103
+ const pushAbort = (message: string) => {
104
+ if (!committed) {
105
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
106
+ committed = true;
107
+ }
108
+ const error = freshMessage(attempt.model);
109
+ error.stopReason = "aborted";
110
+ error.errorMessage = message;
111
+ output.push({ type: "error", reason: "aborted", error });
112
+ output.end();
113
+ };
114
+
115
+ while (true) {
116
+ state.activeProviderRequests++;
117
+ if (allowFallback) state.activeFallbackProviderRequests++;
118
+ installFetchRateLimitObserver();
119
+ const buffer: AssistantMessageEvent[] = [];
120
+ let retryable: RetryableError | undefined;
121
+ let retryableErrorMessage = "";
122
+ let nonRetryableError: { message: string; event?: AssistantMessageEvent } | undefined;
123
+
124
+ try {
125
+ try {
126
+ const attemptOptions = allowFallback
127
+ ? await optionsForModel(model, attempt, options)
128
+ : options;
129
+ const attemptDelegate = attempt.model.api === model.api
130
+ ? delegate
131
+ : state.builtinStreamSimpleByApi.get(attempt.model.api)
132
+ ?? getApiProviders().find((provider) => provider.api === attempt.model.api)?.streamSimple;
133
+ if (!attemptDelegate) throw new Error(`No stream handler registered for API ${attempt.model.api}.`);
134
+ const attemptContext = anthropicSubscriptionContext(attempt.model, context);
135
+ const inner = await attemptDelegate(attempt.model, attemptContext, attemptOptions);
136
+ for await (const event of inner) {
137
+ if (!committed) {
138
+ if (event.type === "error") {
139
+ const rawErrMsg = event.error.errorMessage ?? "";
140
+ const errMsg = errorMessageWithRecentHttpStatus(rawErrMsg);
141
+
142
+ if (isAbortErrorEvent(event, options?.signal)) {
143
+ if (buffer.length > 0) {
144
+ flush(buffer);
145
+ } else {
146
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
147
+ committed = true;
148
+ }
149
+ const error = freshMessage(attempt.model);
150
+ error.stopReason = "aborted";
151
+ error.errorMessage = errMsg || "Operation aborted";
152
+ output.push({ type: "error", reason: "aborted", error });
153
+ output.end();
154
+ return;
155
+ }
156
+
157
+ retryable = getRetryableError(errMsg);
158
+ if (retryable) {
159
+ retryableErrorMessage = errMsg;
160
+ break;
161
+ }
162
+ const observedRateLimit = allowFallback ? recentObservedRateLimitError() : undefined;
163
+ retryable = observedRateLimit ? getRetryableError(observedRateLimit) : undefined;
164
+ if (retryable && observedRateLimit) {
165
+ retryableErrorMessage = observedRateLimit;
166
+ break;
167
+ }
168
+
169
+ nonRetryableError = { message: errMsg, event };
170
+ break;
171
+ }
172
+
173
+ if (event.type === "start") {
174
+ buffer.push(event);
175
+ continue;
176
+ }
177
+
178
+ if (allowFallback) await switchPiModel(attempt);
179
+ if (buffer.length > 0) {
180
+ flush(buffer);
181
+ } else {
182
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
183
+ committed = true;
184
+ }
185
+ output.push(event);
186
+ if (event.type === "done") {
187
+ output.end();
188
+ return;
189
+ }
190
+ continue;
191
+ }
192
+
193
+ output.push(event);
194
+ if (event.type === "done" || event.type === "error") {
195
+ output.end();
196
+ return;
197
+ }
198
+ }
199
+ } catch (err) {
200
+ const errMsg = errorMessageWithRecentHttpStatus(errorMessageWithCauses(err));
201
+ const aborted = Boolean(options?.signal?.aborted) || isAbortMessage(errMsg);
202
+ retryable = !aborted ? getRetryableError(errMsg) : undefined;
203
+ if (retryable) retryableErrorMessage = errMsg;
204
+ if (!retryable && allowFallback) {
205
+ const observedRateLimit = recentObservedRateLimitError();
206
+ retryable = observedRateLimit ? getRetryableError(observedRateLimit) : undefined;
207
+ if (retryable && observedRateLimit) retryableErrorMessage = observedRateLimit;
208
+ }
209
+ if (!retryable) {
210
+ if (aborted) {
211
+ pushAbort(errMsg);
212
+ return;
213
+ }
214
+ nonRetryableError = { message: errMsg };
215
+ }
216
+ }
217
+ } finally {
218
+ state.activeProviderRequests--;
219
+ if (allowFallback) state.activeFallbackProviderRequests--;
220
+ if (state.activeProviderRequests === 0) {
221
+ clearAmbientRetryStatus();
222
+ state.restoreFetch?.();
223
+ }
224
+ }
225
+
226
+ if (!retryable) {
227
+ const failureEvent = nonRetryableError?.event;
228
+ const failureMessage = nonRetryableError?.message ?? "Provider stream ended without a terminal event.";
229
+
230
+ const emitFailure = () => {
231
+ if (!committed) {
232
+ if (buffer.length > 0) {
233
+ flush(buffer);
234
+ } else {
235
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
236
+ committed = true;
237
+ }
238
+ }
239
+ if (failureEvent) {
240
+ output.push(failureEvent);
241
+ } else {
242
+ const error = freshMessage(attempt.model);
243
+ error.stopReason = "error";
244
+ error.errorMessage = failureMessage;
245
+ output.push({ type: "error", reason: "error", error });
246
+ }
247
+ output.end();
248
+ };
249
+
250
+ if (!committed) {
251
+ const key = modelKey(attempt.model);
252
+ const attempts = (nonRetryableAttempts.get(key) ?? 0) + 1;
253
+ nonRetryableAttempts.set(key, attempts);
254
+
255
+ if (attempts < state.nonRetryableMaxAttempts) {
256
+ notifyRetryingAfterError(attempt.model, state.nonRetryableRetryDelayMs, failureMessage);
257
+ const waitResult = await waitForRetry("retry", state.nonRetryableRetryDelayMs, options?.signal);
258
+ if (waitResult === "aborted") {
259
+ pushAbort("Request aborted while retrying after error.");
260
+ return;
261
+ }
262
+ continue;
263
+ }
264
+
265
+ triedModels.add(key);
266
+
267
+ if (allowFallback) {
268
+ if (freezingEnabled()) {
269
+ rememberNonRetryableFailure(attempt.model, failureMessage);
270
+ const next = nextAvailableCandidate(attempt.model);
271
+ if (next) {
272
+ attempt = next;
273
+ continue;
274
+ }
275
+
276
+ const deadline = earliestCandidateDeadline(attempt.model);
277
+ if (deadline) {
278
+ const waitResult = await waitForRetry("model-frozen", Math.max(0, deadline - Date.now()), options?.signal);
279
+ if (waitResult !== "aborted") {
280
+ attempt = nextAvailableCandidate(attempt.model) ?? { model: getPrimaryModel(attempt.model), reasoningEffort: state.primaryThinkingLevel };
281
+ continue;
282
+ }
283
+ }
284
+ } else {
285
+ const next = candidateOrder(attempt.model).find((entry) => !triedModels.has(modelKey(entry.model)));
286
+ if (next) {
287
+ attempt = next;
288
+ continue;
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ emitFailure();
295
+ return;
296
+ }
297
+
298
+ if (allowFallback) {
299
+ if (retryable.reason === "network") {
300
+ notifyRetryableError(attempt.model, retryable, retryableErrorMessage);
301
+ const waitResult = await waitForRetry(retryable.reason, retryable.waitMs, options?.signal);
302
+ if (waitResult === "aborted") {
303
+ pushAbort(`Request aborted during ${reasonLabel(retryable.reason).toLowerCase()} wait.`);
304
+ return;
305
+ }
306
+ continue;
307
+ }
308
+
309
+ rememberRateLimit(attempt.model, retryable, retryableErrorMessage);
310
+ const next = nextAvailableCandidate(attempt.model);
311
+ if (next) {
312
+ attempt = next;
313
+ continue;
314
+ }
315
+
316
+ const deadline = earliestCandidateDeadline(attempt.model);
317
+ const waitMs = deadline ? Math.max(0, deadline - Date.now()) : retryable.waitMs;
318
+ const waitResult = await waitForRetry(retryable.reason, waitMs, options?.signal);
319
+ if (waitResult === "aborted") {
320
+ pushAbort(`Request aborted during ${reasonLabel(retryable.reason).toLowerCase()} wait.`);
321
+ return;
322
+ }
323
+ attempt = nextAvailableCandidate(attempt.model) ?? { model: getPrimaryModel(attempt.model), reasoningEffort: state.primaryThinkingLevel };
324
+ continue;
325
+ }
326
+
327
+ notifyRetryableError(attempt.model, retryable, retryableErrorMessage);
328
+ const waitResult = await waitForRetry(retryable.reason, retryable.waitMs, options?.signal);
329
+ if (waitResult === "aborted") {
330
+ pushAbort(`Request aborted during ${reasonLabel(retryable.reason).toLowerCase()} wait.`);
331
+ return;
332
+ }
333
+ }
334
+ })();
335
+
336
+ return output;
337
+ }
338
+
339
+ export const streamWithRateLimitRetry = streamWithLimitsRetry;
340
+
341
+ export function registerWrappedApi(pi: ExtensionAPI, api: Api): void {
342
+ if (state.wrappedApis.has(api)) return;
343
+
344
+ const builtinStreamSimple = getApiProviders().find((provider) => provider.api === api)?.streamSimple;
345
+ if (!builtinStreamSimple) return;
346
+
347
+ state.builtinStreamSimpleByApi.set(api, builtinStreamSimple);
348
+ state.wrappedApis.add(api);
349
+ pi.registerProvider(`limits-wait-${api}`, {
350
+ api,
351
+ streamSimple: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) =>
352
+ streamWithLimitsRetry(builtinStreamSimple, model, context, options),
353
+ });
354
+ }