oira666_pi-limits-wait 0.6.1 → 0.6.2

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/models.ts CHANGED
@@ -1,281 +1,281 @@
1
- import type { Api, Model } from "@earendil-works/pi-ai";
2
- import { DEFAULT_NON_RETRYABLE_FREEZE_MS } from "./constants.js";
3
- import { expectModelSelection, state } from "./state.js";
4
- import type { FallbackModel, RetryableError } from "./types.js";
5
- import { formatErrorDetail, formatDuration, notifyLiveliness, reasonLabel } from "./ui.js";
6
-
7
- export function modelKey(model: Pick<Model<Api>, "provider" | "id">): string {
8
- return `${model.provider}/${model.id}`;
9
- }
10
-
11
- export function formatModel(model: Pick<Model<Api>, "provider" | "id">): string {
12
- return `${model.provider}/${model.id}`;
13
- }
14
-
15
- export function isInternalSyntheticModel(model: Pick<Model<Api>, "provider" | "id">): boolean {
16
- return model.provider.startsWith("pi-") || model.id.startsWith("synthetic-");
17
- }
18
-
19
- export function isFallbackEligibleModel(model: Model<Api>): boolean {
20
- if (isInternalSyntheticModel(model)) return false;
21
- const key = modelKey(model);
22
- return state.primaryModel
23
- ? key === modelKey(state.primaryModel) || state.fallbackModels.some((entry) => modelKey(entry.model) === key)
24
- : true;
25
- }
26
-
27
- export function fallbackEnabled(): boolean {
28
- return state.fallbackModels.length > 0;
29
- }
30
-
31
- export function getPrimaryModel(current: Model<Api>): Model<Api> {
32
- return state.primaryModel ?? current;
33
- }
34
-
35
- export function configuredAttempt(model: Model<Api>): FallbackModel {
36
- return state.fallbackModels.find((entry) => modelKey(entry.model) === modelKey(model))
37
- ?? { model, reasoningEffort: state.primaryThinkingLevel };
38
- }
39
-
40
- /**
41
- * Listed models are authoritative, top to bottom. An explicitly selected
42
- * model outside the list stays first; the configured list is used only if
43
- * that selection fails. Unrelated calls are excluded by isFallbackEligibleModel.
44
- */
45
- export function candidateOrder(current: Model<Api>): FallbackModel[] {
46
- const primary = getPrimaryModel(current);
47
- const listed = state.fallbackModels.some((entry) => modelKey(entry.model) === modelKey(primary));
48
- const order: FallbackModel[] = listed ? [] : [configuredAttempt(primary)];
49
- const seen = new Set(order.map((entry) => modelKey(entry.model)));
50
- for (const entry of state.fallbackModels) {
51
- const key = modelKey(entry.model);
52
- if (seen.has(key)) continue;
53
- seen.add(key);
54
- order.push(entry);
55
- }
56
- return order;
57
- }
58
-
59
- export function activeLimit(model: Model<Api>) {
60
- const entry = state.rateLimitMemory.get(modelKey(model));
61
- if (!entry) return undefined;
62
- if (Date.now() >= entry.deadline) {
63
- return undefined;
64
- }
65
- return entry;
66
- }
67
-
68
- export function ensureRateLimitedModelsStatus(): void {
69
- // Intentionally no-op: limited/frozen model information is communicated via
70
- // chat notifications only. Do not render persistent TUI status lines.
71
- }
72
-
73
- export function notifyRetryableError(model: Model<Api>, retryable: RetryableError, errorMessage?: string): void {
74
- const detail = errorMessage ? ` Error: ${formatErrorDetail(errorMessage, Number.MAX_SAFE_INTEGER)}` : "";
75
- // A retry is still planned, so this is progress information, not a failure.
76
- notifyLiveliness(`${formatModel(model)} ${reasonLabel(retryable.reason).toLowerCase()} for ${formatDuration(retryable.waitMs)}; waiting, then retrying.${detail}`);
77
- }
78
-
79
- export function notifyRetryingAfterError(model: Model<Api>, waitMs: number, errorMessage: string): void {
80
- notifyLiveliness(`${formatModel(model)} retrying after error in ${formatDuration(waitMs)}. Error: ${formatErrorDetail(errorMessage, Number.MAX_SAFE_INTEGER)}`);
81
- }
82
-
83
- function notifyProbe(model: Model<Api>, remainingMs: number): void {
84
- const ctx = state.sharedCtx;
85
- if (!ctx) return;
86
- const remaining = remainingMs > 0 ? ` (${formatDuration(remainingMs)} left on its remembered limit)` : "";
87
- try {
88
- ctx.ui.notify(`Probing higher-priority model ${formatModel(model)}${remaining}.`, "info");
89
- } catch { /* UI unavailable */ }
90
- }
91
-
92
- export function rememberRateLimit(model: Model<Api>, retryable: RetryableError, errorMessage?: string): void {
93
- const deadline = Date.now() + retryable.waitMs;
94
- state.rateLimitMemory.set(modelKey(model), { reason: retryable.reason, limitedAt: Date.now(), deadline });
95
- notifyRetryableError(model, retryable, errorMessage);
96
- ensureRateLimitedModelsStatus();
97
- }
98
-
99
- export function activeNonRetryableFailure(model: Model<Api>) {
100
- const entry = state.nonRetryableFailureMemory.get(modelKey(model));
101
- if (!entry) return undefined;
102
- if (Date.now() >= entry.deadline) {
103
- return undefined;
104
- }
105
- return entry;
106
- }
107
-
108
- export function hasNonRetryableFailure(model: Model<Api>): boolean {
109
- return Boolean(activeNonRetryableFailure(model));
110
- }
111
-
112
- export function probeIntervalMs(): number {
113
- return Math.max(0, state.probeIntervalMs);
114
- }
115
-
116
- /**
117
- * Remembered unusability of a model, merged across the rate-limit and frozen
118
- * memories, plus the moment it becomes worth re-probing. Remembered deadlines
119
- * are estimates (`retry-after` is often pessimistic, or absent and defaulted),
120
- * so a blocked model is re-probed periodically instead of being trusted until
121
- * its deadline.
122
- */
123
- export function blockedState(model: Model<Api>): { deadline: number; probeAt: number } | undefined {
124
- const limit = activeLimit(model);
125
- const frozen = activeNonRetryableFailure(model);
126
- if (!limit && !frozen) return undefined;
127
- const deadline = Math.max(limit?.deadline ?? 0, frozen?.deadline ?? 0);
128
- // Probe timing follows the most recent evidence of unusability, so a probe
129
- // that fails again postpones the next probe.
130
- const lastEvidence = Math.max(
131
- limit ? limit.lastProbeAt ?? limit.limitedAt : 0,
132
- frozen ? frozen.lastProbeAt ?? frozen.failedAt : 0,
133
- );
134
- const interval = probeIntervalMs();
135
- const probeAt = interval > 0 ? Math.min(lastEvidence + interval, deadline) : deadline;
136
- return { deadline, probeAt };
137
- }
138
-
139
- export function isModelBlocked(model: Model<Api>): boolean {
140
- return Boolean(blockedState(model));
141
- }
142
-
143
- /** Capture record identities before invoking a provider, for concurrency-safe clearing. */
144
- export function unavailabilitySnapshot(model: Model<Api>) {
145
- const key = modelKey(model);
146
- return { limit: state.rateLimitMemory.get(key), frozen: state.nonRetryableFailureMemory.get(key) };
147
- }
148
-
149
- /** Successful output clears only the failure evidence known to that attempt. */
150
- export function clearUnavailability(model: Model<Api>, snapshot: ReturnType<typeof unavailabilitySnapshot>): void {
151
- const key = modelKey(model);
152
- // Each new failure replaces its record. Older output cannot erase newer evidence.
153
- if (state.rateLimitMemory.get(key) === snapshot.limit) state.rateLimitMemory.delete(key);
154
- if (state.nonRetryableFailureMemory.get(key) === snapshot.frozen) state.nonRetryableFailureMemory.delete(key);
155
- }
156
-
157
- /** Record that a blocked model was just re-probed, so probes stay rate-limited. */
158
- export function markProbeAttempt(model: Model<Api>, now = Date.now()): void {
159
- const remainingMs = (blockedState(model)?.deadline ?? now) - now;
160
- postponeProbe(model, now);
161
- notifyProbe(model, remainingMs);
162
- }
163
-
164
- /** Postpone probing existing evidence without creating or extending a freeze. */
165
- export function postponeProbe(model: Model<Api>, now = Date.now()): void {
166
- const key = modelKey(model);
167
- const limit = state.rateLimitMemory.get(key);
168
- if (limit) limit.lastProbeAt = now;
169
- const frozen = state.nonRetryableFailureMemory.get(key);
170
- if (frozen) frozen.lastProbeAt = now;
171
- }
172
-
173
- /**
174
- * The model to use right now, in strict configured priority order: the first
175
- * candidate that is usable, or - ahead of any lower-priority usable candidate -
176
- * the first blocked candidate whose probe is due. This is what makes the top
177
- * model reclaim traffic as soon as it works again.
178
- */
179
- export function nextAttemptCandidate(
180
- current: Model<Api>,
181
- excluded?: ReadonlySet<string>,
182
- ): { entry: FallbackModel; probe: boolean } | undefined {
183
- const now = Date.now();
184
- for (const entry of candidateOrder(current)) {
185
- if (excluded?.has(modelKey(entry.model))) continue;
186
- const blocked = blockedState(entry.model);
187
- if (!blocked) return { entry, probe: false };
188
- if (probeIntervalMs() > 0 && now >= blocked.probeAt) {
189
- return { entry, probe: true };
190
- }
191
- }
192
- return undefined;
193
- }
194
-
195
- export function nextAvailableCandidate(current: Model<Api>, excluded?: ReadonlySet<string>): FallbackModel | undefined {
196
- return nextAttemptCandidate(current, excluded)?.entry;
197
- }
198
-
199
- export function rememberNonRetryableFailure(model: Model<Api>, errorMessage: string): void {
200
- const deadline = Date.now() + DEFAULT_NON_RETRYABLE_FREEZE_MS;
201
- state.nonRetryableFailureMemory.set(modelKey(model), { failedAt: Date.now(), deadline, errorMessage });
202
- notifyLiveliness(`${formatModel(model)} failed (${formatErrorDetail(errorMessage)}); freezing it for ${formatDuration(DEFAULT_NON_RETRYABLE_FREEZE_MS)} and trying another configured model if available.`);
203
- ensureRateLimitedModelsStatus();
204
- }
205
-
206
- export function earliestCandidateDeadline(current: Model<Api>): number | undefined {
207
- const deadlines = candidateOrder(current)
208
- .map((entry) => activeLimit(entry.model)?.deadline ?? activeNonRetryableFailure(entry.model)?.deadline)
209
- .filter((deadline): deadline is number => typeof deadline === "number");
210
- return deadlines.length > 0 ? Math.min(...deadlines) : undefined;
211
- }
212
-
213
- /**
214
- * When to stop waiting while every candidate is blocked: the earliest of all
215
- * remembered deadlines and all due-probe times, so a wait never outlives the
216
- * next chance to reclaim a higher-priority model.
217
- */
218
- export function earliestCandidateWakeup(current: Model<Api>, excluded?: ReadonlySet<string>): number | undefined {
219
- const wakeups = candidateOrder(current)
220
- .filter((entry) => !excluded?.has(modelKey(entry.model)))
221
- .map((entry) => blockedState(entry.model)?.probeAt)
222
- .filter((wakeup): wakeup is number => typeof wakeup === "number");
223
- return wakeups.length > 0 ? Math.min(...wakeups) : undefined;
224
- }
225
-
226
- /**
227
- * Every request restarts from the top of the priority order, so work returns to
228
- * the highest-priority usable model on its own instead of sticking to whatever
229
- * fallback happened to answer last.
230
- */
231
- export function initialAttempt(model: Model<Api>): FallbackModel | undefined {
232
- const current = configuredAttempt(model);
233
- if (!fallbackEnabled()) return current;
234
- return nextAvailableCandidate(model);
235
- }
236
-
237
- let modelSwitchQueue = Promise.resolve();
238
-
239
- export async function switchPiModel(
240
- entry: FallbackModel,
241
- signal?: AbortSignal,
242
- canSelect?: (current: Model<Api> | undefined) => boolean,
243
- ): Promise<boolean> {
244
- let selected = false;
245
- const operation = modelSwitchQueue.then(async () => {
246
- if (signal?.aborted) return;
247
- const pi = state.extensionApi;
248
- if (!pi) return;
249
- const current = state.sharedCtx?.model;
250
- if (current && modelKey(current) === modelKey(entry.model)) {
251
- selected = true;
252
- return;
253
- }
254
- if (canSelect && !canSelect(current)) return;
255
- if (signal?.aborted) return;
256
-
257
- const cancelExpectedSelection = expectModelSelection(entry.model);
258
- try {
259
- const ok = await pi.setModel(entry.model);
260
- if (!ok) {
261
- cancelExpectedSelection();
262
- return;
263
- }
264
- if (signal?.aborted) return;
265
- selected = true;
266
- const level = entry.reasoningEffort ?? state.primaryThinkingLevel;
267
- if (level) pi.setThinkingLevel(level);
268
- if (!signal?.aborted) {
269
- state.sharedCtx?.ui.notify(`Switched to ${formatModel(entry.model)}${level ? ` (${level})` : ""}.`, "info");
270
- }
271
- } catch {
272
- // The request can outlive its session during /reload or replacement. The
273
- // interception owner aborts it; never let a stale UI/model switch strand
274
- // the output stream.
275
- cancelExpectedSelection();
276
- }
277
- });
278
- modelSwitchQueue = operation.catch(() => undefined);
279
- await operation;
280
- return selected;
281
- }
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import { DEFAULT_NON_RETRYABLE_FREEZE_MS } from "./constants.js";
3
+ import { expectModelSelection, state } from "./state.js";
4
+ import type { FallbackModel, RetryableError } from "./types.js";
5
+ import { formatErrorDetail, formatDuration, notifyLiveliness, reasonLabel } from "./ui.js";
6
+
7
+ export function modelKey(model: Pick<Model<Api>, "provider" | "id">): string {
8
+ return `${model.provider}/${model.id}`;
9
+ }
10
+
11
+ export function formatModel(model: Pick<Model<Api>, "provider" | "id">): string {
12
+ return `${model.provider}/${model.id}`;
13
+ }
14
+
15
+ export function isInternalSyntheticModel(model: Pick<Model<Api>, "provider" | "id">): boolean {
16
+ return model.provider.startsWith("pi-") || model.id.startsWith("synthetic-");
17
+ }
18
+
19
+ export function isFallbackEligibleModel(model: Model<Api>): boolean {
20
+ if (isInternalSyntheticModel(model)) return false;
21
+ const key = modelKey(model);
22
+ return state.primaryModel
23
+ ? key === modelKey(state.primaryModel) || state.fallbackModels.some((entry) => modelKey(entry.model) === key)
24
+ : true;
25
+ }
26
+
27
+ export function fallbackEnabled(): boolean {
28
+ return state.fallbackModels.length > 0;
29
+ }
30
+
31
+ export function getPrimaryModel(current: Model<Api>): Model<Api> {
32
+ return state.primaryModel ?? current;
33
+ }
34
+
35
+ export function configuredAttempt(model: Model<Api>): FallbackModel {
36
+ return state.fallbackModels.find((entry) => modelKey(entry.model) === modelKey(model))
37
+ ?? { model, reasoningEffort: state.primaryThinkingLevel };
38
+ }
39
+
40
+ /**
41
+ * Listed models are authoritative, top to bottom. An explicitly selected
42
+ * model outside the list stays first; the configured list is used only if
43
+ * that selection fails. Unrelated calls are excluded by isFallbackEligibleModel.
44
+ */
45
+ export function candidateOrder(current: Model<Api>): FallbackModel[] {
46
+ const primary = getPrimaryModel(current);
47
+ const listed = state.fallbackModels.some((entry) => modelKey(entry.model) === modelKey(primary));
48
+ const order: FallbackModel[] = listed ? [] : [configuredAttempt(primary)];
49
+ const seen = new Set(order.map((entry) => modelKey(entry.model)));
50
+ for (const entry of state.fallbackModels) {
51
+ const key = modelKey(entry.model);
52
+ if (seen.has(key)) continue;
53
+ seen.add(key);
54
+ order.push(entry);
55
+ }
56
+ return order;
57
+ }
58
+
59
+ export function activeLimit(model: Model<Api>) {
60
+ const entry = state.rateLimitMemory.get(modelKey(model));
61
+ if (!entry) return undefined;
62
+ if (Date.now() >= entry.deadline) {
63
+ return undefined;
64
+ }
65
+ return entry;
66
+ }
67
+
68
+ export function ensureRateLimitedModelsStatus(): void {
69
+ // Intentionally no-op: limited/frozen model information is communicated via
70
+ // chat notifications only. Do not render persistent TUI status lines.
71
+ }
72
+
73
+ export function notifyRetryableError(model: Model<Api>, retryable: RetryableError, errorMessage?: string): void {
74
+ const detail = errorMessage ? ` Error: ${formatErrorDetail(errorMessage, Number.MAX_SAFE_INTEGER)}` : "";
75
+ // A retry is still planned, so this is progress information, not a failure.
76
+ notifyLiveliness(`${formatModel(model)} ${reasonLabel(retryable.reason).toLowerCase()} for ${formatDuration(retryable.waitMs)}; waiting, then retrying.${detail}`);
77
+ }
78
+
79
+ export function notifyRetryingAfterError(model: Model<Api>, waitMs: number, errorMessage: string): void {
80
+ notifyLiveliness(`${formatModel(model)} retrying after error in ${formatDuration(waitMs)}. Error: ${formatErrorDetail(errorMessage, Number.MAX_SAFE_INTEGER)}`);
81
+ }
82
+
83
+ function notifyProbe(model: Model<Api>, remainingMs: number): void {
84
+ const ctx = state.sharedCtx;
85
+ if (!ctx) return;
86
+ const remaining = remainingMs > 0 ? ` (${formatDuration(remainingMs)} left on its remembered limit)` : "";
87
+ try {
88
+ ctx.ui.notify(`Probing higher-priority model ${formatModel(model)}${remaining}.`, "info");
89
+ } catch { /* UI unavailable */ }
90
+ }
91
+
92
+ export function rememberRateLimit(model: Model<Api>, retryable: RetryableError, errorMessage?: string): void {
93
+ const deadline = Date.now() + retryable.waitMs;
94
+ state.rateLimitMemory.set(modelKey(model), { reason: retryable.reason, limitedAt: Date.now(), deadline });
95
+ notifyRetryableError(model, retryable, errorMessage);
96
+ ensureRateLimitedModelsStatus();
97
+ }
98
+
99
+ export function activeNonRetryableFailure(model: Model<Api>) {
100
+ const entry = state.nonRetryableFailureMemory.get(modelKey(model));
101
+ if (!entry) return undefined;
102
+ if (Date.now() >= entry.deadline) {
103
+ return undefined;
104
+ }
105
+ return entry;
106
+ }
107
+
108
+ export function hasNonRetryableFailure(model: Model<Api>): boolean {
109
+ return Boolean(activeNonRetryableFailure(model));
110
+ }
111
+
112
+ export function probeIntervalMs(): number {
113
+ return Math.max(0, state.probeIntervalMs);
114
+ }
115
+
116
+ /**
117
+ * Remembered unusability of a model, merged across the rate-limit and frozen
118
+ * memories, plus the moment it becomes worth re-probing. Remembered deadlines
119
+ * are estimates (`retry-after` is often pessimistic, or absent and defaulted),
120
+ * so a blocked model is re-probed periodically instead of being trusted until
121
+ * its deadline.
122
+ */
123
+ export function blockedState(model: Model<Api>): { deadline: number; probeAt: number } | undefined {
124
+ const limit = activeLimit(model);
125
+ const frozen = activeNonRetryableFailure(model);
126
+ if (!limit && !frozen) return undefined;
127
+ const deadline = Math.max(limit?.deadline ?? 0, frozen?.deadline ?? 0);
128
+ // Probe timing follows the most recent evidence of unusability, so a probe
129
+ // that fails again postpones the next probe.
130
+ const lastEvidence = Math.max(
131
+ limit ? limit.lastProbeAt ?? limit.limitedAt : 0,
132
+ frozen ? frozen.lastProbeAt ?? frozen.failedAt : 0,
133
+ );
134
+ const interval = probeIntervalMs();
135
+ const probeAt = interval > 0 ? Math.min(lastEvidence + interval, deadline) : deadline;
136
+ return { deadline, probeAt };
137
+ }
138
+
139
+ export function isModelBlocked(model: Model<Api>): boolean {
140
+ return Boolean(blockedState(model));
141
+ }
142
+
143
+ /** Capture record identities before invoking a provider, for concurrency-safe clearing. */
144
+ export function unavailabilitySnapshot(model: Model<Api>) {
145
+ const key = modelKey(model);
146
+ return { limit: state.rateLimitMemory.get(key), frozen: state.nonRetryableFailureMemory.get(key) };
147
+ }
148
+
149
+ /** Successful output clears only the failure evidence known to that attempt. */
150
+ export function clearUnavailability(model: Model<Api>, snapshot: ReturnType<typeof unavailabilitySnapshot>): void {
151
+ const key = modelKey(model);
152
+ // Each new failure replaces its record. Older output cannot erase newer evidence.
153
+ if (state.rateLimitMemory.get(key) === snapshot.limit) state.rateLimitMemory.delete(key);
154
+ if (state.nonRetryableFailureMemory.get(key) === snapshot.frozen) state.nonRetryableFailureMemory.delete(key);
155
+ }
156
+
157
+ /** Record that a blocked model was just re-probed, so probes stay rate-limited. */
158
+ export function markProbeAttempt(model: Model<Api>, now = Date.now()): void {
159
+ const remainingMs = (blockedState(model)?.deadline ?? now) - now;
160
+ postponeProbe(model, now);
161
+ notifyProbe(model, remainingMs);
162
+ }
163
+
164
+ /** Postpone probing existing evidence without creating or extending a freeze. */
165
+ export function postponeProbe(model: Model<Api>, now = Date.now()): void {
166
+ const key = modelKey(model);
167
+ const limit = state.rateLimitMemory.get(key);
168
+ if (limit) limit.lastProbeAt = now;
169
+ const frozen = state.nonRetryableFailureMemory.get(key);
170
+ if (frozen) frozen.lastProbeAt = now;
171
+ }
172
+
173
+ /**
174
+ * The model to use right now, in strict configured priority order: the first
175
+ * candidate that is usable, or - ahead of any lower-priority usable candidate -
176
+ * the first blocked candidate whose probe is due. This is what makes the top
177
+ * model reclaim traffic as soon as it works again.
178
+ */
179
+ export function nextAttemptCandidate(
180
+ current: Model<Api>,
181
+ excluded?: ReadonlySet<string>,
182
+ ): { entry: FallbackModel; probe: boolean } | undefined {
183
+ const now = Date.now();
184
+ for (const entry of candidateOrder(current)) {
185
+ if (excluded?.has(modelKey(entry.model))) continue;
186
+ const blocked = blockedState(entry.model);
187
+ if (!blocked) return { entry, probe: false };
188
+ if (probeIntervalMs() > 0 && now >= blocked.probeAt) {
189
+ return { entry, probe: true };
190
+ }
191
+ }
192
+ return undefined;
193
+ }
194
+
195
+ export function nextAvailableCandidate(current: Model<Api>, excluded?: ReadonlySet<string>): FallbackModel | undefined {
196
+ return nextAttemptCandidate(current, excluded)?.entry;
197
+ }
198
+
199
+ export function rememberNonRetryableFailure(model: Model<Api>, errorMessage: string): void {
200
+ const deadline = Date.now() + DEFAULT_NON_RETRYABLE_FREEZE_MS;
201
+ state.nonRetryableFailureMemory.set(modelKey(model), { failedAt: Date.now(), deadline, errorMessage });
202
+ notifyLiveliness(`${formatModel(model)} failed (${formatErrorDetail(errorMessage)}); freezing it for ${formatDuration(DEFAULT_NON_RETRYABLE_FREEZE_MS)} and trying another configured model if available.`);
203
+ ensureRateLimitedModelsStatus();
204
+ }
205
+
206
+ export function earliestCandidateDeadline(current: Model<Api>): number | undefined {
207
+ const deadlines = candidateOrder(current)
208
+ .map((entry) => activeLimit(entry.model)?.deadline ?? activeNonRetryableFailure(entry.model)?.deadline)
209
+ .filter((deadline): deadline is number => typeof deadline === "number");
210
+ return deadlines.length > 0 ? Math.min(...deadlines) : undefined;
211
+ }
212
+
213
+ /**
214
+ * When to stop waiting while every candidate is blocked: the earliest of all
215
+ * remembered deadlines and all due-probe times, so a wait never outlives the
216
+ * next chance to reclaim a higher-priority model.
217
+ */
218
+ export function earliestCandidateWakeup(current: Model<Api>, excluded?: ReadonlySet<string>): number | undefined {
219
+ const wakeups = candidateOrder(current)
220
+ .filter((entry) => !excluded?.has(modelKey(entry.model)))
221
+ .map((entry) => blockedState(entry.model)?.probeAt)
222
+ .filter((wakeup): wakeup is number => typeof wakeup === "number");
223
+ return wakeups.length > 0 ? Math.min(...wakeups) : undefined;
224
+ }
225
+
226
+ /**
227
+ * Every request restarts from the top of the priority order, so work returns to
228
+ * the highest-priority usable model on its own instead of sticking to whatever
229
+ * fallback happened to answer last.
230
+ */
231
+ export function initialAttempt(model: Model<Api>): FallbackModel | undefined {
232
+ const current = configuredAttempt(model);
233
+ if (!fallbackEnabled()) return current;
234
+ return nextAvailableCandidate(model);
235
+ }
236
+
237
+ let modelSwitchQueue = Promise.resolve();
238
+
239
+ export async function switchPiModel(
240
+ entry: FallbackModel,
241
+ signal?: AbortSignal,
242
+ canSelect?: (current: Model<Api> | undefined) => boolean,
243
+ ): Promise<boolean> {
244
+ let selected = false;
245
+ const operation = modelSwitchQueue.then(async () => {
246
+ if (signal?.aborted) return;
247
+ const pi = state.extensionApi;
248
+ if (!pi) return;
249
+ const current = state.sharedCtx?.model;
250
+ if (current && modelKey(current) === modelKey(entry.model)) {
251
+ selected = true;
252
+ return;
253
+ }
254
+ if (canSelect && !canSelect(current)) return;
255
+ if (signal?.aborted) return;
256
+
257
+ const cancelExpectedSelection = expectModelSelection(entry.model);
258
+ try {
259
+ const ok = await pi.setModel(entry.model);
260
+ if (!ok) {
261
+ cancelExpectedSelection();
262
+ return;
263
+ }
264
+ if (signal?.aborted) return;
265
+ selected = true;
266
+ const level = entry.reasoningEffort ?? state.primaryThinkingLevel;
267
+ if (level) pi.setThinkingLevel(level);
268
+ if (!signal?.aborted) {
269
+ state.sharedCtx?.ui.notify(`Switched to ${formatModel(entry.model)}${level ? ` (${level})` : ""}.`, "info");
270
+ }
271
+ } catch {
272
+ // The request can outlive its session during /reload or replacement. The
273
+ // interception owner aborts it; never let a stale UI/model switch strand
274
+ // the output stream.
275
+ cancelExpectedSelection();
276
+ }
277
+ });
278
+ modelSwitchQueue = operation.catch(() => undefined);
279
+ await operation;
280
+ return selected;
281
+ }