pi-model-fallback 0.1.0 → 0.3.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/CHANGELOG.md +56 -33
- package/README.md +164 -163
- package/extensions/index.ts +199 -27
- package/lib/config.ts +144 -136
- package/lib/state.ts +118 -0
- package/lib/storage.ts +37 -36
- package/package.json +59 -59
package/extensions/index.ts
CHANGED
|
@@ -3,15 +3,27 @@ import { dirname, join, resolve } from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { Type } from "typebox";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
defaultConfig,
|
|
8
|
+
findFallback,
|
|
9
|
+
modelKey,
|
|
10
|
+
modelRefKey,
|
|
11
|
+
validateConfigShape,
|
|
12
|
+
type ModelFallbackConfig,
|
|
13
|
+
type ModelRef,
|
|
14
|
+
} from "../lib/config.js";
|
|
15
|
+
import { findActiveStateEntry, readState, upsertStateEntry, writeState, type FallbackState } from "../lib/state.js";
|
|
7
16
|
import { modelFallbackPaths, readConfig, writeConfig, type ModelFallbackPaths } from "../lib/storage.js";
|
|
8
17
|
|
|
9
18
|
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
10
19
|
const STATUS_KEY = "model-fallback";
|
|
20
|
+
const DEFAULT_429_COOLDOWN_MS = 72 * 60 * 60 * 1000;
|
|
21
|
+
const DEFAULT_5XX_COOLDOWN_MS = 10 * 60 * 1000;
|
|
11
22
|
|
|
12
23
|
export default function modelFallback(pi: ExtensionAPI) {
|
|
13
24
|
let paths: ModelFallbackPaths = modelFallbackPaths(getAgentDir());
|
|
14
25
|
let config: ModelFallbackConfig | undefined;
|
|
26
|
+
let state: FallbackState | undefined;
|
|
15
27
|
let originalModelKey: string | undefined;
|
|
16
28
|
let activeFallbackKey: string | undefined;
|
|
17
29
|
let lastFallbackReason: string | undefined;
|
|
@@ -31,12 +43,95 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
31
43
|
}
|
|
32
44
|
}
|
|
33
45
|
|
|
46
|
+
async function loadState(ctx: ExtensionContext): Promise<FallbackState | undefined> {
|
|
47
|
+
try {
|
|
48
|
+
syncPaths(ctx);
|
|
49
|
+
state = await readState(paths.state);
|
|
50
|
+
await writeState(paths.state, state);
|
|
51
|
+
return state;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
ctx.ui.notify(`Model fallback state error: ${errorMessage(error)}`, "warning");
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
34
58
|
function updateStatus(ctx: ExtensionContext): void {
|
|
35
59
|
ctx.ui.setStatus(STATUS_KEY, activeFallbackKey ? `fallback:${activeFallbackKey}` : undefined);
|
|
36
60
|
}
|
|
37
61
|
|
|
38
|
-
|
|
62
|
+
async function applyPersistentFallback(ctx: ExtensionContext): Promise<void> {
|
|
63
|
+
const current = ctx.model;
|
|
64
|
+
if (!current) return;
|
|
65
|
+
const loadedConfig = config ?? (await loadConfig(ctx));
|
|
66
|
+
if (!loadedConfig?.enabled) return;
|
|
67
|
+
const loadedState = state ?? (await loadState(ctx));
|
|
68
|
+
if (!loadedState) return;
|
|
69
|
+
|
|
70
|
+
const active = findActiveStateEntry(loadedState, modelToRef(current));
|
|
71
|
+
if (!active) return;
|
|
72
|
+
|
|
73
|
+
const fallbackModel = ctx.modelRegistry.find(active.fallback.provider, active.fallback.model);
|
|
74
|
+
if (!fallbackModel) {
|
|
75
|
+
ctx.ui.notify(`Model fallback state target missing: ${modelRefKey(active.fallback)}`, "warning");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ok = await pi.setModel(fallbackModel);
|
|
80
|
+
if (!ok) {
|
|
81
|
+
ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(active.fallback)}`, "warning");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
originalModelKey = modelKey(current);
|
|
86
|
+
activeFallbackKey = modelRefKey(active.fallback);
|
|
87
|
+
lastFallbackReason = `persistent ${active.status} from ${originalModelKey} until ${active.until}`;
|
|
88
|
+
updateStatus(ctx);
|
|
89
|
+
ctx.ui.notify(`Model fallback preselected: ${originalModelKey} → ${activeFallbackKey} until ${active.until}.`, "warning");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function persistFailure(source: ModelRef, status: number, headers: Record<string, string>, ctx: ExtensionContext): Promise<void> {
|
|
93
|
+
const loaded = config ?? (await loadConfig(ctx));
|
|
94
|
+
if (!loaded) return;
|
|
95
|
+
const match = findFallback(loaded, { provider: source.provider, id: source.model }, status);
|
|
96
|
+
if (!match) return;
|
|
97
|
+
if (activeFallbackKey && activeFallbackKey === modelRefKey(match.fallback)) return;
|
|
98
|
+
|
|
99
|
+
const fallbackModel = ctx.modelRegistry.find(match.fallback.provider, match.fallback.model);
|
|
100
|
+
if (!fallbackModel) {
|
|
101
|
+
ctx.ui.notify(`Model fallback missing: ${modelRefKey(match.fallback)}`, "warning");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const now = new Date();
|
|
106
|
+
const until = new Date(now.getTime() + cooldownMsFor(match.rule.cooldownMs, status, headers)).toISOString();
|
|
107
|
+
const loadedState = state ?? (await loadState(ctx)) ?? { version: 1 as const, entries: [] };
|
|
108
|
+
state = upsertStateEntry(loadedState, {
|
|
109
|
+
source,
|
|
110
|
+
fallback: match.fallback,
|
|
111
|
+
status,
|
|
112
|
+
until,
|
|
113
|
+
createdAt: now.toISOString(),
|
|
114
|
+
ruleName: match.rule.name,
|
|
115
|
+
});
|
|
116
|
+
await writeState(paths.state, state);
|
|
117
|
+
|
|
118
|
+
const ok = await pi.setModel(fallbackModel);
|
|
119
|
+
if (!ok) {
|
|
120
|
+
ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(match.fallback)}`, "warning");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
originalModelKey = modelRefKey(source);
|
|
125
|
+
activeFallbackKey = modelRefKey(match.fallback);
|
|
126
|
+
lastFallbackReason = `${status} from ${originalModelKey}; persistent until ${until}`;
|
|
127
|
+
updateStatus(ctx);
|
|
128
|
+
ctx.ui.notify(`Model fallback: ${originalModelKey} → ${activeFallbackKey} (${status}). Future sessions preselect fallback until ${until}.`, "warning");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
39
132
|
await loadConfig(ctx);
|
|
133
|
+
await loadState(ctx);
|
|
134
|
+
await applyPersistentFallback(ctx);
|
|
40
135
|
updateStatus(ctx);
|
|
41
136
|
});
|
|
42
137
|
|
|
@@ -56,41 +151,43 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
56
151
|
if (event.status >= 200 && event.status < 300) return;
|
|
57
152
|
if (activeFallbackKey && modelKey(current) === activeFallbackKey) return;
|
|
58
153
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const fallbackModel = ctx.modelRegistry.find(match.fallback.provider, match.fallback.model);
|
|
63
|
-
if (!fallbackModel) {
|
|
64
|
-
ctx.ui.notify(`Model fallback missing: ${modelRefKey(match.fallback)}`, "warning");
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const ok = await pi.setModel(fallbackModel);
|
|
69
|
-
if (!ok) {
|
|
70
|
-
ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(match.fallback)}`, "warning");
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
154
|
+
await persistFailure(modelToRef(current), event.status, event.headers, ctx);
|
|
155
|
+
});
|
|
73
156
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
157
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
158
|
+
const message = event.message as unknown;
|
|
159
|
+
if (!isRecord(message) || message.role !== "assistant") return;
|
|
160
|
+
const errorMessage = typeof message.errorMessage === "string" ? message.errorMessage : undefined;
|
|
161
|
+
if (!errorMessage) return;
|
|
162
|
+
const status = parseStatusFromErrorMessage(errorMessage);
|
|
163
|
+
if (status === undefined) return;
|
|
164
|
+
const provider = typeof message.provider === "string" ? message.provider : ctx.model?.provider;
|
|
165
|
+
const model = typeof message.model === "string" ? message.model : ctx.model?.id;
|
|
166
|
+
if (!provider || !model) return;
|
|
167
|
+
if (activeFallbackKey && `${provider}/${model}` === activeFallbackKey) return;
|
|
168
|
+
await persistFailure({ provider, model }, status, {}, ctx);
|
|
79
169
|
});
|
|
80
170
|
|
|
81
171
|
pi.registerCommand("model-fallback:status", {
|
|
82
172
|
description: "Show model fallback status",
|
|
83
173
|
handler: async (_args, ctx) => {
|
|
84
174
|
const loaded = config ?? (await loadConfig(ctx)) ?? defaultConfig();
|
|
85
|
-
|
|
175
|
+
const loadedState = state ?? (await loadState(ctx)) ?? { version: 1 as const, entries: [] };
|
|
176
|
+
ctx.ui.notify(formatStatus(loaded, loadedState, paths.config, paths.state, activeFallbackKey, originalModelKey, lastFallbackReason), "info");
|
|
86
177
|
},
|
|
87
178
|
});
|
|
88
179
|
|
|
89
180
|
pi.registerCommand("model-fallback:reset", {
|
|
90
|
-
description: "Return from fallback model to the pre-fallback model when remembered",
|
|
181
|
+
description: "Return from fallback model to the pre-fallback model when remembered and clear persistent state",
|
|
91
182
|
handler: async (_args, ctx) => {
|
|
183
|
+
syncPaths(ctx);
|
|
184
|
+
state = { version: 1, entries: [] };
|
|
185
|
+
await writeState(paths.state, state);
|
|
92
186
|
if (!originalModelKey) {
|
|
93
|
-
ctx.ui.notify("Model fallback: no remembered original model.", "info");
|
|
187
|
+
ctx.ui.notify("Model fallback: persistent state cleared; no remembered original model.", "info");
|
|
188
|
+
activeFallbackKey = undefined;
|
|
189
|
+
lastFallbackReason = undefined;
|
|
190
|
+
updateStatus(ctx);
|
|
94
191
|
return;
|
|
95
192
|
}
|
|
96
193
|
const [provider, ...modelParts] = originalModelKey.split("/");
|
|
@@ -108,7 +205,7 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
108
205
|
originalModelKey = undefined;
|
|
109
206
|
lastFallbackReason = undefined;
|
|
110
207
|
updateStatus(ctx);
|
|
111
|
-
ctx.ui.notify(`Model fallback reset: ${modelKey(model)}
|
|
208
|
+
ctx.ui.notify(`Model fallback reset: ${modelKey(model)}; persistent state cleared.`, "info");
|
|
112
209
|
},
|
|
113
210
|
});
|
|
114
211
|
|
|
@@ -129,7 +226,15 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
129
226
|
syncPaths(ctx);
|
|
130
227
|
if (params.action === "read" || params.action === "status") {
|
|
131
228
|
const current = await readConfig(paths.config);
|
|
132
|
-
|
|
229
|
+
const currentState = await readState(paths.state);
|
|
230
|
+
return textResult(JSON.stringify(current, null, 2), {
|
|
231
|
+
configPath: paths.config,
|
|
232
|
+
statePath: paths.state,
|
|
233
|
+
state: currentState,
|
|
234
|
+
activeFallbackKey,
|
|
235
|
+
originalModelKey,
|
|
236
|
+
lastFallbackReason,
|
|
237
|
+
});
|
|
133
238
|
}
|
|
134
239
|
if (!params.configJson) throw new Error("configJson is required for validate/save.");
|
|
135
240
|
const nextConfig = validateConfigShape(JSON.parse(params.configJson));
|
|
@@ -174,11 +279,22 @@ function validateRegisteredModels(ctx: ExtensionContext, nextConfig: ModelFallba
|
|
|
174
279
|
}
|
|
175
280
|
}
|
|
176
281
|
|
|
177
|
-
function formatStatus(
|
|
282
|
+
function formatStatus(
|
|
283
|
+
config: ModelFallbackConfig,
|
|
284
|
+
state: FallbackState,
|
|
285
|
+
configPath: string,
|
|
286
|
+
statePath: string,
|
|
287
|
+
activeFallbackKey?: string,
|
|
288
|
+
originalModelKey?: string,
|
|
289
|
+
reason?: string,
|
|
290
|
+
): string {
|
|
178
291
|
return [
|
|
179
292
|
`Model fallback: ${config.enabled ? "enabled" : "disabled"}`,
|
|
180
293
|
`Config: ${configPath}`,
|
|
294
|
+
`State: ${statePath}`,
|
|
181
295
|
`Rules: ${config.rules.length}`,
|
|
296
|
+
`Persistent entries: ${state.entries.length}`,
|
|
297
|
+
...state.entries.map((entry) => `- ${modelRefKey(entry.source)} -> ${modelRefKey(entry.fallback)} until ${entry.until} (${entry.status})`),
|
|
182
298
|
`Active fallback: ${activeFallbackKey ?? "none"}`,
|
|
183
299
|
`Original model: ${originalModelKey ?? "none"}`,
|
|
184
300
|
`Reason: ${reason ?? "none"}`,
|
|
@@ -196,6 +312,62 @@ function textResult(text: string, details?: Record<string, unknown>) {
|
|
|
196
312
|
return { content: [{ type: "text" as const, text }], details: details ?? {} };
|
|
197
313
|
}
|
|
198
314
|
|
|
315
|
+
function cooldownMsFor(ruleCooldownMs: number | undefined, status: number, headers: Record<string, string>): number {
|
|
316
|
+
const headerMs = cooldownMsFromHeaders(headers);
|
|
317
|
+
if (headerMs !== undefined) return headerMs;
|
|
318
|
+
if (ruleCooldownMs !== undefined) return ruleCooldownMs;
|
|
319
|
+
if (status === 429) return DEFAULT_429_COOLDOWN_MS;
|
|
320
|
+
if (status >= 500 && status <= 599) return DEFAULT_5XX_COOLDOWN_MS;
|
|
321
|
+
return DEFAULT_5XX_COOLDOWN_MS;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function cooldownMsFromHeaders(headers: Record<string, string>): number | undefined {
|
|
325
|
+
const normalized = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
326
|
+
const retryAfter = parseRetryAfter(normalized["retry-after"]);
|
|
327
|
+
if (retryAfter !== undefined) return retryAfter;
|
|
328
|
+
|
|
329
|
+
for (const name of ["x-ratelimit-reset", "x-ratelimit-reset-requests", "x-ratelimit-reset-tokens", "ratelimit-reset", "x-rate-limit-reset"]) {
|
|
330
|
+
const parsed = parseResetHeader(normalized[name]);
|
|
331
|
+
if (parsed !== undefined) return parsed;
|
|
332
|
+
}
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function parseRetryAfter(value: string | undefined): number | undefined {
|
|
337
|
+
if (!value) return undefined;
|
|
338
|
+
const trimmed = value.trim();
|
|
339
|
+
const seconds = Number(trimmed);
|
|
340
|
+
if (Number.isFinite(seconds) && seconds > 0) return Math.ceil(seconds * 1000);
|
|
341
|
+
const dateMs = Date.parse(trimmed);
|
|
342
|
+
if (!Number.isNaN(dateMs) && dateMs > Date.now()) return dateMs - Date.now();
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function parseResetHeader(value: string | undefined): number | undefined {
|
|
347
|
+
if (!value) return undefined;
|
|
348
|
+
const trimmed = value.trim();
|
|
349
|
+
const numeric = Number(trimmed);
|
|
350
|
+
if (Number.isFinite(numeric) && numeric > 0) {
|
|
351
|
+
if (numeric > 1_000_000_000_000) return Math.max(0, numeric - Date.now());
|
|
352
|
+
if (numeric > 1_000_000_000) return Math.max(0, numeric * 1000 - Date.now());
|
|
353
|
+
return Math.ceil(numeric * 1000);
|
|
354
|
+
}
|
|
355
|
+
const dateMs = Date.parse(trimmed);
|
|
356
|
+
if (!Number.isNaN(dateMs) && dateMs > Date.now()) return dateMs - Date.now();
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function parseStatusFromErrorMessage(message: string): number | undefined {
|
|
361
|
+
const match = message.match(/\b([1-5][0-9]{2})\b/);
|
|
362
|
+
if (!match) return undefined;
|
|
363
|
+
const status = Number(match[1]);
|
|
364
|
+
return Number.isInteger(status) ? status : undefined;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function modelToRef(model: { provider: string; id: string }): ModelRef {
|
|
368
|
+
return { provider: model.provider, model: model.id };
|
|
369
|
+
}
|
|
370
|
+
|
|
199
371
|
function errorMessage(error: unknown): string {
|
|
200
372
|
return error instanceof Error ? error.message : String(error);
|
|
201
373
|
}
|
package/lib/config.ts
CHANGED
|
@@ -1,136 +1,144 @@
|
|
|
1
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
2
|
-
|
|
3
|
-
export const CONFIG_VERSION = 1;
|
|
4
|
-
export const DEFAULT_FALLBACK_STATUSES = [429, 500, 502, 503, 504] as const;
|
|
5
|
-
|
|
6
|
-
export interface ModelRef {
|
|
7
|
-
provider: string;
|
|
8
|
-
model: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface FallbackRule {
|
|
12
|
-
name?: string;
|
|
13
|
-
matchProviders?: string[];
|
|
14
|
-
matchModels?: ModelRef[];
|
|
15
|
-
statuses?: number[];
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
if (value.
|
|
83
|
-
if (value.
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
1
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
export const CONFIG_VERSION = 1;
|
|
4
|
+
export const DEFAULT_FALLBACK_STATUSES = [429, 500, 502, 503, 504] as const;
|
|
5
|
+
|
|
6
|
+
export interface ModelRef {
|
|
7
|
+
provider: string;
|
|
8
|
+
model: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FallbackRule {
|
|
12
|
+
name?: string;
|
|
13
|
+
matchProviders?: string[];
|
|
14
|
+
matchModels?: ModelRef[];
|
|
15
|
+
statuses?: number[];
|
|
16
|
+
/** Persistent failover cooldown in milliseconds after a matching failure. Defaults: 429 => 72h, 5xx => 10m. */
|
|
17
|
+
cooldownMs?: number;
|
|
18
|
+
fallback: ModelRef;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ModelFallbackConfig {
|
|
22
|
+
version: 1;
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
rules: FallbackRule[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MatchedFallback {
|
|
28
|
+
rule: FallbackRule;
|
|
29
|
+
fallback: ModelRef;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function defaultConfig(): ModelFallbackConfig {
|
|
33
|
+
return {
|
|
34
|
+
version: CONFIG_VERSION,
|
|
35
|
+
enabled: true,
|
|
36
|
+
rules: [
|
|
37
|
+
{
|
|
38
|
+
name: "zai-to-deepseek-flash",
|
|
39
|
+
matchProviders: ["zai"],
|
|
40
|
+
statuses: [...DEFAULT_FALLBACK_STATUSES],
|
|
41
|
+
fallback: { provider: "deepseek", model: "deepseek-v4-flash" },
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function validateConfigShape(value: unknown): ModelFallbackConfig {
|
|
48
|
+
if (!isRecord(value)) throw new Error("Config must be an object.");
|
|
49
|
+
if (value.version !== CONFIG_VERSION) throw new Error(`Unsupported config version: ${String(value.version)}.`);
|
|
50
|
+
const enabled = typeof value.enabled === "boolean" ? value.enabled : true;
|
|
51
|
+
if (!Array.isArray(value.rules)) throw new Error("rules must be an array.");
|
|
52
|
+
if (value.rules.length === 0) throw new Error("rules must include at least one fallback rule.");
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
version: CONFIG_VERSION,
|
|
56
|
+
enabled,
|
|
57
|
+
rules: value.rules.map((ruleValue, index) => validateRule(ruleValue, index)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function findFallback(config: ModelFallbackConfig, model: Pick<Model<any>, "provider" | "id">, status: number): MatchedFallback | undefined {
|
|
62
|
+
if (!config.enabled) return undefined;
|
|
63
|
+
for (const rule of config.rules) {
|
|
64
|
+
if (!statusesFor(rule).has(status)) continue;
|
|
65
|
+
if (!modelMatches(rule, model)) continue;
|
|
66
|
+
return { rule, fallback: rule.fallback };
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function modelRefKey(ref: ModelRef): string {
|
|
72
|
+
return `${ref.provider}/${ref.model}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function modelKey(model: Pick<Model<any>, "provider" | "id">): string {
|
|
76
|
+
return `${model.provider}/${model.id}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateRule(value: unknown, index: number): FallbackRule {
|
|
80
|
+
if (!isRecord(value)) throw new Error(`rules[${index}] must be an object.`);
|
|
81
|
+
const rule: FallbackRule = { fallback: readModelRef(value.fallback, `rules[${index}].fallback`) };
|
|
82
|
+
if (typeof value.name === "string" && value.name.trim() !== "") rule.name = value.name.trim();
|
|
83
|
+
if (value.matchProviders !== undefined) rule.matchProviders = readStringArray(value.matchProviders, `rules[${index}].matchProviders`);
|
|
84
|
+
if (value.matchModels !== undefined) rule.matchModels = readModelRefArray(value.matchModels, `rules[${index}].matchModels`);
|
|
85
|
+
if (value.statuses !== undefined) rule.statuses = readStatuses(value.statuses, `rules[${index}].statuses`);
|
|
86
|
+
if (value.cooldownMs !== undefined) rule.cooldownMs = readPositiveInteger(value.cooldownMs, `rules[${index}].cooldownMs`);
|
|
87
|
+
if ((!rule.matchProviders || rule.matchProviders.length === 0) && (!rule.matchModels || rule.matchModels.length === 0)) {
|
|
88
|
+
throw new Error(`rules[${index}] must define matchProviders or matchModels.`);
|
|
89
|
+
}
|
|
90
|
+
return rule;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function modelMatches(rule: FallbackRule, model: Pick<Model<any>, "provider" | "id">): boolean {
|
|
94
|
+
if (rule.matchModels?.some((entry) => entry.provider === model.provider && entry.model === model.id)) return true;
|
|
95
|
+
if (rule.matchProviders?.includes(model.provider)) return true;
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function statusesFor(rule: FallbackRule): Set<number> {
|
|
100
|
+
return new Set(rule.statuses && rule.statuses.length > 0 ? rule.statuses : DEFAULT_FALLBACK_STATUSES);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readModelRef(value: unknown, path: string): ModelRef {
|
|
104
|
+
if (!isRecord(value)) throw new Error(`${path} must be an object.`);
|
|
105
|
+
return {
|
|
106
|
+
provider: readNonEmptyString(value.provider, `${path}.provider`),
|
|
107
|
+
model: readNonEmptyString(value.model, `${path}.model`),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readModelRefArray(value: unknown, path: string): ModelRef[] {
|
|
112
|
+
if (!Array.isArray(value)) throw new Error(`${path} must be an array.`);
|
|
113
|
+
if (value.length === 0) throw new Error(`${path} must not be empty.`);
|
|
114
|
+
return value.map((entry, index) => readModelRef(entry, `${path}[${index}]`));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readStringArray(value: unknown, path: string): string[] {
|
|
118
|
+
if (!Array.isArray(value)) throw new Error(`${path} must be an array.`);
|
|
119
|
+
if (value.length === 0) throw new Error(`${path} must not be empty.`);
|
|
120
|
+
return value.map((entry, index) => readNonEmptyString(entry, `${path}[${index}]`));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readStatuses(value: unknown, path: string): number[] {
|
|
124
|
+
if (!Array.isArray(value)) throw new Error(`${path} must be an array.`);
|
|
125
|
+
if (value.length === 0) throw new Error(`${path} must not be empty.`);
|
|
126
|
+
return value.map((entry, index) => {
|
|
127
|
+
if (!Number.isInteger(entry) || entry < 100 || entry > 599) throw new Error(`${path}[${index}] must be an HTTP status code.`);
|
|
128
|
+
return entry;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readPositiveInteger(value: unknown, path: string): number {
|
|
133
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new Error(`${path} must be a positive integer.`);
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readNonEmptyString(value: unknown, path: string): string {
|
|
138
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
|
|
139
|
+
return value.trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
143
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
144
|
+
}
|