@smart-coders-hq/opencode-model-fallback 1.0.3 → 1.0.5
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 +16 -12
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +716 -688
- package/dist/src/config/defaults.d.ts.map +1 -1
- package/dist/src/config/schema.d.ts +4 -0
- package/dist/src/config/schema.d.ts.map +1 -1
- package/dist/src/display/notifier.d.ts +1 -1
- package/dist/src/display/notifier.d.ts.map +1 -1
- package/dist/src/logging/logger.d.ts +2 -1
- package/dist/src/logging/logger.d.ts.map +1 -1
- package/dist/src/plugin.d.ts.map +1 -1
- package/dist/src/preemptive.d.ts +2 -2
- package/dist/src/preemptive.d.ts.map +1 -1
- package/dist/src/replay/message-converter.d.ts.map +1 -1
- package/dist/src/replay/orchestrator.d.ts +2 -2
- package/dist/src/replay/orchestrator.d.ts.map +1 -1
- package/dist/src/resolution/agent-resolver.d.ts +1 -1
- package/dist/src/resolution/agent-resolver.d.ts.map +1 -1
- package/dist/src/resolution/fallback-resolver.d.ts +1 -1
- package/dist/src/resolution/fallback-resolver.d.ts.map +1 -1
- package/dist/src/state/model-health.d.ts +1 -1
- package/dist/src/state/model-health.d.ts.map +1 -1
- package/dist/src/state/session-state.d.ts +1 -1
- package/dist/src/state/session-state.d.ts.map +1 -1
- package/dist/src/state/store.d.ts +2 -2
- package/dist/src/state/store.d.ts.map +1 -1
- package/dist/src/tools/fallback-status.d.ts +1 -1
- package/dist/src/tools/fallback-status.d.ts.map +1 -1
- package/dist/src/types.d.ts +1 -0
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,288 +1,10 @@
|
|
|
1
1
|
// src/plugin.ts
|
|
2
|
-
import { existsSync as
|
|
3
|
-
import { dirname as dirname2, join as join4 } from "path";
|
|
2
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
4
3
|
import { homedir as homedir5 } from "os";
|
|
5
|
-
|
|
6
|
-
// src/config/loader.ts
|
|
7
|
-
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
8
|
-
import { basename as basename2, join as join3 } from "path";
|
|
9
|
-
import { homedir as homedir4 } from "os";
|
|
10
|
-
|
|
11
|
-
// src/config/schema.ts
|
|
12
|
-
import { z } from "zod";
|
|
13
|
-
import { homedir as homedir2 } from "os";
|
|
14
|
-
import { isAbsolute, relative, resolve } from "path";
|
|
15
|
-
|
|
16
|
-
// src/config/defaults.ts
|
|
17
|
-
import { homedir } from "os";
|
|
18
|
-
import { join } from "path";
|
|
19
|
-
var DEFAULT_PATTERNS = [
|
|
20
|
-
"rate limit",
|
|
21
|
-
"usage limit",
|
|
22
|
-
"too many requests",
|
|
23
|
-
"quota exceeded",
|
|
24
|
-
"overloaded",
|
|
25
|
-
"capacity exceeded",
|
|
26
|
-
"credits exhausted",
|
|
27
|
-
"billing limit",
|
|
28
|
-
"429"
|
|
29
|
-
];
|
|
30
|
-
var DEFAULT_LOG_PATH = join(homedir(), ".local/share/opencode/logs/model-fallback.log");
|
|
31
|
-
var DEFAULT_CONFIG = {
|
|
32
|
-
enabled: true,
|
|
33
|
-
defaults: {
|
|
34
|
-
fallbackOn: ["rate_limit", "quota_exceeded", "5xx", "timeout", "overloaded"],
|
|
35
|
-
cooldownMs: 300000,
|
|
36
|
-
retryOriginalAfterMs: 900000,
|
|
37
|
-
maxFallbackDepth: 3
|
|
38
|
-
},
|
|
39
|
-
agents: {
|
|
40
|
-
"*": {
|
|
41
|
-
fallbackModels: []
|
|
42
|
-
}
|
|
43
|
-
},
|
|
44
|
-
patterns: DEFAULT_PATTERNS,
|
|
45
|
-
logging: true,
|
|
46
|
-
logPath: DEFAULT_LOG_PATH,
|
|
47
|
-
agentDirs: []
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
// src/config/schema.ts
|
|
51
|
-
var MODEL_KEY_RE = /^[a-zA-Z0-9_-]{1,100}\/[a-zA-Z0-9._-]{1,100}$/;
|
|
52
|
-
var home = resolve(homedir2());
|
|
53
|
-
function formatPath(path) {
|
|
54
|
-
return path.map((segment) => String(segment)).join(".");
|
|
55
|
-
}
|
|
56
|
-
function normalizeLogPath(path) {
|
|
57
|
-
if (path.startsWith("~/")) {
|
|
58
|
-
return resolve(home, path.slice(2));
|
|
59
|
-
}
|
|
60
|
-
return resolve(path);
|
|
61
|
-
}
|
|
62
|
-
function isPathWithinHome(path) {
|
|
63
|
-
const rel = relative(home, path);
|
|
64
|
-
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
65
|
-
}
|
|
66
|
-
var modelKey = z.string().regex(MODEL_KEY_RE, "Model key must be 'providerID/modelID'");
|
|
67
|
-
var agentConfig = z.object({
|
|
68
|
-
fallbackModels: z.array(modelKey).min(1)
|
|
69
|
-
});
|
|
70
|
-
var fallbackDefaults = z.object({
|
|
71
|
-
fallbackOn: z.array(z.enum(["rate_limit", "quota_exceeded", "5xx", "timeout", "overloaded"])).optional(),
|
|
72
|
-
cooldownMs: z.number().min(1e4).optional(),
|
|
73
|
-
retryOriginalAfterMs: z.number().min(1e4).optional(),
|
|
74
|
-
maxFallbackDepth: z.number().int().min(1).max(10).optional()
|
|
75
|
-
});
|
|
76
|
-
var logPathSchema = z.string().refine((p) => isPathWithinHome(normalizeLogPath(p)), "logPath must resolve within $HOME");
|
|
77
|
-
var pluginConfigSchema = z.object({
|
|
78
|
-
enabled: z.boolean().optional(),
|
|
79
|
-
defaults: fallbackDefaults.optional(),
|
|
80
|
-
agents: z.record(z.string(), agentConfig).optional(),
|
|
81
|
-
patterns: z.array(z.string()).optional(),
|
|
82
|
-
logging: z.boolean().optional(),
|
|
83
|
-
logPath: logPathSchema.optional(),
|
|
84
|
-
agentDirs: z.array(z.string()).optional()
|
|
85
|
-
}).strict();
|
|
86
|
-
function parseConfig(raw) {
|
|
87
|
-
const warnings = [];
|
|
88
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
89
|
-
warnings.push("Config warning at root: expected object — using default");
|
|
90
|
-
return { config: {}, warnings };
|
|
91
|
-
}
|
|
92
|
-
const obj = raw;
|
|
93
|
-
const allowed = new Set([
|
|
94
|
-
"enabled",
|
|
95
|
-
"defaults",
|
|
96
|
-
"agents",
|
|
97
|
-
"patterns",
|
|
98
|
-
"logging",
|
|
99
|
-
"logPath",
|
|
100
|
-
"agentDirs"
|
|
101
|
-
]);
|
|
102
|
-
for (const key of Object.keys(obj)) {
|
|
103
|
-
if (!allowed.has(key)) {
|
|
104
|
-
warnings.push(`Config warning at ${key}: unknown field — using default`);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
const config = {};
|
|
108
|
-
const enabledResult = z.boolean().safeParse(obj.enabled);
|
|
109
|
-
if (obj.enabled !== undefined) {
|
|
110
|
-
if (enabledResult.success) {
|
|
111
|
-
config.enabled = enabledResult.data;
|
|
112
|
-
} else {
|
|
113
|
-
warnings.push(`Config warning at enabled: ${enabledResult.error.issues[0].message} — using default`);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
if (obj.defaults !== undefined) {
|
|
117
|
-
if (!obj.defaults || typeof obj.defaults !== "object" || Array.isArray(obj.defaults)) {
|
|
118
|
-
warnings.push("Config warning at defaults: expected object — using default");
|
|
119
|
-
} else {
|
|
120
|
-
const defaultsObj = obj.defaults;
|
|
121
|
-
const parsedDefaults = {};
|
|
122
|
-
const fallbackOnResult = fallbackDefaults.shape.fallbackOn.safeParse(defaultsObj.fallbackOn);
|
|
123
|
-
if (defaultsObj.fallbackOn !== undefined) {
|
|
124
|
-
if (fallbackOnResult.success && fallbackOnResult.data !== undefined) {
|
|
125
|
-
parsedDefaults.fallbackOn = fallbackOnResult.data;
|
|
126
|
-
} else if (!fallbackOnResult.success) {
|
|
127
|
-
for (const issue of fallbackOnResult.error.issues) {
|
|
128
|
-
const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
|
|
129
|
-
warnings.push(`Config warning at defaults.fallbackOn${suffix}: ${issue.message} — using default`);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
const cooldownResult = fallbackDefaults.shape.cooldownMs.safeParse(defaultsObj.cooldownMs);
|
|
134
|
-
if (defaultsObj.cooldownMs !== undefined) {
|
|
135
|
-
if (cooldownResult.success && cooldownResult.data !== undefined) {
|
|
136
|
-
parsedDefaults.cooldownMs = cooldownResult.data;
|
|
137
|
-
} else if (!cooldownResult.success) {
|
|
138
|
-
for (const issue of cooldownResult.error.issues) {
|
|
139
|
-
const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
|
|
140
|
-
warnings.push(`Config warning at defaults.cooldownMs${suffix}: ${issue.message} — using default`);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
const retryResult = fallbackDefaults.shape.retryOriginalAfterMs.safeParse(defaultsObj.retryOriginalAfterMs);
|
|
145
|
-
if (defaultsObj.retryOriginalAfterMs !== undefined) {
|
|
146
|
-
if (retryResult.success && retryResult.data !== undefined) {
|
|
147
|
-
parsedDefaults.retryOriginalAfterMs = retryResult.data;
|
|
148
|
-
} else if (!retryResult.success) {
|
|
149
|
-
for (const issue of retryResult.error.issues) {
|
|
150
|
-
const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
|
|
151
|
-
warnings.push(`Config warning at defaults.retryOriginalAfterMs${suffix}: ${issue.message} — using default`);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
const depthResult = fallbackDefaults.shape.maxFallbackDepth.safeParse(defaultsObj.maxFallbackDepth);
|
|
156
|
-
if (defaultsObj.maxFallbackDepth !== undefined) {
|
|
157
|
-
if (depthResult.success && depthResult.data !== undefined) {
|
|
158
|
-
parsedDefaults.maxFallbackDepth = depthResult.data;
|
|
159
|
-
} else if (!depthResult.success) {
|
|
160
|
-
for (const issue of depthResult.error.issues) {
|
|
161
|
-
const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
|
|
162
|
-
warnings.push(`Config warning at defaults.maxFallbackDepth${suffix}: ${issue.message} — using default`);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
for (const key of Object.keys(defaultsObj)) {
|
|
167
|
-
if (!Object.hasOwn(fallbackDefaults.shape, key)) {
|
|
168
|
-
warnings.push(`Config warning at defaults.${key}: unknown field — using default`);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
if (Object.keys(parsedDefaults).length > 0) {
|
|
172
|
-
config.defaults = parsedDefaults;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
if (obj.agents !== undefined) {
|
|
177
|
-
if (!obj.agents || typeof obj.agents !== "object" || Array.isArray(obj.agents)) {
|
|
178
|
-
warnings.push("Config warning at agents: expected object — using default");
|
|
179
|
-
} else {
|
|
180
|
-
const parsedAgents = {};
|
|
181
|
-
for (const [agentName, agentValue] of Object.entries(obj.agents)) {
|
|
182
|
-
const agentResult = agentConfig.safeParse(agentValue);
|
|
183
|
-
if (agentResult.success) {
|
|
184
|
-
parsedAgents[agentName] = agentResult.data;
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
for (const issue of agentResult.error.issues) {
|
|
188
|
-
const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
|
|
189
|
-
warnings.push(`Config warning at agents.${agentName}${suffix}: ${issue.message} — using default`);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
config.agents = parsedAgents;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
if (obj.patterns !== undefined) {
|
|
196
|
-
const patternsResult = z.array(z.string()).safeParse(obj.patterns);
|
|
197
|
-
if (patternsResult.success) {
|
|
198
|
-
config.patterns = patternsResult.data;
|
|
199
|
-
} else {
|
|
200
|
-
for (const issue of patternsResult.error.issues) {
|
|
201
|
-
const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
|
|
202
|
-
warnings.push(`Config warning at patterns${suffix}: ${issue.message} — using default`);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
if (obj.logging !== undefined) {
|
|
207
|
-
const loggingResult = z.boolean().safeParse(obj.logging);
|
|
208
|
-
if (loggingResult.success) {
|
|
209
|
-
config.logging = loggingResult.data;
|
|
210
|
-
} else {
|
|
211
|
-
warnings.push(`Config warning at logging: ${loggingResult.error.issues[0].message} — using default`);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (obj.logPath !== undefined) {
|
|
215
|
-
const logPathResult = logPathSchema.safeParse(obj.logPath);
|
|
216
|
-
if (logPathResult.success) {
|
|
217
|
-
config.logPath = obj.logPath;
|
|
218
|
-
} else {
|
|
219
|
-
for (const issue of logPathResult.error.issues) {
|
|
220
|
-
const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
|
|
221
|
-
warnings.push(`Config warning at logPath${suffix}: ${issue.message} — using default`);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
if (obj.agentDirs !== undefined) {
|
|
226
|
-
const agentDirsResult = z.array(z.string()).safeParse(obj.agentDirs);
|
|
227
|
-
if (agentDirsResult.success) {
|
|
228
|
-
config.agentDirs = agentDirsResult.data;
|
|
229
|
-
} else {
|
|
230
|
-
for (const issue of agentDirsResult.error.issues) {
|
|
231
|
-
const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
|
|
232
|
-
warnings.push(`Config warning at agentDirs${suffix}: ${issue.message} — using default`);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
return { config, warnings };
|
|
237
|
-
}
|
|
238
|
-
function mergeWithDefaults(raw) {
|
|
239
|
-
const def = DEFAULT_CONFIG;
|
|
240
|
-
const logPath = raw.logPath ? normalizeLogPath(raw.logPath) : def.logPath;
|
|
241
|
-
return {
|
|
242
|
-
enabled: raw.enabled ?? def.enabled,
|
|
243
|
-
defaults: {
|
|
244
|
-
fallbackOn: raw.defaults?.fallbackOn ?? def.defaults.fallbackOn,
|
|
245
|
-
cooldownMs: raw.defaults?.cooldownMs ?? def.defaults.cooldownMs,
|
|
246
|
-
retryOriginalAfterMs: raw.defaults?.retryOriginalAfterMs ?? def.defaults.retryOriginalAfterMs,
|
|
247
|
-
maxFallbackDepth: raw.defaults?.maxFallbackDepth ?? def.defaults.maxFallbackDepth
|
|
248
|
-
},
|
|
249
|
-
agents: raw.agents ?? def.agents,
|
|
250
|
-
patterns: raw.patterns ?? def.patterns,
|
|
251
|
-
logging: raw.logging ?? def.logging,
|
|
252
|
-
logPath,
|
|
253
|
-
agentDirs: raw.agentDirs ?? def.agentDirs
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// src/config/migrate.ts
|
|
258
|
-
function isOldFormat(raw) {
|
|
259
|
-
if (typeof raw !== "object" || raw === null)
|
|
260
|
-
return false;
|
|
261
|
-
return "fallbackModel" in raw && typeof raw.fallbackModel === "string";
|
|
262
|
-
}
|
|
263
|
-
function migrateOldConfig(old) {
|
|
264
|
-
const migrated = {};
|
|
265
|
-
if (typeof old.enabled === "boolean")
|
|
266
|
-
migrated.enabled = old.enabled;
|
|
267
|
-
if (typeof old.logging === "boolean")
|
|
268
|
-
migrated.logging = old.logging;
|
|
269
|
-
if (Array.isArray(old.patterns))
|
|
270
|
-
migrated.patterns = old.patterns;
|
|
271
|
-
if (old.fallbackModel) {
|
|
272
|
-
migrated.agents = {
|
|
273
|
-
"*": { fallbackModels: [old.fallbackModel] }
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
if (typeof old.cooldownMs === "number") {
|
|
277
|
-
migrated.defaults = { cooldownMs: old.cooldownMs };
|
|
278
|
-
}
|
|
279
|
-
return migrated;
|
|
280
|
-
}
|
|
4
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
281
5
|
|
|
282
6
|
// src/config/agent-loader.ts
|
|
283
|
-
import { existsSync,
|
|
284
|
-
import { homedir as homedir3 } from "os";
|
|
285
|
-
import { basename, extname, isAbsolute as isAbsolute2, join as join2, relative as relative2, resolve as resolve2 } from "path";
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "fs";
|
|
286
8
|
|
|
287
9
|
// node_modules/js-yaml/dist/js-yaml.mjs
|
|
288
10
|
/*! js-yaml 4.1.1 https://github.com/nodeca/js-yaml @license MIT */
|
|
@@ -2970,22 +2692,24 @@ var jsYaml = {
|
|
|
2970
2692
|
};
|
|
2971
2693
|
|
|
2972
2694
|
// src/config/agent-loader.ts
|
|
2973
|
-
|
|
2695
|
+
import { homedir } from "os";
|
|
2696
|
+
import { basename, extname, isAbsolute, join, relative, resolve } from "path";
|
|
2697
|
+
var MODEL_KEY_RE = /^[a-zA-Z0-9_-]{1,100}\/[a-zA-Z0-9._-]{1,100}$/;
|
|
2974
2698
|
function isPathInside(baseDir, targetPath) {
|
|
2975
|
-
const rel =
|
|
2976
|
-
return rel === "" || !rel.startsWith("..") && !
|
|
2699
|
+
const rel = relative(baseDir, targetPath);
|
|
2700
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
2977
2701
|
}
|
|
2978
|
-
function toRelativeAgentPath(absPath, projectDirectory, homeDir =
|
|
2979
|
-
const resolvedAbs =
|
|
2980
|
-
const configBase =
|
|
2981
|
-
const projectBase =
|
|
2702
|
+
function toRelativeAgentPath(absPath, projectDirectory, homeDir = homedir()) {
|
|
2703
|
+
const resolvedAbs = resolve(absPath);
|
|
2704
|
+
const configBase = resolve(join(homeDir, ".config", "opencode"));
|
|
2705
|
+
const projectBase = resolve(projectDirectory);
|
|
2982
2706
|
if (isPathInside(configBase, resolvedAbs)) {
|
|
2983
|
-
const rel =
|
|
2707
|
+
const rel = relative(configBase, resolvedAbs);
|
|
2984
2708
|
if (rel)
|
|
2985
2709
|
return rel;
|
|
2986
2710
|
}
|
|
2987
2711
|
if (isPathInside(projectBase, resolvedAbs)) {
|
|
2988
|
-
const rel =
|
|
2712
|
+
const rel = relative(projectBase, resolvedAbs);
|
|
2989
2713
|
if (rel)
|
|
2990
2714
|
return rel;
|
|
2991
2715
|
}
|
|
@@ -2999,7 +2723,7 @@ function stemName(filePath) {
|
|
|
2999
2723
|
function collectFiles(dir, recursive) {
|
|
3000
2724
|
if (!existsSync(dir))
|
|
3001
2725
|
return [];
|
|
3002
|
-
const baseDir =
|
|
2726
|
+
const baseDir = resolve(dir);
|
|
3003
2727
|
let baseRealPath = baseDir;
|
|
3004
2728
|
try {
|
|
3005
2729
|
baseRealPath = realpathSync(baseDir);
|
|
@@ -3014,7 +2738,7 @@ function collectFiles(dir, recursive) {
|
|
|
3014
2738
|
entries = readdirSync(baseDir);
|
|
3015
2739
|
}
|
|
3016
2740
|
return entries.filter((e) => e.endsWith(".md") || e.endsWith(".json")).map((e) => {
|
|
3017
|
-
const candidatePath =
|
|
2741
|
+
const candidatePath = resolve(join(baseDir, e));
|
|
3018
2742
|
if (!isPathInside(baseDir, candidatePath))
|
|
3019
2743
|
return null;
|
|
3020
2744
|
try {
|
|
@@ -3066,8 +2790,8 @@ function parseAgentFile(filePath) {
|
|
|
3066
2790
|
return null;
|
|
3067
2791
|
const validModels = [];
|
|
3068
2792
|
for (const m of models) {
|
|
3069
|
-
if (typeof m !== "string" || !
|
|
3070
|
-
console.warn(`[model-fallback] agent-loader: skipping invalid model key ${JSON.stringify(m)} in ${filePath}`);
|
|
2793
|
+
if (typeof m !== "string" || !MODEL_KEY_RE.test(m)) {
|
|
2794
|
+
console.warn(`[model-fallback] agent-loader: skipping invalid model key ${JSON.stringify(m)} in ${basename(filePath)}`);
|
|
3071
2795
|
continue;
|
|
3072
2796
|
}
|
|
3073
2797
|
validModels.push(m);
|
|
@@ -3077,16 +2801,16 @@ function parseAgentFile(filePath) {
|
|
|
3077
2801
|
const name = typeof obj.name === "string" && obj.name.length > 0 ? obj.name : stemName(filePath);
|
|
3078
2802
|
return { name, config: { fallbackModels: validModels } };
|
|
3079
2803
|
} catch (err) {
|
|
3080
|
-
console.warn(`[model-fallback] agent-loader: failed to parse ${filePath}:`, err);
|
|
2804
|
+
console.warn(`[model-fallback] agent-loader: failed to parse ${basename(filePath)}:`, err);
|
|
3081
2805
|
return null;
|
|
3082
2806
|
}
|
|
3083
2807
|
}
|
|
3084
|
-
function resolveAgentFile(agentName, projectDirectory, customDirs, homeDir =
|
|
2808
|
+
function resolveAgentFile(agentName, projectDirectory, customDirs, homeDir = homedir()) {
|
|
3085
2809
|
const scanDirs = customDirs && customDirs.length > 0 ? customDirs.map((d) => [d, false]) : [
|
|
3086
|
-
[
|
|
3087
|
-
[
|
|
3088
|
-
[
|
|
3089
|
-
[
|
|
2810
|
+
[join(homeDir, ".config", "opencode", "agents"), false],
|
|
2811
|
+
[join(homeDir, ".config", "opencode", "agent"), true],
|
|
2812
|
+
[join(projectDirectory, ".opencode", "agents"), false],
|
|
2813
|
+
[join(projectDirectory, ".opencode", "agent"), true]
|
|
3090
2814
|
];
|
|
3091
2815
|
const allFiles = [];
|
|
3092
2816
|
for (const [dir, recursive] of scanDirs) {
|
|
@@ -3107,12 +2831,12 @@ function resolveAgentFile(agentName, projectDirectory, customDirs, homeDir = hom
|
|
|
3107
2831
|
}
|
|
3108
2832
|
return null;
|
|
3109
2833
|
}
|
|
3110
|
-
function loadAgentFallbackConfigs(projectDirectory, homeDir =
|
|
2834
|
+
function loadAgentFallbackConfigs(projectDirectory, homeDir = homedir()) {
|
|
3111
2835
|
const scanDirs = [
|
|
3112
|
-
[
|
|
3113
|
-
[
|
|
3114
|
-
[
|
|
3115
|
-
[
|
|
2836
|
+
[join(homeDir, ".config", "opencode", "agents"), false],
|
|
2837
|
+
[join(homeDir, ".config", "opencode", "agent"), true],
|
|
2838
|
+
[join(projectDirectory, ".opencode", "agents"), false],
|
|
2839
|
+
[join(projectDirectory, ".opencode", "agent"), true]
|
|
3116
2840
|
];
|
|
3117
2841
|
const result = {};
|
|
3118
2842
|
for (const [dir, recursive] of scanDirs) {
|
|
@@ -3127,6 +2851,292 @@ function loadAgentFallbackConfigs(projectDirectory, homeDir = homedir3()) {
|
|
|
3127
2851
|
return result;
|
|
3128
2852
|
}
|
|
3129
2853
|
|
|
2854
|
+
// src/config/loader.ts
|
|
2855
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
2856
|
+
import { homedir as homedir4 } from "os";
|
|
2857
|
+
import { basename as basename2, join as join3 } from "path";
|
|
2858
|
+
|
|
2859
|
+
// src/config/defaults.ts
|
|
2860
|
+
import { homedir as homedir2 } from "os";
|
|
2861
|
+
import { join as join2 } from "path";
|
|
2862
|
+
var DEFAULT_PATTERNS = [
|
|
2863
|
+
"rate limit",
|
|
2864
|
+
"usage limit",
|
|
2865
|
+
"too many requests",
|
|
2866
|
+
"quota exceeded",
|
|
2867
|
+
"overloaded",
|
|
2868
|
+
"capacity exceeded",
|
|
2869
|
+
"credits exhausted",
|
|
2870
|
+
"billing limit",
|
|
2871
|
+
"429"
|
|
2872
|
+
];
|
|
2873
|
+
var DEFAULT_LOG_PATH = join2(homedir2(), ".local/share/opencode/logs/model-fallback.log");
|
|
2874
|
+
var DEFAULT_CONFIG = {
|
|
2875
|
+
enabled: true,
|
|
2876
|
+
defaults: {
|
|
2877
|
+
fallbackOn: ["rate_limit", "quota_exceeded", "5xx", "timeout", "overloaded"],
|
|
2878
|
+
cooldownMs: 300000,
|
|
2879
|
+
retryOriginalAfterMs: 900000,
|
|
2880
|
+
maxFallbackDepth: 3
|
|
2881
|
+
},
|
|
2882
|
+
agents: {
|
|
2883
|
+
"*": {
|
|
2884
|
+
fallbackModels: []
|
|
2885
|
+
}
|
|
2886
|
+
},
|
|
2887
|
+
patterns: DEFAULT_PATTERNS,
|
|
2888
|
+
logging: true,
|
|
2889
|
+
logLevel: "info",
|
|
2890
|
+
logPath: DEFAULT_LOG_PATH,
|
|
2891
|
+
agentDirs: []
|
|
2892
|
+
};
|
|
2893
|
+
|
|
2894
|
+
// src/config/migrate.ts
|
|
2895
|
+
function isOldFormat(raw) {
|
|
2896
|
+
if (typeof raw !== "object" || raw === null)
|
|
2897
|
+
return false;
|
|
2898
|
+
return "fallbackModel" in raw && typeof raw.fallbackModel === "string";
|
|
2899
|
+
}
|
|
2900
|
+
function migrateOldConfig(old) {
|
|
2901
|
+
const migrated = {};
|
|
2902
|
+
if (typeof old.enabled === "boolean")
|
|
2903
|
+
migrated.enabled = old.enabled;
|
|
2904
|
+
if (typeof old.logging === "boolean")
|
|
2905
|
+
migrated.logging = old.logging;
|
|
2906
|
+
if (Array.isArray(old.patterns))
|
|
2907
|
+
migrated.patterns = old.patterns;
|
|
2908
|
+
if (old.fallbackModel) {
|
|
2909
|
+
migrated.agents = {
|
|
2910
|
+
"*": { fallbackModels: [old.fallbackModel] }
|
|
2911
|
+
};
|
|
2912
|
+
}
|
|
2913
|
+
if (typeof old.cooldownMs === "number") {
|
|
2914
|
+
migrated.defaults = { cooldownMs: old.cooldownMs };
|
|
2915
|
+
}
|
|
2916
|
+
return migrated;
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
// src/config/schema.ts
|
|
2920
|
+
import { homedir as homedir3 } from "os";
|
|
2921
|
+
import { isAbsolute as isAbsolute2, relative as relative2, resolve as resolve2 } from "path";
|
|
2922
|
+
import { z } from "zod";
|
|
2923
|
+
var MODEL_KEY_RE2 = /^[a-zA-Z0-9_-]{1,100}\/[a-zA-Z0-9._-]{1,100}$/;
|
|
2924
|
+
var home = resolve2(homedir3());
|
|
2925
|
+
function formatPath(path) {
|
|
2926
|
+
return path.map((segment) => String(segment)).join(".");
|
|
2927
|
+
}
|
|
2928
|
+
function normalizeLogPath(path) {
|
|
2929
|
+
if (path.startsWith("~/")) {
|
|
2930
|
+
return resolve2(home, path.slice(2));
|
|
2931
|
+
}
|
|
2932
|
+
return resolve2(path);
|
|
2933
|
+
}
|
|
2934
|
+
function isPathWithinHome(path) {
|
|
2935
|
+
const rel = relative2(home, path);
|
|
2936
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
|
|
2937
|
+
}
|
|
2938
|
+
var modelKey = z.string().regex(MODEL_KEY_RE2, "Model key must be 'providerID/modelID'");
|
|
2939
|
+
var agentConfig = z.object({
|
|
2940
|
+
fallbackModels: z.array(modelKey).min(1)
|
|
2941
|
+
});
|
|
2942
|
+
var fallbackDefaults = z.object({
|
|
2943
|
+
fallbackOn: z.array(z.enum(["rate_limit", "quota_exceeded", "5xx", "timeout", "overloaded"])).optional(),
|
|
2944
|
+
cooldownMs: z.number().min(1e4).optional(),
|
|
2945
|
+
retryOriginalAfterMs: z.number().min(1e4).optional(),
|
|
2946
|
+
maxFallbackDepth: z.number().int().min(1).max(10).optional()
|
|
2947
|
+
});
|
|
2948
|
+
var logPathSchema = z.string().refine((p) => isPathWithinHome(normalizeLogPath(p)), "logPath must resolve within $HOME");
|
|
2949
|
+
var pluginConfigSchema = z.object({
|
|
2950
|
+
enabled: z.boolean().optional(),
|
|
2951
|
+
defaults: fallbackDefaults.optional(),
|
|
2952
|
+
agents: z.record(z.string(), agentConfig).optional(),
|
|
2953
|
+
patterns: z.array(z.string()).optional(),
|
|
2954
|
+
logging: z.boolean().optional(),
|
|
2955
|
+
logLevel: z.enum(["debug", "info"]).optional(),
|
|
2956
|
+
logPath: logPathSchema.optional(),
|
|
2957
|
+
agentDirs: z.array(z.string()).optional()
|
|
2958
|
+
}).strict();
|
|
2959
|
+
function parseConfig(raw) {
|
|
2960
|
+
const warnings = [];
|
|
2961
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
2962
|
+
warnings.push("Config warning at root: expected object — using default");
|
|
2963
|
+
return { config: {}, warnings };
|
|
2964
|
+
}
|
|
2965
|
+
const obj = raw;
|
|
2966
|
+
const allowed = new Set([
|
|
2967
|
+
"enabled",
|
|
2968
|
+
"defaults",
|
|
2969
|
+
"agents",
|
|
2970
|
+
"patterns",
|
|
2971
|
+
"logging",
|
|
2972
|
+
"logLevel",
|
|
2973
|
+
"logPath",
|
|
2974
|
+
"agentDirs"
|
|
2975
|
+
]);
|
|
2976
|
+
for (const key of Object.keys(obj)) {
|
|
2977
|
+
if (!allowed.has(key)) {
|
|
2978
|
+
warnings.push(`Config warning at ${key}: unknown field — using default`);
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
const config = {};
|
|
2982
|
+
const enabledResult = z.boolean().safeParse(obj.enabled);
|
|
2983
|
+
if (obj.enabled !== undefined) {
|
|
2984
|
+
if (enabledResult.success) {
|
|
2985
|
+
config.enabled = enabledResult.data;
|
|
2986
|
+
} else {
|
|
2987
|
+
warnings.push(`Config warning at enabled: ${enabledResult.error.issues[0].message} — using default`);
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
if (obj.defaults !== undefined) {
|
|
2991
|
+
if (!obj.defaults || typeof obj.defaults !== "object" || Array.isArray(obj.defaults)) {
|
|
2992
|
+
warnings.push("Config warning at defaults: expected object — using default");
|
|
2993
|
+
} else {
|
|
2994
|
+
const defaultsObj = obj.defaults;
|
|
2995
|
+
const parsedDefaults = {};
|
|
2996
|
+
const fallbackOnResult = fallbackDefaults.shape.fallbackOn.safeParse(defaultsObj.fallbackOn);
|
|
2997
|
+
if (defaultsObj.fallbackOn !== undefined) {
|
|
2998
|
+
if (fallbackOnResult.success && fallbackOnResult.data !== undefined) {
|
|
2999
|
+
parsedDefaults.fallbackOn = fallbackOnResult.data;
|
|
3000
|
+
} else if (!fallbackOnResult.success) {
|
|
3001
|
+
for (const issue of fallbackOnResult.error.issues) {
|
|
3002
|
+
const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
|
|
3003
|
+
warnings.push(`Config warning at defaults.fallbackOn${suffix}: ${issue.message} — using default`);
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
const cooldownResult = fallbackDefaults.shape.cooldownMs.safeParse(defaultsObj.cooldownMs);
|
|
3008
|
+
if (defaultsObj.cooldownMs !== undefined) {
|
|
3009
|
+
if (cooldownResult.success && cooldownResult.data !== undefined) {
|
|
3010
|
+
parsedDefaults.cooldownMs = cooldownResult.data;
|
|
3011
|
+
} else if (!cooldownResult.success) {
|
|
3012
|
+
for (const issue of cooldownResult.error.issues) {
|
|
3013
|
+
const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
|
|
3014
|
+
warnings.push(`Config warning at defaults.cooldownMs${suffix}: ${issue.message} — using default`);
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
const retryResult = fallbackDefaults.shape.retryOriginalAfterMs.safeParse(defaultsObj.retryOriginalAfterMs);
|
|
3019
|
+
if (defaultsObj.retryOriginalAfterMs !== undefined) {
|
|
3020
|
+
if (retryResult.success && retryResult.data !== undefined) {
|
|
3021
|
+
parsedDefaults.retryOriginalAfterMs = retryResult.data;
|
|
3022
|
+
} else if (!retryResult.success) {
|
|
3023
|
+
for (const issue of retryResult.error.issues) {
|
|
3024
|
+
const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
|
|
3025
|
+
warnings.push(`Config warning at defaults.retryOriginalAfterMs${suffix}: ${issue.message} — using default`);
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
const depthResult = fallbackDefaults.shape.maxFallbackDepth.safeParse(defaultsObj.maxFallbackDepth);
|
|
3030
|
+
if (defaultsObj.maxFallbackDepth !== undefined) {
|
|
3031
|
+
if (depthResult.success && depthResult.data !== undefined) {
|
|
3032
|
+
parsedDefaults.maxFallbackDepth = depthResult.data;
|
|
3033
|
+
} else if (!depthResult.success) {
|
|
3034
|
+
for (const issue of depthResult.error.issues) {
|
|
3035
|
+
const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
|
|
3036
|
+
warnings.push(`Config warning at defaults.maxFallbackDepth${suffix}: ${issue.message} — using default`);
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
for (const key of Object.keys(defaultsObj)) {
|
|
3041
|
+
if (!Object.hasOwn(fallbackDefaults.shape, key)) {
|
|
3042
|
+
warnings.push(`Config warning at defaults.${key}: unknown field — using default`);
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
if (Object.keys(parsedDefaults).length > 0) {
|
|
3046
|
+
config.defaults = parsedDefaults;
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
if (obj.agents !== undefined) {
|
|
3051
|
+
if (!obj.agents || typeof obj.agents !== "object" || Array.isArray(obj.agents)) {
|
|
3052
|
+
warnings.push("Config warning at agents: expected object — using default");
|
|
3053
|
+
} else {
|
|
3054
|
+
const parsedAgents = {};
|
|
3055
|
+
for (const [agentName, agentValue] of Object.entries(obj.agents)) {
|
|
3056
|
+
const agentResult = agentConfig.safeParse(agentValue);
|
|
3057
|
+
if (agentResult.success) {
|
|
3058
|
+
parsedAgents[agentName] = agentResult.data;
|
|
3059
|
+
continue;
|
|
3060
|
+
}
|
|
3061
|
+
for (const issue of agentResult.error.issues) {
|
|
3062
|
+
const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
|
|
3063
|
+
warnings.push(`Config warning at agents.${agentName}${suffix}: ${issue.message} — using default`);
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
config.agents = parsedAgents;
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
if (obj.patterns !== undefined) {
|
|
3070
|
+
const patternsResult = z.array(z.string()).safeParse(obj.patterns);
|
|
3071
|
+
if (patternsResult.success) {
|
|
3072
|
+
config.patterns = patternsResult.data;
|
|
3073
|
+
} else {
|
|
3074
|
+
for (const issue of patternsResult.error.issues) {
|
|
3075
|
+
const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
|
|
3076
|
+
warnings.push(`Config warning at patterns${suffix}: ${issue.message} — using default`);
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
if (obj.logging !== undefined) {
|
|
3081
|
+
const loggingResult = z.boolean().safeParse(obj.logging);
|
|
3082
|
+
if (loggingResult.success) {
|
|
3083
|
+
config.logging = loggingResult.data;
|
|
3084
|
+
} else {
|
|
3085
|
+
warnings.push(`Config warning at logging: ${loggingResult.error.issues[0].message} — using default`);
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
if (obj.logLevel !== undefined) {
|
|
3089
|
+
const logLevelResult = z.enum(["debug", "info"]).safeParse(obj.logLevel);
|
|
3090
|
+
if (logLevelResult.success) {
|
|
3091
|
+
config.logLevel = logLevelResult.data;
|
|
3092
|
+
} else {
|
|
3093
|
+
warnings.push(`Config warning at logLevel: ${logLevelResult.error.issues[0].message} — using default`);
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
if (obj.logPath !== undefined) {
|
|
3097
|
+
const logPathResult = logPathSchema.safeParse(obj.logPath);
|
|
3098
|
+
if (logPathResult.success) {
|
|
3099
|
+
config.logPath = obj.logPath;
|
|
3100
|
+
} else {
|
|
3101
|
+
for (const issue of logPathResult.error.issues) {
|
|
3102
|
+
const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
|
|
3103
|
+
warnings.push(`Config warning at logPath${suffix}: ${issue.message} — using default`);
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
if (obj.agentDirs !== undefined) {
|
|
3108
|
+
const agentDirsResult = z.array(z.string()).safeParse(obj.agentDirs);
|
|
3109
|
+
if (agentDirsResult.success) {
|
|
3110
|
+
config.agentDirs = agentDirsResult.data;
|
|
3111
|
+
} else {
|
|
3112
|
+
for (const issue of agentDirsResult.error.issues) {
|
|
3113
|
+
const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
|
|
3114
|
+
warnings.push(`Config warning at agentDirs${suffix}: ${issue.message} — using default`);
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
return { config, warnings };
|
|
3119
|
+
}
|
|
3120
|
+
function mergeWithDefaults(raw) {
|
|
3121
|
+
const def = DEFAULT_CONFIG;
|
|
3122
|
+
const logPath = raw.logPath ? normalizeLogPath(raw.logPath) : def.logPath;
|
|
3123
|
+
return {
|
|
3124
|
+
enabled: raw.enabled ?? def.enabled,
|
|
3125
|
+
defaults: {
|
|
3126
|
+
fallbackOn: raw.defaults?.fallbackOn ?? def.defaults.fallbackOn,
|
|
3127
|
+
cooldownMs: raw.defaults?.cooldownMs ?? def.defaults.cooldownMs,
|
|
3128
|
+
retryOriginalAfterMs: raw.defaults?.retryOriginalAfterMs ?? def.defaults.retryOriginalAfterMs,
|
|
3129
|
+
maxFallbackDepth: raw.defaults?.maxFallbackDepth ?? def.defaults.maxFallbackDepth
|
|
3130
|
+
},
|
|
3131
|
+
agents: raw.agents ?? def.agents,
|
|
3132
|
+
patterns: raw.patterns ?? def.patterns,
|
|
3133
|
+
logging: raw.logging ?? def.logging,
|
|
3134
|
+
logLevel: raw.logLevel ?? def.logLevel,
|
|
3135
|
+
logPath,
|
|
3136
|
+
agentDirs: raw.agentDirs ?? def.agentDirs
|
|
3137
|
+
};
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3130
3140
|
// src/config/loader.ts
|
|
3131
3141
|
var CONFIG_FILENAME = "model-fallback.json";
|
|
3132
3142
|
var OLD_CONFIG_FILENAME = "rate-limit-fallback.json";
|
|
@@ -3164,281 +3174,24 @@ function loadConfig(directory) {
|
|
|
3164
3174
|
raw = migrateOldConfig(raw);
|
|
3165
3175
|
}
|
|
3166
3176
|
const { config: parsed, warnings } = parseConfig(raw);
|
|
3167
|
-
const merged = mergeWithDefaults(parsed);
|
|
3168
|
-
merged.agents = { ...agentFileConfigs, ...merged.agents };
|
|
3169
|
-
return {
|
|
3170
|
-
config: merged,
|
|
3171
|
-
path: candidate,
|
|
3172
|
-
warnings,
|
|
3173
|
-
migrated: isOld
|
|
3174
|
-
};
|
|
3175
|
-
}
|
|
3176
|
-
return {
|
|
3177
|
-
config: {
|
|
3178
|
-
...DEFAULT_CONFIG,
|
|
3179
|
-
agents: { ...agentFileConfigs, ...DEFAULT_CONFIG.agents }
|
|
3180
|
-
},
|
|
3181
|
-
path: null,
|
|
3182
|
-
warnings: [],
|
|
3183
|
-
migrated: false
|
|
3184
|
-
};
|
|
3185
|
-
}
|
|
3186
|
-
|
|
3187
|
-
// src/logging/logger.ts
|
|
3188
|
-
import { appendFileSync, mkdirSync } from "fs";
|
|
3189
|
-
import { dirname } from "path";
|
|
3190
|
-
|
|
3191
|
-
class Logger {
|
|
3192
|
-
client;
|
|
3193
|
-
logPath;
|
|
3194
|
-
enabled;
|
|
3195
|
-
dirCreated = false;
|
|
3196
|
-
constructor(client, logPath, enabled) {
|
|
3197
|
-
this.client = client;
|
|
3198
|
-
this.logPath = logPath;
|
|
3199
|
-
this.enabled = enabled;
|
|
3200
|
-
}
|
|
3201
|
-
log(level, event, fields = {}) {
|
|
3202
|
-
const entry = {
|
|
3203
|
-
ts: new Date().toISOString(),
|
|
3204
|
-
level,
|
|
3205
|
-
event,
|
|
3206
|
-
...fields
|
|
3207
|
-
};
|
|
3208
|
-
if (this.enabled) {
|
|
3209
|
-
this.writeToFile(entry);
|
|
3210
|
-
}
|
|
3211
|
-
if (level !== "debug") {
|
|
3212
|
-
const message = `[model-fallback] ${event}${Object.keys(fields).length ? " " + JSON.stringify(fields) : ""}`;
|
|
3213
|
-
this.client.app.log({
|
|
3214
|
-
body: { service: "model-fallback", level, message }
|
|
3215
|
-
}).catch(() => {});
|
|
3216
|
-
}
|
|
3217
|
-
}
|
|
3218
|
-
info(event, fields) {
|
|
3219
|
-
this.log("info", event, fields);
|
|
3220
|
-
}
|
|
3221
|
-
warn(event, fields) {
|
|
3222
|
-
this.log("warn", event, fields);
|
|
3223
|
-
}
|
|
3224
|
-
error(event, fields) {
|
|
3225
|
-
this.log("error", event, fields);
|
|
3226
|
-
}
|
|
3227
|
-
debug(event, fields) {
|
|
3228
|
-
this.log("debug", event, fields);
|
|
3229
|
-
}
|
|
3230
|
-
writeToFile(entry) {
|
|
3231
|
-
try {
|
|
3232
|
-
if (!this.dirCreated) {
|
|
3233
|
-
mkdirSync(dirname(this.logPath), { recursive: true });
|
|
3234
|
-
this.dirCreated = true;
|
|
3235
|
-
}
|
|
3236
|
-
appendFileSync(this.logPath, JSON.stringify(entry) + `
|
|
3237
|
-
`, "utf-8");
|
|
3238
|
-
} catch {}
|
|
3239
|
-
}
|
|
3240
|
-
}
|
|
3241
|
-
|
|
3242
|
-
// src/state/model-health.ts
|
|
3243
|
-
class ModelHealthStore {
|
|
3244
|
-
store = new Map;
|
|
3245
|
-
timer = null;
|
|
3246
|
-
onTransition;
|
|
3247
|
-
constructor(opts) {
|
|
3248
|
-
this.onTransition = opts?.onTransition;
|
|
3249
|
-
this.timer = setInterval(() => this.tick(), 30000);
|
|
3250
|
-
}
|
|
3251
|
-
get(modelKey2) {
|
|
3252
|
-
return this.store.get(modelKey2) ?? this.newHealth(modelKey2);
|
|
3253
|
-
}
|
|
3254
|
-
markRateLimited(modelKey2, cooldownMs, retryOriginalAfterMs) {
|
|
3255
|
-
const now = Date.now();
|
|
3256
|
-
const existing = this.get(modelKey2);
|
|
3257
|
-
const health = {
|
|
3258
|
-
...existing,
|
|
3259
|
-
state: "rate_limited",
|
|
3260
|
-
lastFailure: now,
|
|
3261
|
-
failureCount: existing.failureCount + 1,
|
|
3262
|
-
cooldownExpiresAt: now + cooldownMs,
|
|
3263
|
-
retryOriginalAt: now + retryOriginalAfterMs
|
|
3264
|
-
};
|
|
3265
|
-
this.store.set(modelKey2, health);
|
|
3266
|
-
}
|
|
3267
|
-
isUsable(modelKey2) {
|
|
3268
|
-
const h = this.get(modelKey2);
|
|
3269
|
-
return h.state === "healthy" || h.state === "cooldown";
|
|
3270
|
-
}
|
|
3271
|
-
preferScore(modelKey2) {
|
|
3272
|
-
const state = this.get(modelKey2).state;
|
|
3273
|
-
if (state === "healthy")
|
|
3274
|
-
return 2;
|
|
3275
|
-
if (state === "cooldown")
|
|
3276
|
-
return 1;
|
|
3277
|
-
return 0;
|
|
3278
|
-
}
|
|
3279
|
-
getAll() {
|
|
3280
|
-
return Array.from(this.store.values());
|
|
3281
|
-
}
|
|
3282
|
-
destroy() {
|
|
3283
|
-
if (this.timer) {
|
|
3284
|
-
clearInterval(this.timer);
|
|
3285
|
-
this.timer = null;
|
|
3286
|
-
}
|
|
3287
|
-
}
|
|
3288
|
-
tick() {
|
|
3289
|
-
const now = Date.now();
|
|
3290
|
-
for (const [key, health] of this.store) {
|
|
3291
|
-
if (health.state === "rate_limited" && health.cooldownExpiresAt && now >= health.cooldownExpiresAt) {
|
|
3292
|
-
const next = { ...health, state: "cooldown" };
|
|
3293
|
-
this.store.set(key, next);
|
|
3294
|
-
this.onTransition?.(key, "rate_limited", "cooldown");
|
|
3295
|
-
} else if (health.state === "cooldown" && health.retryOriginalAt && now >= health.retryOriginalAt) {
|
|
3296
|
-
const next = {
|
|
3297
|
-
...health,
|
|
3298
|
-
state: "healthy",
|
|
3299
|
-
cooldownExpiresAt: null,
|
|
3300
|
-
retryOriginalAt: null
|
|
3301
|
-
};
|
|
3302
|
-
this.store.set(key, next);
|
|
3303
|
-
this.onTransition?.(key, "cooldown", "healthy");
|
|
3304
|
-
}
|
|
3305
|
-
}
|
|
3306
|
-
}
|
|
3307
|
-
newHealth(modelKey2) {
|
|
3308
|
-
return {
|
|
3309
|
-
modelKey: modelKey2,
|
|
3310
|
-
state: "healthy",
|
|
3311
|
-
lastFailure: null,
|
|
3312
|
-
failureCount: 0,
|
|
3313
|
-
cooldownExpiresAt: null,
|
|
3314
|
-
retryOriginalAt: null
|
|
3315
|
-
};
|
|
3316
|
-
}
|
|
3317
|
-
}
|
|
3318
|
-
|
|
3319
|
-
// src/state/session-state.ts
|
|
3320
|
-
class SessionStateStore {
|
|
3321
|
-
store = new Map;
|
|
3322
|
-
get(sessionId) {
|
|
3323
|
-
if (!this.store.has(sessionId)) {
|
|
3324
|
-
this.store.set(sessionId, this.newState(sessionId));
|
|
3325
|
-
}
|
|
3326
|
-
return this.store.get(sessionId);
|
|
3327
|
-
}
|
|
3328
|
-
acquireLock(sessionId) {
|
|
3329
|
-
const state = this.get(sessionId);
|
|
3330
|
-
if (state.isProcessing)
|
|
3331
|
-
return false;
|
|
3332
|
-
state.isProcessing = true;
|
|
3333
|
-
return true;
|
|
3334
|
-
}
|
|
3335
|
-
releaseLock(sessionId) {
|
|
3336
|
-
const state = this.store.get(sessionId);
|
|
3337
|
-
if (state)
|
|
3338
|
-
state.isProcessing = false;
|
|
3339
|
-
}
|
|
3340
|
-
isInDedupWindow(sessionId, windowMs = 3000) {
|
|
3341
|
-
const state = this.get(sessionId);
|
|
3342
|
-
if (!state.lastFallbackAt)
|
|
3343
|
-
return false;
|
|
3344
|
-
return Date.now() - state.lastFallbackAt < windowMs;
|
|
3345
|
-
}
|
|
3346
|
-
recordFallback(sessionId, fromModel, toModel, reason, agentName) {
|
|
3347
|
-
const state = this.get(sessionId);
|
|
3348
|
-
const event = {
|
|
3349
|
-
at: Date.now(),
|
|
3350
|
-
fromModel,
|
|
3351
|
-
toModel,
|
|
3352
|
-
reason,
|
|
3353
|
-
sessionId,
|
|
3354
|
-
trigger: "reactive",
|
|
3355
|
-
agentName
|
|
3356
|
-
};
|
|
3357
|
-
state.currentModel = toModel;
|
|
3358
|
-
state.fallbackDepth++;
|
|
3359
|
-
state.lastFallbackAt = event.at;
|
|
3360
|
-
state.recoveryNotifiedForModel = null;
|
|
3361
|
-
state.fallbackHistory.push(event);
|
|
3362
|
-
if (agentName)
|
|
3363
|
-
state.agentName = agentName;
|
|
3364
|
-
}
|
|
3365
|
-
recordPreemptiveRedirect(sessionId, fromModel, toModel, agentName) {
|
|
3366
|
-
const state = this.get(sessionId);
|
|
3367
|
-
const event = {
|
|
3368
|
-
at: Date.now(),
|
|
3369
|
-
fromModel,
|
|
3370
|
-
toModel,
|
|
3371
|
-
reason: "rate_limit",
|
|
3372
|
-
sessionId,
|
|
3373
|
-
trigger: "preemptive",
|
|
3374
|
-
agentName
|
|
3375
|
-
};
|
|
3376
|
-
state.currentModel = toModel;
|
|
3377
|
-
state.recoveryNotifiedForModel = null;
|
|
3378
|
-
state.fallbackHistory.push(event);
|
|
3379
|
-
if (agentName)
|
|
3380
|
-
state.agentName = agentName;
|
|
3381
|
-
}
|
|
3382
|
-
setOriginalModel(sessionId, model) {
|
|
3383
|
-
const state = this.get(sessionId);
|
|
3384
|
-
if (!state.originalModel) {
|
|
3385
|
-
state.originalModel = model;
|
|
3386
|
-
state.currentModel = model;
|
|
3387
|
-
}
|
|
3388
|
-
}
|
|
3389
|
-
setAgentName(sessionId, agentName) {
|
|
3390
|
-
const state = this.get(sessionId);
|
|
3391
|
-
state.agentName = agentName;
|
|
3392
|
-
}
|
|
3393
|
-
setAgentFile(sessionId, agentFile) {
|
|
3394
|
-
const state = this.get(sessionId);
|
|
3395
|
-
state.agentFile = agentFile;
|
|
3396
|
-
}
|
|
3397
|
-
partialReset(sessionId) {
|
|
3398
|
-
const state = this.store.get(sessionId);
|
|
3399
|
-
if (!state)
|
|
3400
|
-
return;
|
|
3401
|
-
state.fallbackHistory = [];
|
|
3402
|
-
state.lastFallbackAt = null;
|
|
3403
|
-
state.isProcessing = false;
|
|
3404
|
-
}
|
|
3405
|
-
delete(sessionId) {
|
|
3406
|
-
this.store.delete(sessionId);
|
|
3407
|
-
}
|
|
3408
|
-
getAll() {
|
|
3409
|
-
return Array.from(this.store.values());
|
|
3410
|
-
}
|
|
3411
|
-
newState(sessionId) {
|
|
3177
|
+
const merged = mergeWithDefaults(parsed);
|
|
3178
|
+
merged.agents = { ...agentFileConfigs, ...merged.agents };
|
|
3412
3179
|
return {
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
currentModel: null,
|
|
3418
|
-
fallbackDepth: 0,
|
|
3419
|
-
isProcessing: false,
|
|
3420
|
-
lastFallbackAt: null,
|
|
3421
|
-
fallbackHistory: [],
|
|
3422
|
-
recoveryNotifiedForModel: null
|
|
3180
|
+
config: merged,
|
|
3181
|
+
path: candidate,
|
|
3182
|
+
warnings,
|
|
3183
|
+
migrated: isOld
|
|
3423
3184
|
};
|
|
3424
3185
|
}
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
onTransition: (modelKey2, from, to) => {
|
|
3435
|
-
logger.info("health.transition", { modelKey: modelKey2, from, to });
|
|
3436
|
-
}
|
|
3437
|
-
});
|
|
3438
|
-
}
|
|
3439
|
-
destroy() {
|
|
3440
|
-
this.health.destroy();
|
|
3441
|
-
}
|
|
3186
|
+
return {
|
|
3187
|
+
config: {
|
|
3188
|
+
...DEFAULT_CONFIG,
|
|
3189
|
+
agents: { ...agentFileConfigs, ...DEFAULT_CONFIG.agents }
|
|
3190
|
+
},
|
|
3191
|
+
path: null,
|
|
3192
|
+
warnings: [],
|
|
3193
|
+
migrated: false
|
|
3194
|
+
};
|
|
3442
3195
|
}
|
|
3443
3196
|
|
|
3444
3197
|
// src/detection/patterns.ts
|
|
@@ -3494,6 +3247,107 @@ function classifyError(message, statusCode) {
|
|
|
3494
3247
|
return "unknown";
|
|
3495
3248
|
}
|
|
3496
3249
|
|
|
3250
|
+
// src/display/notifier.ts
|
|
3251
|
+
async function notifyFallback(client, from, to, reason) {
|
|
3252
|
+
const fromLabel = from ? shortModelName(from) : "current model";
|
|
3253
|
+
const message = `Model fallback: switched from ${fromLabel} to ${shortModelName(to)} (${reason})`;
|
|
3254
|
+
await client.tui.showToast({
|
|
3255
|
+
body: {
|
|
3256
|
+
title: "Model Fallback",
|
|
3257
|
+
message,
|
|
3258
|
+
variant: "warning",
|
|
3259
|
+
duration: 6000
|
|
3260
|
+
}
|
|
3261
|
+
}).catch(() => {});
|
|
3262
|
+
}
|
|
3263
|
+
async function notifyFallbackActive(client, originalModel, currentModel) {
|
|
3264
|
+
const message = `Using ${shortModelName(currentModel)} (fallback from ${shortModelName(originalModel)})`;
|
|
3265
|
+
await client.tui.showToast({
|
|
3266
|
+
body: {
|
|
3267
|
+
title: "Fallback Active",
|
|
3268
|
+
message,
|
|
3269
|
+
variant: "warning",
|
|
3270
|
+
duration: 4000
|
|
3271
|
+
}
|
|
3272
|
+
}).catch(() => {});
|
|
3273
|
+
}
|
|
3274
|
+
async function notifyRecovery(client, originalModel) {
|
|
3275
|
+
const message = `Original model ${shortModelName(originalModel)} is available again`;
|
|
3276
|
+
await client.tui.showToast({
|
|
3277
|
+
body: {
|
|
3278
|
+
title: "Model Recovered",
|
|
3279
|
+
message,
|
|
3280
|
+
variant: "info",
|
|
3281
|
+
duration: 5000
|
|
3282
|
+
}
|
|
3283
|
+
}).catch(() => {});
|
|
3284
|
+
}
|
|
3285
|
+
function shortModelName(key) {
|
|
3286
|
+
const parts = key.split("/");
|
|
3287
|
+
return parts.length > 1 ? parts.slice(1).join("/") : key;
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
// src/logging/logger.ts
|
|
3291
|
+
import { appendFileSync, existsSync as existsSync3, mkdirSync, writeFileSync } from "fs";
|
|
3292
|
+
import { dirname } from "path";
|
|
3293
|
+
|
|
3294
|
+
class Logger {
|
|
3295
|
+
client;
|
|
3296
|
+
logPath;
|
|
3297
|
+
enabled;
|
|
3298
|
+
minLevel;
|
|
3299
|
+
dirCreated = false;
|
|
3300
|
+
constructor(client, logPath, enabled, minLevel = "info") {
|
|
3301
|
+
this.client = client;
|
|
3302
|
+
this.logPath = logPath;
|
|
3303
|
+
this.enabled = enabled;
|
|
3304
|
+
this.minLevel = minLevel;
|
|
3305
|
+
}
|
|
3306
|
+
log(level, event, fields = {}) {
|
|
3307
|
+
const entry = {
|
|
3308
|
+
ts: new Date().toISOString(),
|
|
3309
|
+
level,
|
|
3310
|
+
event,
|
|
3311
|
+
...fields
|
|
3312
|
+
};
|
|
3313
|
+
const shouldWrite = this.enabled && (this.minLevel === "debug" || level !== "debug");
|
|
3314
|
+
if (shouldWrite) {
|
|
3315
|
+
this.writeToFile(entry);
|
|
3316
|
+
}
|
|
3317
|
+
if (level !== "debug") {
|
|
3318
|
+
const message = `[model-fallback] ${event}${Object.keys(fields).length ? " " + JSON.stringify(fields) : ""}`;
|
|
3319
|
+
this.client.app.log({
|
|
3320
|
+
body: { service: "model-fallback", level, message }
|
|
3321
|
+
}).catch(() => {});
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
info(event, fields) {
|
|
3325
|
+
this.log("info", event, fields);
|
|
3326
|
+
}
|
|
3327
|
+
warn(event, fields) {
|
|
3328
|
+
this.log("warn", event, fields);
|
|
3329
|
+
}
|
|
3330
|
+
error(event, fields) {
|
|
3331
|
+
this.log("error", event, fields);
|
|
3332
|
+
}
|
|
3333
|
+
debug(event, fields) {
|
|
3334
|
+
this.log("debug", event, fields);
|
|
3335
|
+
}
|
|
3336
|
+
writeToFile(entry) {
|
|
3337
|
+
try {
|
|
3338
|
+
if (!this.dirCreated) {
|
|
3339
|
+
mkdirSync(dirname(this.logPath), { recursive: true, mode: 448 });
|
|
3340
|
+
this.dirCreated = true;
|
|
3341
|
+
}
|
|
3342
|
+
if (!existsSync3(this.logPath)) {
|
|
3343
|
+
writeFileSync(this.logPath, "", { mode: 384 });
|
|
3344
|
+
}
|
|
3345
|
+
appendFileSync(this.logPath, JSON.stringify(entry) + `
|
|
3346
|
+
`, "utf-8");
|
|
3347
|
+
} catch {}
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3497
3351
|
// src/resolution/agent-resolver.ts
|
|
3498
3352
|
async function resolveAgentName(client, sessionId, cachedName) {
|
|
3499
3353
|
if (cachedName)
|
|
@@ -3533,10 +3387,53 @@ function resolveFallbackModel(chain, currentModel, health) {
|
|
|
3533
3387
|
return null;
|
|
3534
3388
|
}
|
|
3535
3389
|
|
|
3390
|
+
// src/preemptive.ts
|
|
3391
|
+
function tryPreemptiveRedirect(sessionId, modelKey2, agentName, store, config, logger) {
|
|
3392
|
+
const sessionState = store.sessions.get(sessionId);
|
|
3393
|
+
store.sessions.setOriginalModel(sessionId, modelKey2);
|
|
3394
|
+
if (sessionState.currentModel !== modelKey2) {
|
|
3395
|
+
const wasOnFallback = sessionState.currentModel !== null && sessionState.currentModel !== sessionState.originalModel;
|
|
3396
|
+
sessionState.currentModel = modelKey2;
|
|
3397
|
+
if (wasOnFallback && modelKey2 === sessionState.originalModel) {
|
|
3398
|
+
sessionState.fallbackDepth = 0;
|
|
3399
|
+
logger.debug("preemptive.depth.reset", { sessionId, modelKey: modelKey2 });
|
|
3400
|
+
}
|
|
3401
|
+
}
|
|
3402
|
+
const health = store.health.get(modelKey2);
|
|
3403
|
+
if (health.state !== "rate_limited") {
|
|
3404
|
+
return { redirected: false };
|
|
3405
|
+
}
|
|
3406
|
+
const chain = resolveFallbackModels(config, agentName);
|
|
3407
|
+
if (chain.length === 0) {
|
|
3408
|
+
logger.debug("preemptive.no-chain", { sessionId, agentName });
|
|
3409
|
+
return { redirected: false };
|
|
3410
|
+
}
|
|
3411
|
+
const fallbackModel = resolveFallbackModel(chain, modelKey2, store.health);
|
|
3412
|
+
if (!fallbackModel) {
|
|
3413
|
+
logger.debug("preemptive.all-exhausted", { sessionId });
|
|
3414
|
+
return { redirected: false };
|
|
3415
|
+
}
|
|
3416
|
+
logger.info("preemptive.redirect", {
|
|
3417
|
+
sessionId,
|
|
3418
|
+
agentName,
|
|
3419
|
+
agentFile: sessionState.agentFile,
|
|
3420
|
+
from: modelKey2,
|
|
3421
|
+
to: fallbackModel
|
|
3422
|
+
});
|
|
3423
|
+
store.sessions.recordPreemptiveRedirect(sessionId, modelKey2, fallbackModel, agentName);
|
|
3424
|
+
return { redirected: true, fallbackModel };
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3536
3427
|
// src/replay/message-converter.ts
|
|
3537
3428
|
function convertPartsForPrompt(parts) {
|
|
3538
3429
|
const result = [];
|
|
3430
|
+
if (!parts || !Array.isArray(parts)) {
|
|
3431
|
+
return result;
|
|
3432
|
+
}
|
|
3539
3433
|
for (const part of parts) {
|
|
3434
|
+
if (!part || typeof part !== "object" || !("type" in part)) {
|
|
3435
|
+
continue;
|
|
3436
|
+
}
|
|
3540
3437
|
if (part.type === "text") {
|
|
3541
3438
|
if (part.synthetic || part.ignored)
|
|
3542
3439
|
continue;
|
|
@@ -3689,86 +3586,250 @@ async function attemptFallback(sessionId, reason, client, store, config, logger,
|
|
|
3689
3586
|
if (promptParts.length === 0) {
|
|
3690
3587
|
promptParts.push({ type: "text", text: "" });
|
|
3691
3588
|
}
|
|
3692
|
-
const [providerID, ...rest] = fallbackModel.split("/");
|
|
3693
|
-
const modelID = rest.join("/");
|
|
3694
|
-
try {
|
|
3695
|
-
await client.session.prompt({
|
|
3696
|
-
path: { id: sessionId },
|
|
3697
|
-
body: {
|
|
3698
|
-
model: { providerID, modelID },
|
|
3699
|
-
agent: agentName ?? undefined,
|
|
3700
|
-
parts: promptParts
|
|
3701
|
-
}
|
|
3702
|
-
});
|
|
3703
|
-
logger.debug("replay.prompt.ok", { sessionId, fallbackModel });
|
|
3704
|
-
} catch (err) {
|
|
3705
|
-
logger.error("replay.prompt.failed", {
|
|
3706
|
-
sessionId,
|
|
3707
|
-
fallbackModel,
|
|
3708
|
-
err: String(err)
|
|
3709
|
-
});
|
|
3710
|
-
return { success: false, error: "prompt failed" };
|
|
3589
|
+
const [providerID, ...rest] = fallbackModel.split("/");
|
|
3590
|
+
const modelID = rest.join("/");
|
|
3591
|
+
try {
|
|
3592
|
+
await client.session.prompt({
|
|
3593
|
+
path: { id: sessionId },
|
|
3594
|
+
body: {
|
|
3595
|
+
model: { providerID, modelID },
|
|
3596
|
+
agent: agentName ?? undefined,
|
|
3597
|
+
parts: promptParts
|
|
3598
|
+
}
|
|
3599
|
+
});
|
|
3600
|
+
logger.debug("replay.prompt.ok", { sessionId, fallbackModel });
|
|
3601
|
+
} catch (err) {
|
|
3602
|
+
logger.error("replay.prompt.failed", {
|
|
3603
|
+
sessionId,
|
|
3604
|
+
fallbackModel,
|
|
3605
|
+
err: String(err)
|
|
3606
|
+
});
|
|
3607
|
+
return { success: false, error: "prompt failed" };
|
|
3608
|
+
}
|
|
3609
|
+
const newDepth = sessionState.fallbackDepth + 1;
|
|
3610
|
+
store.sessions.recordFallback(sessionId, currentModel ?? fallbackModel, fallbackModel, reason, agentName);
|
|
3611
|
+
logger.info("fallback.success", {
|
|
3612
|
+
sessionId,
|
|
3613
|
+
agentName,
|
|
3614
|
+
agentFile: store.sessions.get(sessionId).agentFile,
|
|
3615
|
+
from: currentModel,
|
|
3616
|
+
to: fallbackModel,
|
|
3617
|
+
reason,
|
|
3618
|
+
depth: newDepth
|
|
3619
|
+
});
|
|
3620
|
+
return { success: true, fallbackModel };
|
|
3621
|
+
} finally {
|
|
3622
|
+
store.sessions.releaseLock(sessionId);
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
function sanitizeParts(parts) {
|
|
3626
|
+
if (!Array.isArray(parts))
|
|
3627
|
+
return [];
|
|
3628
|
+
return parts.filter((part) => typeof part === "object" && part !== null && ("type" in part));
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
// src/state/model-health.ts
|
|
3632
|
+
class ModelHealthStore {
|
|
3633
|
+
store = new Map;
|
|
3634
|
+
timer = null;
|
|
3635
|
+
onTransition;
|
|
3636
|
+
constructor(opts) {
|
|
3637
|
+
this.onTransition = opts?.onTransition;
|
|
3638
|
+
this.timer = setInterval(() => this.tick(), 30000);
|
|
3639
|
+
}
|
|
3640
|
+
get(modelKey2) {
|
|
3641
|
+
return this.store.get(modelKey2) ?? this.newHealth(modelKey2);
|
|
3642
|
+
}
|
|
3643
|
+
markRateLimited(modelKey2, cooldownMs, retryOriginalAfterMs) {
|
|
3644
|
+
const now = Date.now();
|
|
3645
|
+
const existing = this.get(modelKey2);
|
|
3646
|
+
const health = {
|
|
3647
|
+
...existing,
|
|
3648
|
+
state: "rate_limited",
|
|
3649
|
+
lastFailure: now,
|
|
3650
|
+
failureCount: existing.failureCount + 1,
|
|
3651
|
+
cooldownExpiresAt: now + cooldownMs,
|
|
3652
|
+
retryOriginalAt: now + retryOriginalAfterMs
|
|
3653
|
+
};
|
|
3654
|
+
this.store.set(modelKey2, health);
|
|
3655
|
+
}
|
|
3656
|
+
isUsable(modelKey2) {
|
|
3657
|
+
const h = this.get(modelKey2);
|
|
3658
|
+
return h.state === "healthy" || h.state === "cooldown";
|
|
3659
|
+
}
|
|
3660
|
+
preferScore(modelKey2) {
|
|
3661
|
+
const state = this.get(modelKey2).state;
|
|
3662
|
+
if (state === "healthy")
|
|
3663
|
+
return 2;
|
|
3664
|
+
if (state === "cooldown")
|
|
3665
|
+
return 1;
|
|
3666
|
+
return 0;
|
|
3667
|
+
}
|
|
3668
|
+
getAll() {
|
|
3669
|
+
return Array.from(this.store.values());
|
|
3670
|
+
}
|
|
3671
|
+
destroy() {
|
|
3672
|
+
if (this.timer) {
|
|
3673
|
+
clearInterval(this.timer);
|
|
3674
|
+
this.timer = null;
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
tick() {
|
|
3678
|
+
const now = Date.now();
|
|
3679
|
+
for (const [key, health] of this.store) {
|
|
3680
|
+
if (health.state === "rate_limited" && health.cooldownExpiresAt && now >= health.cooldownExpiresAt) {
|
|
3681
|
+
const next = { ...health, state: "cooldown" };
|
|
3682
|
+
this.store.set(key, next);
|
|
3683
|
+
this.onTransition?.(key, "rate_limited", "cooldown");
|
|
3684
|
+
} else if (health.state === "cooldown" && health.retryOriginalAt && now >= health.retryOriginalAt) {
|
|
3685
|
+
const next = {
|
|
3686
|
+
...health,
|
|
3687
|
+
state: "healthy",
|
|
3688
|
+
cooldownExpiresAt: null,
|
|
3689
|
+
retryOriginalAt: null
|
|
3690
|
+
};
|
|
3691
|
+
this.store.set(key, next);
|
|
3692
|
+
this.onTransition?.(key, "cooldown", "healthy");
|
|
3693
|
+
}
|
|
3711
3694
|
}
|
|
3712
|
-
const newDepth = sessionState.fallbackDepth + 1;
|
|
3713
|
-
store.sessions.recordFallback(sessionId, currentModel ?? fallbackModel, fallbackModel, reason, agentName);
|
|
3714
|
-
logger.info("fallback.success", {
|
|
3715
|
-
sessionId,
|
|
3716
|
-
agentName,
|
|
3717
|
-
agentFile: store.sessions.get(sessionId).agentFile,
|
|
3718
|
-
from: currentModel,
|
|
3719
|
-
to: fallbackModel,
|
|
3720
|
-
reason,
|
|
3721
|
-
depth: newDepth
|
|
3722
|
-
});
|
|
3723
|
-
return { success: true, fallbackModel };
|
|
3724
|
-
} finally {
|
|
3725
|
-
store.sessions.releaseLock(sessionId);
|
|
3726
3695
|
}
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3696
|
+
newHealth(modelKey2) {
|
|
3697
|
+
return {
|
|
3698
|
+
modelKey: modelKey2,
|
|
3699
|
+
state: "healthy",
|
|
3700
|
+
lastFailure: null,
|
|
3701
|
+
failureCount: 0,
|
|
3702
|
+
cooldownExpiresAt: null,
|
|
3703
|
+
retryOriginalAt: null
|
|
3704
|
+
};
|
|
3705
|
+
}
|
|
3732
3706
|
}
|
|
3733
3707
|
|
|
3734
|
-
// src/
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
variant: "warning",
|
|
3743
|
-
duration: 6000
|
|
3744
|
-
}
|
|
3745
|
-
}).catch(() => {});
|
|
3746
|
-
}
|
|
3747
|
-
async function notifyFallbackActive(client, originalModel, currentModel) {
|
|
3748
|
-
const message = `Using ${shortModelName(currentModel)} (fallback from ${shortModelName(originalModel)})`;
|
|
3749
|
-
await client.tui.showToast({
|
|
3750
|
-
body: {
|
|
3751
|
-
title: "Fallback Active",
|
|
3752
|
-
message,
|
|
3753
|
-
variant: "warning",
|
|
3754
|
-
duration: 4000
|
|
3708
|
+
// src/state/session-state.ts
|
|
3709
|
+
class SessionStateStore {
|
|
3710
|
+
store = new Map;
|
|
3711
|
+
get(sessionId) {
|
|
3712
|
+
let state = this.store.get(sessionId);
|
|
3713
|
+
if (!state) {
|
|
3714
|
+
state = this.newState(sessionId);
|
|
3715
|
+
this.store.set(sessionId, state);
|
|
3755
3716
|
}
|
|
3756
|
-
|
|
3757
|
-
}
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3717
|
+
return state;
|
|
3718
|
+
}
|
|
3719
|
+
acquireLock(sessionId) {
|
|
3720
|
+
const state = this.get(sessionId);
|
|
3721
|
+
if (state.isProcessing)
|
|
3722
|
+
return false;
|
|
3723
|
+
state.isProcessing = true;
|
|
3724
|
+
return true;
|
|
3725
|
+
}
|
|
3726
|
+
releaseLock(sessionId) {
|
|
3727
|
+
const state = this.store.get(sessionId);
|
|
3728
|
+
if (state)
|
|
3729
|
+
state.isProcessing = false;
|
|
3730
|
+
}
|
|
3731
|
+
isInDedupWindow(sessionId, windowMs = 3000) {
|
|
3732
|
+
const state = this.get(sessionId);
|
|
3733
|
+
if (!state.lastFallbackAt)
|
|
3734
|
+
return false;
|
|
3735
|
+
return Date.now() - state.lastFallbackAt < windowMs;
|
|
3736
|
+
}
|
|
3737
|
+
recordFallback(sessionId, fromModel, toModel, reason, agentName) {
|
|
3738
|
+
const state = this.get(sessionId);
|
|
3739
|
+
const event = {
|
|
3740
|
+
at: Date.now(),
|
|
3741
|
+
fromModel,
|
|
3742
|
+
toModel,
|
|
3743
|
+
reason,
|
|
3744
|
+
sessionId,
|
|
3745
|
+
trigger: "reactive",
|
|
3746
|
+
agentName
|
|
3747
|
+
};
|
|
3748
|
+
state.currentModel = toModel;
|
|
3749
|
+
state.fallbackDepth++;
|
|
3750
|
+
state.lastFallbackAt = event.at;
|
|
3751
|
+
state.recoveryNotifiedForModel = null;
|
|
3752
|
+
state.fallbackHistory.push(event);
|
|
3753
|
+
if (agentName)
|
|
3754
|
+
state.agentName = agentName;
|
|
3755
|
+
}
|
|
3756
|
+
recordPreemptiveRedirect(sessionId, fromModel, toModel, agentName) {
|
|
3757
|
+
const state = this.get(sessionId);
|
|
3758
|
+
const event = {
|
|
3759
|
+
at: Date.now(),
|
|
3760
|
+
fromModel,
|
|
3761
|
+
toModel,
|
|
3762
|
+
reason: "rate_limit",
|
|
3763
|
+
sessionId,
|
|
3764
|
+
trigger: "preemptive",
|
|
3765
|
+
agentName
|
|
3766
|
+
};
|
|
3767
|
+
state.currentModel = toModel;
|
|
3768
|
+
state.recoveryNotifiedForModel = null;
|
|
3769
|
+
state.fallbackHistory.push(event);
|
|
3770
|
+
if (agentName)
|
|
3771
|
+
state.agentName = agentName;
|
|
3772
|
+
}
|
|
3773
|
+
setOriginalModel(sessionId, model) {
|
|
3774
|
+
const state = this.get(sessionId);
|
|
3775
|
+
if (!state.originalModel) {
|
|
3776
|
+
state.originalModel = model;
|
|
3777
|
+
state.currentModel = model;
|
|
3766
3778
|
}
|
|
3767
|
-
}
|
|
3779
|
+
}
|
|
3780
|
+
setAgentName(sessionId, agentName) {
|
|
3781
|
+
const state = this.get(sessionId);
|
|
3782
|
+
state.agentName = agentName;
|
|
3783
|
+
}
|
|
3784
|
+
setAgentFile(sessionId, agentFile) {
|
|
3785
|
+
const state = this.get(sessionId);
|
|
3786
|
+
state.agentFile = agentFile;
|
|
3787
|
+
}
|
|
3788
|
+
partialReset(sessionId) {
|
|
3789
|
+
const state = this.store.get(sessionId);
|
|
3790
|
+
if (!state)
|
|
3791
|
+
return;
|
|
3792
|
+
state.fallbackHistory = [];
|
|
3793
|
+
state.lastFallbackAt = null;
|
|
3794
|
+
state.isProcessing = false;
|
|
3795
|
+
}
|
|
3796
|
+
delete(sessionId) {
|
|
3797
|
+
this.store.delete(sessionId);
|
|
3798
|
+
}
|
|
3799
|
+
getAll() {
|
|
3800
|
+
return Array.from(this.store.values());
|
|
3801
|
+
}
|
|
3802
|
+
newState(sessionId) {
|
|
3803
|
+
return {
|
|
3804
|
+
sessionId,
|
|
3805
|
+
agentName: null,
|
|
3806
|
+
agentFile: null,
|
|
3807
|
+
originalModel: null,
|
|
3808
|
+
currentModel: null,
|
|
3809
|
+
fallbackDepth: 0,
|
|
3810
|
+
isProcessing: false,
|
|
3811
|
+
lastFallbackAt: null,
|
|
3812
|
+
fallbackHistory: [],
|
|
3813
|
+
recoveryNotifiedForModel: null
|
|
3814
|
+
};
|
|
3815
|
+
}
|
|
3768
3816
|
}
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3817
|
+
|
|
3818
|
+
// src/state/store.ts
|
|
3819
|
+
class FallbackStore {
|
|
3820
|
+
health;
|
|
3821
|
+
sessions;
|
|
3822
|
+
constructor(_config, logger) {
|
|
3823
|
+
this.sessions = new SessionStateStore;
|
|
3824
|
+
this.health = new ModelHealthStore({
|
|
3825
|
+
onTransition: (modelKey2, from, to) => {
|
|
3826
|
+
logger.info("health.transition", { modelKey: modelKey2, from, to });
|
|
3827
|
+
}
|
|
3828
|
+
});
|
|
3829
|
+
}
|
|
3830
|
+
destroy() {
|
|
3831
|
+
this.health.destroy();
|
|
3832
|
+
}
|
|
3772
3833
|
}
|
|
3773
3834
|
|
|
3774
3835
|
// src/tools/fallback-status.ts
|
|
@@ -3947,50 +4008,15 @@ function getLastUserModelAndAgent(data) {
|
|
|
3947
4008
|
return null;
|
|
3948
4009
|
}
|
|
3949
4010
|
|
|
3950
|
-
// src/preemptive.ts
|
|
3951
|
-
function tryPreemptiveRedirect(sessionId, modelKey2, agentName, store, config, logger) {
|
|
3952
|
-
const sessionState = store.sessions.get(sessionId);
|
|
3953
|
-
store.sessions.setOriginalModel(sessionId, modelKey2);
|
|
3954
|
-
if (sessionState.currentModel !== modelKey2) {
|
|
3955
|
-
const wasOnFallback = sessionState.currentModel !== null && sessionState.currentModel !== sessionState.originalModel;
|
|
3956
|
-
sessionState.currentModel = modelKey2;
|
|
3957
|
-
if (wasOnFallback && modelKey2 === sessionState.originalModel) {
|
|
3958
|
-
sessionState.fallbackDepth = 0;
|
|
3959
|
-
logger.debug("preemptive.depth.reset", { sessionId, modelKey: modelKey2 });
|
|
3960
|
-
}
|
|
3961
|
-
}
|
|
3962
|
-
const health = store.health.get(modelKey2);
|
|
3963
|
-
if (health.state !== "rate_limited") {
|
|
3964
|
-
return { redirected: false };
|
|
3965
|
-
}
|
|
3966
|
-
const chain = resolveFallbackModels(config, agentName);
|
|
3967
|
-
if (chain.length === 0) {
|
|
3968
|
-
logger.debug("preemptive.no-chain", { sessionId, agentName });
|
|
3969
|
-
return { redirected: false };
|
|
3970
|
-
}
|
|
3971
|
-
const fallbackModel = resolveFallbackModel(chain, modelKey2, store.health);
|
|
3972
|
-
if (!fallbackModel) {
|
|
3973
|
-
logger.debug("preemptive.all-exhausted", { sessionId });
|
|
3974
|
-
return { redirected: false };
|
|
3975
|
-
}
|
|
3976
|
-
logger.info("preemptive.redirect", {
|
|
3977
|
-
sessionId,
|
|
3978
|
-
from: modelKey2,
|
|
3979
|
-
to: fallbackModel
|
|
3980
|
-
});
|
|
3981
|
-
store.sessions.recordPreemptiveRedirect(sessionId, modelKey2, fallbackModel, agentName);
|
|
3982
|
-
return { redirected: true, fallbackModel };
|
|
3983
|
-
}
|
|
3984
|
-
|
|
3985
4011
|
// src/plugin.ts
|
|
3986
4012
|
var createPlugin = async ({ client, directory }) => {
|
|
3987
4013
|
const { config, path: configPath, warnings, migrated } = loadConfig(directory);
|
|
3988
|
-
const logger = new Logger(client, config.logPath, config.logging);
|
|
4014
|
+
const logger = new Logger(client, config.logPath, config.logging, config.logLevel);
|
|
3989
4015
|
const cmdPath = join4(homedir5(), ".config/opencode/commands/fallback-status.md");
|
|
3990
4016
|
try {
|
|
3991
|
-
if (!
|
|
4017
|
+
if (!existsSync4(cmdPath)) {
|
|
3992
4018
|
mkdirSync2(dirname2(cmdPath), { recursive: true });
|
|
3993
|
-
|
|
4019
|
+
writeFileSync2(cmdPath, `Call the fallback-status tool and display the full output.
|
|
3994
4020
|
`);
|
|
3995
4021
|
}
|
|
3996
4022
|
} catch (err) {
|
|
@@ -4060,6 +4086,7 @@ var createPlugin = async ({ client, directory }) => {
|
|
|
4060
4086
|
return hooks;
|
|
4061
4087
|
};
|
|
4062
4088
|
async function handleEvent(event, client, store, config, logger, directory) {
|
|
4089
|
+
logger.debug("event.received", { type: event.type });
|
|
4063
4090
|
if (event.type === "session.status") {
|
|
4064
4091
|
const { sessionID, status } = event.properties;
|
|
4065
4092
|
if (status.type === "retry") {
|
|
@@ -4101,6 +4128,7 @@ async function handleEvent(event, client, store, config, logger, directory) {
|
|
|
4101
4128
|
}
|
|
4102
4129
|
async function handleRetry(sessionId, message, client, store, config, logger, directory) {
|
|
4103
4130
|
if (!matchesAnyPattern(message, config.patterns)) {
|
|
4131
|
+
logger.debug("retry.nomatch", { sessionId, message });
|
|
4104
4132
|
return;
|
|
4105
4133
|
}
|
|
4106
4134
|
const category = classifyError(message);
|