oira666_pi-limits-wait 0.5.2 → 0.5.4

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,155 +1,166 @@
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, 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
- export function candidateOrder(current: Model<Api>): FallbackModel[] {
41
- const primary = getPrimaryModel(current);
42
- const order: FallbackModel[] = [configuredAttempt(primary)];
43
- const seen = new Set([modelKey(primary)]);
44
- for (const entry of state.fallbackModels) {
45
- const key = modelKey(entry.model);
46
- if (seen.has(key)) continue;
47
- seen.add(key);
48
- order.push(entry);
49
- }
50
- return order;
51
- }
52
-
53
- export function activeLimit(model: Model<Api>) {
54
- const entry = state.rateLimitMemory.get(modelKey(model));
55
- if (!entry) return undefined;
56
- if (Date.now() >= entry.deadline) {
57
- state.rateLimitMemory.delete(modelKey(model));
58
- return undefined;
59
- }
60
- return entry;
61
- }
62
-
63
- export function ensureRateLimitedModelsStatus(): void {
64
- // Intentionally no-op: limited/frozen model information is communicated via
65
- // chat notifications only. Do not render persistent TUI status lines.
66
- }
67
-
68
- export function notifyRetryableError(model: Model<Api>, retryable: RetryableError, errorMessage?: string): void {
69
- const detail = errorMessage ? ` Error: ${formatErrorDetail(errorMessage, Number.MAX_SAFE_INTEGER)}` : "";
70
- state.sharedCtx?.ui.notify(`${formatModel(model)} ${reasonLabel(retryable.reason).toLowerCase()} for ${formatDuration(retryable.waitMs)}.${detail}`, "warning");
71
- }
72
-
73
- export function notifyRetryingAfterError(model: Model<Api>, waitMs: number, errorMessage: string): void {
74
- state.sharedCtx?.ui.notify(`${formatModel(model)} retrying after error for ${formatDuration(waitMs)}. Error: ${formatErrorDetail(errorMessage, Number.MAX_SAFE_INTEGER)}`, "warning");
75
- }
76
-
77
- export function rememberRateLimit(model: Model<Api>, retryable: RetryableError, errorMessage?: string): void {
78
- const deadline = Date.now() + retryable.waitMs;
79
- state.rateLimitMemory.set(modelKey(model), { reason: retryable.reason, limitedAt: Date.now(), deadline });
80
- notifyRetryableError(model, retryable, errorMessage);
81
- ensureRateLimitedModelsStatus();
82
- }
83
-
84
- export function activeNonRetryableFailure(model: Model<Api>) {
85
- const entry = state.nonRetryableFailureMemory.get(modelKey(model));
86
- if (!entry) return undefined;
87
- if (Date.now() >= entry.deadline) {
88
- state.nonRetryableFailureMemory.delete(modelKey(model));
89
- return undefined;
90
- }
91
- return entry;
92
- }
93
-
94
- export function hasNonRetryableFailure(model: Model<Api>): boolean {
95
- return Boolean(activeNonRetryableFailure(model));
96
- }
97
-
98
- export function nextAvailableCandidate(current: Model<Api>): FallbackModel | undefined {
99
- return candidateOrder(current).find((entry) => !activeLimit(entry.model) && !hasNonRetryableFailure(entry.model));
100
- }
101
-
102
- export function rememberNonRetryableFailure(model: Model<Api>, errorMessage: string): void {
103
- const deadline = Date.now() + DEFAULT_NON_RETRYABLE_FREEZE_MS;
104
- state.nonRetryableFailureMemory.set(modelKey(model), { failedAt: Date.now(), deadline, errorMessage });
105
- state.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");
106
- ensureRateLimitedModelsStatus();
107
- }
108
-
109
- export function earliestCandidateDeadline(current: Model<Api>): number | undefined {
110
- const deadlines = candidateOrder(current)
111
- .map((entry) => activeLimit(entry.model)?.deadline ?? activeNonRetryableFailure(entry.model)?.deadline)
112
- .filter((deadline): deadline is number => typeof deadline === "number");
113
- return deadlines.length > 0 ? Math.min(...deadlines) : undefined;
114
- }
115
-
116
- export function initialAttempt(model: Model<Api>): FallbackModel {
117
- const current = configuredAttempt(model);
118
- if (!fallbackEnabled() || (!activeLimit(model) && !hasNonRetryableFailure(model))) return current;
119
- return nextAvailableCandidate(model) ?? current;
120
- }
121
-
122
- let modelSwitchQueue = Promise.resolve();
123
-
124
- export async function switchPiModel(entry: FallbackModel, signal?: AbortSignal): Promise<void> {
125
- const operation = modelSwitchQueue.then(async () => {
126
- if (signal?.aborted) return;
127
- const pi = state.extensionApi;
128
- if (!pi) return;
129
- const current = state.sharedCtx?.model;
130
- if (current && modelKey(current) === modelKey(entry.model)) return;
131
- if (signal?.aborted) return;
132
-
133
- const cancelExpectedSelection = expectModelSelection(entry.model);
134
- try {
135
- const ok = await pi.setModel(entry.model);
136
- if (!ok) {
137
- cancelExpectedSelection();
138
- return;
139
- }
140
- if (signal?.aborted) return;
141
- const level = entry.reasoningEffort ?? state.primaryThinkingLevel;
142
- if (level) pi.setThinkingLevel(level);
143
- if (!signal?.aborted) {
144
- state.sharedCtx?.ui.notify(`Switched to ${formatModel(entry.model)}${level ? ` (${level})` : ""}.`, "info");
145
- }
146
- } catch {
147
- // The request can outlive its session during /reload or replacement. The
148
- // interception owner aborts it; never let a stale UI/model switch strand
149
- // the output stream.
150
- cancelExpectedSelection();
151
- }
152
- });
153
- modelSwitchQueue = operation.catch(() => undefined);
154
- await operation;
155
- }
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, 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
+ export function candidateOrder(current: Model<Api>): FallbackModel[] {
41
+ const primary = getPrimaryModel(current);
42
+ const order: FallbackModel[] = [configuredAttempt(primary)];
43
+ const seen = new Set([modelKey(primary)]);
44
+ for (const entry of state.fallbackModels) {
45
+ const key = modelKey(entry.model);
46
+ if (seen.has(key)) continue;
47
+ seen.add(key);
48
+ order.push(entry);
49
+ }
50
+ return order;
51
+ }
52
+
53
+ export function activeLimit(model: Model<Api>) {
54
+ const entry = state.rateLimitMemory.get(modelKey(model));
55
+ if (!entry) return undefined;
56
+ if (Date.now() >= entry.deadline) {
57
+ state.rateLimitMemory.delete(modelKey(model));
58
+ return undefined;
59
+ }
60
+ return entry;
61
+ }
62
+
63
+ export function ensureRateLimitedModelsStatus(): void {
64
+ // Intentionally no-op: limited/frozen model information is communicated via
65
+ // chat notifications only. Do not render persistent TUI status lines.
66
+ }
67
+
68
+ export function notifyRetryableError(model: Model<Api>, retryable: RetryableError, errorMessage?: string): void {
69
+ const detail = errorMessage ? ` Error: ${formatErrorDetail(errorMessage, Number.MAX_SAFE_INTEGER)}` : "";
70
+ state.sharedCtx?.ui.notify(`${formatModel(model)} ${reasonLabel(retryable.reason).toLowerCase()} for ${formatDuration(retryable.waitMs)}.${detail}`, "warning");
71
+ }
72
+
73
+ export function notifyRetryingAfterError(model: Model<Api>, waitMs: number, errorMessage: string): void {
74
+ state.sharedCtx?.ui.notify(`${formatModel(model)} retrying after error for ${formatDuration(waitMs)}. Error: ${formatErrorDetail(errorMessage, Number.MAX_SAFE_INTEGER)}`, "warning");
75
+ }
76
+
77
+ export function rememberRateLimit(model: Model<Api>, retryable: RetryableError, errorMessage?: string): void {
78
+ const deadline = Date.now() + retryable.waitMs;
79
+ state.rateLimitMemory.set(modelKey(model), { reason: retryable.reason, limitedAt: Date.now(), deadline });
80
+ notifyRetryableError(model, retryable, errorMessage);
81
+ ensureRateLimitedModelsStatus();
82
+ }
83
+
84
+ export function activeNonRetryableFailure(model: Model<Api>) {
85
+ const entry = state.nonRetryableFailureMemory.get(modelKey(model));
86
+ if (!entry) return undefined;
87
+ if (Date.now() >= entry.deadline) {
88
+ state.nonRetryableFailureMemory.delete(modelKey(model));
89
+ return undefined;
90
+ }
91
+ return entry;
92
+ }
93
+
94
+ export function hasNonRetryableFailure(model: Model<Api>): boolean {
95
+ return Boolean(activeNonRetryableFailure(model));
96
+ }
97
+
98
+ export function nextAvailableCandidate(current: Model<Api>): FallbackModel | undefined {
99
+ return candidateOrder(current).find((entry) => !activeLimit(entry.model) && !hasNonRetryableFailure(entry.model));
100
+ }
101
+
102
+ export function rememberNonRetryableFailure(model: Model<Api>, errorMessage: string): void {
103
+ const deadline = Date.now() + DEFAULT_NON_RETRYABLE_FREEZE_MS;
104
+ state.nonRetryableFailureMemory.set(modelKey(model), { failedAt: Date.now(), deadline, errorMessage });
105
+ state.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");
106
+ ensureRateLimitedModelsStatus();
107
+ }
108
+
109
+ export function earliestCandidateDeadline(current: Model<Api>): number | undefined {
110
+ const deadlines = candidateOrder(current)
111
+ .map((entry) => activeLimit(entry.model)?.deadline ?? activeNonRetryableFailure(entry.model)?.deadline)
112
+ .filter((deadline): deadline is number => typeof deadline === "number");
113
+ return deadlines.length > 0 ? Math.min(...deadlines) : undefined;
114
+ }
115
+
116
+ export function initialAttempt(model: Model<Api>): FallbackModel {
117
+ const current = configuredAttempt(model);
118
+ if (!fallbackEnabled() || (!activeLimit(model) && !hasNonRetryableFailure(model))) return current;
119
+ return nextAvailableCandidate(model) ?? current;
120
+ }
121
+
122
+ let modelSwitchQueue = Promise.resolve();
123
+
124
+ export async function switchPiModel(
125
+ entry: FallbackModel,
126
+ signal?: AbortSignal,
127
+ canSelect?: (current: Model<Api> | undefined) => boolean,
128
+ ): Promise<boolean> {
129
+ let selected = false;
130
+ const operation = modelSwitchQueue.then(async () => {
131
+ if (signal?.aborted) return;
132
+ const pi = state.extensionApi;
133
+ if (!pi) return;
134
+ const current = state.sharedCtx?.model;
135
+ if (current && modelKey(current) === modelKey(entry.model)) {
136
+ selected = true;
137
+ return;
138
+ }
139
+ if (canSelect && !canSelect(current)) return;
140
+ if (signal?.aborted) return;
141
+
142
+ const cancelExpectedSelection = expectModelSelection(entry.model);
143
+ try {
144
+ const ok = await pi.setModel(entry.model);
145
+ if (!ok) {
146
+ cancelExpectedSelection();
147
+ return;
148
+ }
149
+ if (signal?.aborted) return;
150
+ selected = true;
151
+ const level = entry.reasoningEffort ?? state.primaryThinkingLevel;
152
+ if (level) pi.setThinkingLevel(level);
153
+ if (!signal?.aborted) {
154
+ state.sharedCtx?.ui.notify(`Switched to ${formatModel(entry.model)}${level ? ` (${level})` : ""}.`, "info");
155
+ }
156
+ } catch {
157
+ // The request can outlive its session during /reload or replacement. The
158
+ // interception owner aborts it; never let a stale UI/model switch strand
159
+ // the output stream.
160
+ cancelExpectedSelection();
161
+ }
162
+ });
163
+ modelSwitchQueue = operation.catch(() => undefined);
164
+ await operation;
165
+ return selected;
166
+ }