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/README.md +1 -0
- package/package.json +1 -1
- package/src/constants.ts +1 -1
- package/src/index.ts +174 -166
- package/src/models.ts +281 -281
- package/src/retry-errors.ts +263 -263
- package/src/retry-summary.ts +139 -139
- package/src/settings.ts +162 -162
- package/src/status-json.ts +126 -126
- package/src/stream.ts +855 -855
- package/src/telemetry.ts +81 -81
- package/src/ui.ts +490 -490
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
|
+
}
|