@smart-coders-hq/opencode-model-fallback 1.0.4 → 1.0.6

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/dist/index.js CHANGED
@@ -1,550 +1,262 @@
1
1
  // src/plugin.ts
2
2
  import { existsSync as existsSync4, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
3
- import { dirname as dirname2, join as join4 } from "path";
4
3
  import { homedir as homedir5 } from "os";
4
+ import { dirname as dirname2, join as join4 } from "path";
5
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";
6
+ // src/config/agent-loader.ts
7
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "fs";
15
8
 
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: []
9
+ // node_modules/js-yaml/dist/js-yaml.mjs
10
+ /*! js-yaml 4.1.1 https://github.com/nodeca/js-yaml @license MIT */
11
+ function isNothing(subject) {
12
+ return typeof subject === "undefined" || subject === null;
13
+ }
14
+ function isObject(subject) {
15
+ return typeof subject === "object" && subject !== null;
16
+ }
17
+ function toArray(sequence) {
18
+ if (Array.isArray(sequence))
19
+ return sequence;
20
+ else if (isNothing(sequence))
21
+ return [];
22
+ return [sequence];
23
+ }
24
+ function extend(target, source) {
25
+ var index, length, key, sourceKeys;
26
+ if (source) {
27
+ sourceKeys = Object.keys(source);
28
+ for (index = 0, length = sourceKeys.length;index < length; index += 1) {
29
+ key = sourceKeys[index];
30
+ target[key] = source[key];
42
31
  }
43
- },
44
- patterns: DEFAULT_PATTERNS,
45
- logging: true,
46
- logLevel: "info",
47
- logPath: DEFAULT_LOG_PATH,
48
- agentDirs: []
32
+ }
33
+ return target;
34
+ }
35
+ function repeat(string, count) {
36
+ var result = "", cycle;
37
+ for (cycle = 0;cycle < count; cycle += 1) {
38
+ result += string;
39
+ }
40
+ return result;
41
+ }
42
+ function isNegativeZero(number) {
43
+ return number === 0 && Number.NEGATIVE_INFINITY === 1 / number;
44
+ }
45
+ var isNothing_1 = isNothing;
46
+ var isObject_1 = isObject;
47
+ var toArray_1 = toArray;
48
+ var repeat_1 = repeat;
49
+ var isNegativeZero_1 = isNegativeZero;
50
+ var extend_1 = extend;
51
+ var common = {
52
+ isNothing: isNothing_1,
53
+ isObject: isObject_1,
54
+ toArray: toArray_1,
55
+ repeat: repeat_1,
56
+ isNegativeZero: isNegativeZero_1,
57
+ extend: extend_1
49
58
  };
59
+ function formatError(exception, compact) {
60
+ var where = "", message = exception.reason || "(unknown reason)";
61
+ if (!exception.mark)
62
+ return message;
63
+ if (exception.mark.name) {
64
+ where += 'in "' + exception.mark.name + '" ';
65
+ }
66
+ where += "(" + (exception.mark.line + 1) + ":" + (exception.mark.column + 1) + ")";
67
+ if (!compact && exception.mark.snippet) {
68
+ where += `
50
69
 
51
- // src/config/schema.ts
52
- var MODEL_KEY_RE = /^[a-zA-Z0-9_-]{1,100}\/[a-zA-Z0-9._-]{1,100}$/;
53
- var home = resolve(homedir2());
54
- function formatPath(path) {
55
- return path.map((segment) => String(segment)).join(".");
56
- }
57
- function normalizeLogPath(path) {
58
- if (path.startsWith("~/")) {
59
- return resolve(home, path.slice(2));
70
+ ` + exception.mark.snippet;
60
71
  }
61
- return resolve(path);
72
+ return message + " " + where;
62
73
  }
63
- function isPathWithinHome(path) {
64
- const rel = relative(home, path);
65
- return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
74
+ function YAMLException$1(reason, mark) {
75
+ Error.call(this);
76
+ this.name = "YAMLException";
77
+ this.reason = reason;
78
+ this.mark = mark;
79
+ this.message = formatError(this, false);
80
+ if (Error.captureStackTrace) {
81
+ Error.captureStackTrace(this, this.constructor);
82
+ } else {
83
+ this.stack = new Error().stack || "";
84
+ }
66
85
  }
67
- var modelKey = z.string().regex(MODEL_KEY_RE, "Model key must be 'providerID/modelID'");
68
- var agentConfig = z.object({
69
- fallbackModels: z.array(modelKey).min(1)
70
- });
71
- var fallbackDefaults = z.object({
72
- fallbackOn: z.array(z.enum(["rate_limit", "quota_exceeded", "5xx", "timeout", "overloaded"])).optional(),
73
- cooldownMs: z.number().min(1e4).optional(),
74
- retryOriginalAfterMs: z.number().min(1e4).optional(),
75
- maxFallbackDepth: z.number().int().min(1).max(10).optional()
76
- });
77
- var logPathSchema = z.string().refine((p) => isPathWithinHome(normalizeLogPath(p)), "logPath must resolve within $HOME");
78
- var pluginConfigSchema = z.object({
79
- enabled: z.boolean().optional(),
80
- defaults: fallbackDefaults.optional(),
81
- agents: z.record(z.string(), agentConfig).optional(),
82
- patterns: z.array(z.string()).optional(),
83
- logging: z.boolean().optional(),
84
- logLevel: z.enum(["debug", "info"]).optional(),
85
- logPath: logPathSchema.optional(),
86
- agentDirs: z.array(z.string()).optional()
87
- }).strict();
88
- function parseConfig(raw) {
89
- const warnings = [];
90
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
91
- warnings.push("Config warning at root: expected object — using default");
92
- return { config: {}, warnings };
86
+ YAMLException$1.prototype = Object.create(Error.prototype);
87
+ YAMLException$1.prototype.constructor = YAMLException$1;
88
+ YAMLException$1.prototype.toString = function toString(compact) {
89
+ return this.name + ": " + formatError(this, compact);
90
+ };
91
+ var exception = YAMLException$1;
92
+ function getLine(buffer, lineStart, lineEnd, position, maxLineLength) {
93
+ var head = "";
94
+ var tail = "";
95
+ var maxHalfLength = Math.floor(maxLineLength / 2) - 1;
96
+ if (position - lineStart > maxHalfLength) {
97
+ head = " ... ";
98
+ lineStart = position - maxHalfLength + head.length;
93
99
  }
94
- const obj = raw;
95
- const allowed = new Set([
96
- "enabled",
97
- "defaults",
98
- "agents",
99
- "patterns",
100
- "logging",
101
- "logLevel",
102
- "logPath",
103
- "agentDirs"
104
- ]);
105
- for (const key of Object.keys(obj)) {
106
- if (!allowed.has(key)) {
107
- warnings.push(`Config warning at ${key}: unknown field — using default`);
108
- }
100
+ if (lineEnd - position > maxHalfLength) {
101
+ tail = " ...";
102
+ lineEnd = position + maxHalfLength - tail.length;
109
103
  }
110
- const config = {};
111
- const enabledResult = z.boolean().safeParse(obj.enabled);
112
- if (obj.enabled !== undefined) {
113
- if (enabledResult.success) {
114
- config.enabled = enabledResult.data;
115
- } else {
116
- warnings.push(`Config warning at enabled: ${enabledResult.error.issues[0].message} using default`);
104
+ return {
105
+ str: head + buffer.slice(lineStart, lineEnd).replace(/\t/g, "→") + tail,
106
+ pos: position - lineStart + head.length
107
+ };
108
+ }
109
+ function padStart(string, max) {
110
+ return common.repeat(" ", max - string.length) + string;
111
+ }
112
+ function makeSnippet(mark, options) {
113
+ options = Object.create(options || null);
114
+ if (!mark.buffer)
115
+ return null;
116
+ if (!options.maxLength)
117
+ options.maxLength = 79;
118
+ if (typeof options.indent !== "number")
119
+ options.indent = 1;
120
+ if (typeof options.linesBefore !== "number")
121
+ options.linesBefore = 3;
122
+ if (typeof options.linesAfter !== "number")
123
+ options.linesAfter = 2;
124
+ var re = /\r?\n|\r|\0/g;
125
+ var lineStarts = [0];
126
+ var lineEnds = [];
127
+ var match;
128
+ var foundLineNo = -1;
129
+ while (match = re.exec(mark.buffer)) {
130
+ lineEnds.push(match.index);
131
+ lineStarts.push(match.index + match[0].length);
132
+ if (mark.position <= match.index && foundLineNo < 0) {
133
+ foundLineNo = lineStarts.length - 2;
117
134
  }
118
135
  }
119
- if (obj.defaults !== undefined) {
120
- if (!obj.defaults || typeof obj.defaults !== "object" || Array.isArray(obj.defaults)) {
121
- warnings.push("Config warning at defaults: expected object — using default");
122
- } else {
123
- const defaultsObj = obj.defaults;
124
- const parsedDefaults = {};
125
- const fallbackOnResult = fallbackDefaults.shape.fallbackOn.safeParse(defaultsObj.fallbackOn);
126
- if (defaultsObj.fallbackOn !== undefined) {
127
- if (fallbackOnResult.success && fallbackOnResult.data !== undefined) {
128
- parsedDefaults.fallbackOn = fallbackOnResult.data;
129
- } else if (!fallbackOnResult.success) {
130
- for (const issue of fallbackOnResult.error.issues) {
131
- const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
132
- warnings.push(`Config warning at defaults.fallbackOn${suffix}: ${issue.message} — using default`);
133
- }
134
- }
135
- }
136
- const cooldownResult = fallbackDefaults.shape.cooldownMs.safeParse(defaultsObj.cooldownMs);
137
- if (defaultsObj.cooldownMs !== undefined) {
138
- if (cooldownResult.success && cooldownResult.data !== undefined) {
139
- parsedDefaults.cooldownMs = cooldownResult.data;
140
- } else if (!cooldownResult.success) {
141
- for (const issue of cooldownResult.error.issues) {
142
- const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
143
- warnings.push(`Config warning at defaults.cooldownMs${suffix}: ${issue.message} — using default`);
144
- }
145
- }
146
- }
147
- const retryResult = fallbackDefaults.shape.retryOriginalAfterMs.safeParse(defaultsObj.retryOriginalAfterMs);
148
- if (defaultsObj.retryOriginalAfterMs !== undefined) {
149
- if (retryResult.success && retryResult.data !== undefined) {
150
- parsedDefaults.retryOriginalAfterMs = retryResult.data;
151
- } else if (!retryResult.success) {
152
- for (const issue of retryResult.error.issues) {
153
- const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
154
- warnings.push(`Config warning at defaults.retryOriginalAfterMs${suffix}: ${issue.message} — using default`);
155
- }
156
- }
157
- }
158
- const depthResult = fallbackDefaults.shape.maxFallbackDepth.safeParse(defaultsObj.maxFallbackDepth);
159
- if (defaultsObj.maxFallbackDepth !== undefined) {
160
- if (depthResult.success && depthResult.data !== undefined) {
161
- parsedDefaults.maxFallbackDepth = depthResult.data;
162
- } else if (!depthResult.success) {
163
- for (const issue of depthResult.error.issues) {
164
- const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
165
- warnings.push(`Config warning at defaults.maxFallbackDepth${suffix}: ${issue.message} — using default`);
166
- }
167
- }
168
- }
169
- for (const key of Object.keys(defaultsObj)) {
170
- if (!Object.hasOwn(fallbackDefaults.shape, key)) {
171
- warnings.push(`Config warning at defaults.${key}: unknown field — using default`);
172
- }
173
- }
174
- if (Object.keys(parsedDefaults).length > 0) {
175
- config.defaults = parsedDefaults;
176
- }
177
- }
178
- }
179
- if (obj.agents !== undefined) {
180
- if (!obj.agents || typeof obj.agents !== "object" || Array.isArray(obj.agents)) {
181
- warnings.push("Config warning at agents: expected object — using default");
182
- } else {
183
- const parsedAgents = {};
184
- for (const [agentName, agentValue] of Object.entries(obj.agents)) {
185
- const agentResult = agentConfig.safeParse(agentValue);
186
- if (agentResult.success) {
187
- parsedAgents[agentName] = agentResult.data;
188
- continue;
189
- }
190
- for (const issue of agentResult.error.issues) {
191
- const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
192
- warnings.push(`Config warning at agents.${agentName}${suffix}: ${issue.message} — using default`);
193
- }
194
- }
195
- config.agents = parsedAgents;
196
- }
136
+ if (foundLineNo < 0)
137
+ foundLineNo = lineStarts.length - 1;
138
+ var result = "", i, line;
139
+ var lineNoLength = Math.min(mark.line + options.linesAfter, lineEnds.length).toString().length;
140
+ var maxLineLength = options.maxLength - (options.indent + lineNoLength + 3);
141
+ for (i = 1;i <= options.linesBefore; i++) {
142
+ if (foundLineNo - i < 0)
143
+ break;
144
+ line = getLine(mark.buffer, lineStarts[foundLineNo - i], lineEnds[foundLineNo - i], mark.position - (lineStarts[foundLineNo] - lineStarts[foundLineNo - i]), maxLineLength);
145
+ result = common.repeat(" ", options.indent) + padStart((mark.line - i + 1).toString(), lineNoLength) + " | " + line.str + `
146
+ ` + result;
197
147
  }
198
- if (obj.patterns !== undefined) {
199
- const patternsResult = z.array(z.string()).safeParse(obj.patterns);
200
- if (patternsResult.success) {
201
- config.patterns = patternsResult.data;
202
- } else {
203
- for (const issue of patternsResult.error.issues) {
204
- const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
205
- warnings.push(`Config warning at patterns${suffix}: ${issue.message} — using default`);
206
- }
207
- }
148
+ line = getLine(mark.buffer, lineStarts[foundLineNo], lineEnds[foundLineNo], mark.position, maxLineLength);
149
+ result += common.repeat(" ", options.indent) + padStart((mark.line + 1).toString(), lineNoLength) + " | " + line.str + `
150
+ `;
151
+ result += common.repeat("-", options.indent + lineNoLength + 3 + line.pos) + "^" + `
152
+ `;
153
+ for (i = 1;i <= options.linesAfter; i++) {
154
+ if (foundLineNo + i >= lineEnds.length)
155
+ break;
156
+ line = getLine(mark.buffer, lineStarts[foundLineNo + i], lineEnds[foundLineNo + i], mark.position - (lineStarts[foundLineNo] - lineStarts[foundLineNo + i]), maxLineLength);
157
+ result += common.repeat(" ", options.indent) + padStart((mark.line + i + 1).toString(), lineNoLength) + " | " + line.str + `
158
+ `;
208
159
  }
209
- if (obj.logging !== undefined) {
210
- const loggingResult = z.boolean().safeParse(obj.logging);
211
- if (loggingResult.success) {
212
- config.logging = loggingResult.data;
213
- } else {
214
- warnings.push(`Config warning at logging: ${loggingResult.error.issues[0].message} — using default`);
215
- }
160
+ return result.replace(/\n$/, "");
161
+ }
162
+ var snippet = makeSnippet;
163
+ var TYPE_CONSTRUCTOR_OPTIONS = [
164
+ "kind",
165
+ "multi",
166
+ "resolve",
167
+ "construct",
168
+ "instanceOf",
169
+ "predicate",
170
+ "represent",
171
+ "representName",
172
+ "defaultStyle",
173
+ "styleAliases"
174
+ ];
175
+ var YAML_NODE_KINDS = [
176
+ "scalar",
177
+ "sequence",
178
+ "mapping"
179
+ ];
180
+ function compileStyleAliases(map) {
181
+ var result = {};
182
+ if (map !== null) {
183
+ Object.keys(map).forEach(function(style) {
184
+ map[style].forEach(function(alias) {
185
+ result[String(alias)] = style;
186
+ });
187
+ });
216
188
  }
217
- if (obj.logLevel !== undefined) {
218
- const logLevelResult = z.enum(["debug", "info"]).safeParse(obj.logLevel);
219
- if (logLevelResult.success) {
220
- config.logLevel = logLevelResult.data;
221
- } else {
222
- warnings.push(`Config warning at logLevel: ${logLevelResult.error.issues[0].message} — using default`);
189
+ return result;
190
+ }
191
+ function Type$1(tag, options) {
192
+ options = options || {};
193
+ Object.keys(options).forEach(function(name) {
194
+ if (TYPE_CONSTRUCTOR_OPTIONS.indexOf(name) === -1) {
195
+ throw new exception('Unknown option "' + name + '" is met in definition of "' + tag + '" YAML type.');
223
196
  }
197
+ });
198
+ this.options = options;
199
+ this.tag = tag;
200
+ this.kind = options["kind"] || null;
201
+ this.resolve = options["resolve"] || function() {
202
+ return true;
203
+ };
204
+ this.construct = options["construct"] || function(data) {
205
+ return data;
206
+ };
207
+ this.instanceOf = options["instanceOf"] || null;
208
+ this.predicate = options["predicate"] || null;
209
+ this.represent = options["represent"] || null;
210
+ this.representName = options["representName"] || null;
211
+ this.defaultStyle = options["defaultStyle"] || null;
212
+ this.multi = options["multi"] || false;
213
+ this.styleAliases = compileStyleAliases(options["styleAliases"] || null);
214
+ if (YAML_NODE_KINDS.indexOf(this.kind) === -1) {
215
+ throw new exception('Unknown kind "' + this.kind + '" is specified for "' + tag + '" YAML type.');
224
216
  }
225
- if (obj.logPath !== undefined) {
226
- const logPathResult = logPathSchema.safeParse(obj.logPath);
227
- if (logPathResult.success) {
228
- config.logPath = obj.logPath;
229
- } else {
230
- for (const issue of logPathResult.error.issues) {
231
- const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
232
- warnings.push(`Config warning at logPath${suffix}: ${issue.message} using default`);
217
+ }
218
+ var type = Type$1;
219
+ function compileList(schema, name) {
220
+ var result = [];
221
+ schema[name].forEach(function(currentType) {
222
+ var newIndex = result.length;
223
+ result.forEach(function(previousType, previousIndex) {
224
+ if (previousType.tag === currentType.tag && previousType.kind === currentType.kind && previousType.multi === currentType.multi) {
225
+ newIndex = previousIndex;
233
226
  }
227
+ });
228
+ result[newIndex] = currentType;
229
+ });
230
+ return result;
231
+ }
232
+ function compileMap() {
233
+ var result = {
234
+ scalar: {},
235
+ sequence: {},
236
+ mapping: {},
237
+ fallback: {},
238
+ multi: {
239
+ scalar: [],
240
+ sequence: [],
241
+ mapping: [],
242
+ fallback: []
234
243
  }
235
- }
236
- if (obj.agentDirs !== undefined) {
237
- const agentDirsResult = z.array(z.string()).safeParse(obj.agentDirs);
238
- if (agentDirsResult.success) {
239
- config.agentDirs = agentDirsResult.data;
244
+ }, index, length;
245
+ function collectType(type2) {
246
+ if (type2.multi) {
247
+ result.multi[type2.kind].push(type2);
248
+ result.multi["fallback"].push(type2);
240
249
  } else {
241
- for (const issue of agentDirsResult.error.issues) {
242
- const suffix = issue.path.length > 0 ? `.${formatPath(issue.path)}` : "";
243
- warnings.push(`Config warning at agentDirs${suffix}: ${issue.message} — using default`);
244
- }
250
+ result[type2.kind][type2.tag] = result["fallback"][type2.tag] = type2;
245
251
  }
246
252
  }
247
- return { config, warnings };
248
- }
249
- function mergeWithDefaults(raw) {
250
- const def = DEFAULT_CONFIG;
251
- const logPath = raw.logPath ? normalizeLogPath(raw.logPath) : def.logPath;
252
- return {
253
- enabled: raw.enabled ?? def.enabled,
254
- defaults: {
255
- fallbackOn: raw.defaults?.fallbackOn ?? def.defaults.fallbackOn,
256
- cooldownMs: raw.defaults?.cooldownMs ?? def.defaults.cooldownMs,
257
- retryOriginalAfterMs: raw.defaults?.retryOriginalAfterMs ?? def.defaults.retryOriginalAfterMs,
258
- maxFallbackDepth: raw.defaults?.maxFallbackDepth ?? def.defaults.maxFallbackDepth
259
- },
260
- agents: raw.agents ?? def.agents,
261
- patterns: raw.patterns ?? def.patterns,
262
- logging: raw.logging ?? def.logging,
263
- logLevel: raw.logLevel ?? def.logLevel,
264
- logPath,
265
- agentDirs: raw.agentDirs ?? def.agentDirs
266
- };
267
- }
268
-
269
- // src/config/migrate.ts
270
- function isOldFormat(raw) {
271
- if (typeof raw !== "object" || raw === null)
272
- return false;
273
- return "fallbackModel" in raw && typeof raw.fallbackModel === "string";
274
- }
275
- function migrateOldConfig(old) {
276
- const migrated = {};
277
- if (typeof old.enabled === "boolean")
278
- migrated.enabled = old.enabled;
279
- if (typeof old.logging === "boolean")
280
- migrated.logging = old.logging;
281
- if (Array.isArray(old.patterns))
282
- migrated.patterns = old.patterns;
283
- if (old.fallbackModel) {
284
- migrated.agents = {
285
- "*": { fallbackModels: [old.fallbackModel] }
286
- };
287
- }
288
- if (typeof old.cooldownMs === "number") {
289
- migrated.defaults = { cooldownMs: old.cooldownMs };
253
+ for (index = 0, length = arguments.length;index < length; index += 1) {
254
+ arguments[index].forEach(collectType);
290
255
  }
291
- return migrated;
292
- }
293
-
294
- // src/config/agent-loader.ts
295
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "fs";
296
-
297
- // node_modules/js-yaml/dist/js-yaml.mjs
298
- /*! js-yaml 4.1.1 https://github.com/nodeca/js-yaml @license MIT */
299
- function isNothing(subject) {
300
- return typeof subject === "undefined" || subject === null;
301
- }
302
- function isObject(subject) {
303
- return typeof subject === "object" && subject !== null;
256
+ return result;
304
257
  }
305
- function toArray(sequence) {
306
- if (Array.isArray(sequence))
307
- return sequence;
308
- else if (isNothing(sequence))
309
- return [];
310
- return [sequence];
311
- }
312
- function extend(target, source) {
313
- var index, length, key, sourceKeys;
314
- if (source) {
315
- sourceKeys = Object.keys(source);
316
- for (index = 0, length = sourceKeys.length;index < length; index += 1) {
317
- key = sourceKeys[index];
318
- target[key] = source[key];
319
- }
320
- }
321
- return target;
322
- }
323
- function repeat(string, count) {
324
- var result = "", cycle;
325
- for (cycle = 0;cycle < count; cycle += 1) {
326
- result += string;
327
- }
328
- return result;
329
- }
330
- function isNegativeZero(number) {
331
- return number === 0 && Number.NEGATIVE_INFINITY === 1 / number;
332
- }
333
- var isNothing_1 = isNothing;
334
- var isObject_1 = isObject;
335
- var toArray_1 = toArray;
336
- var repeat_1 = repeat;
337
- var isNegativeZero_1 = isNegativeZero;
338
- var extend_1 = extend;
339
- var common = {
340
- isNothing: isNothing_1,
341
- isObject: isObject_1,
342
- toArray: toArray_1,
343
- repeat: repeat_1,
344
- isNegativeZero: isNegativeZero_1,
345
- extend: extend_1
346
- };
347
- function formatError(exception, compact) {
348
- var where = "", message = exception.reason || "(unknown reason)";
349
- if (!exception.mark)
350
- return message;
351
- if (exception.mark.name) {
352
- where += 'in "' + exception.mark.name + '" ';
353
- }
354
- where += "(" + (exception.mark.line + 1) + ":" + (exception.mark.column + 1) + ")";
355
- if (!compact && exception.mark.snippet) {
356
- where += `
357
-
358
- ` + exception.mark.snippet;
359
- }
360
- return message + " " + where;
361
- }
362
- function YAMLException$1(reason, mark) {
363
- Error.call(this);
364
- this.name = "YAMLException";
365
- this.reason = reason;
366
- this.mark = mark;
367
- this.message = formatError(this, false);
368
- if (Error.captureStackTrace) {
369
- Error.captureStackTrace(this, this.constructor);
370
- } else {
371
- this.stack = new Error().stack || "";
372
- }
373
- }
374
- YAMLException$1.prototype = Object.create(Error.prototype);
375
- YAMLException$1.prototype.constructor = YAMLException$1;
376
- YAMLException$1.prototype.toString = function toString(compact) {
377
- return this.name + ": " + formatError(this, compact);
378
- };
379
- var exception = YAMLException$1;
380
- function getLine(buffer, lineStart, lineEnd, position, maxLineLength) {
381
- var head = "";
382
- var tail = "";
383
- var maxHalfLength = Math.floor(maxLineLength / 2) - 1;
384
- if (position - lineStart > maxHalfLength) {
385
- head = " ... ";
386
- lineStart = position - maxHalfLength + head.length;
387
- }
388
- if (lineEnd - position > maxHalfLength) {
389
- tail = " ...";
390
- lineEnd = position + maxHalfLength - tail.length;
391
- }
392
- return {
393
- str: head + buffer.slice(lineStart, lineEnd).replace(/\t/g, "→") + tail,
394
- pos: position - lineStart + head.length
395
- };
396
- }
397
- function padStart(string, max) {
398
- return common.repeat(" ", max - string.length) + string;
399
- }
400
- function makeSnippet(mark, options) {
401
- options = Object.create(options || null);
402
- if (!mark.buffer)
403
- return null;
404
- if (!options.maxLength)
405
- options.maxLength = 79;
406
- if (typeof options.indent !== "number")
407
- options.indent = 1;
408
- if (typeof options.linesBefore !== "number")
409
- options.linesBefore = 3;
410
- if (typeof options.linesAfter !== "number")
411
- options.linesAfter = 2;
412
- var re = /\r?\n|\r|\0/g;
413
- var lineStarts = [0];
414
- var lineEnds = [];
415
- var match;
416
- var foundLineNo = -1;
417
- while (match = re.exec(mark.buffer)) {
418
- lineEnds.push(match.index);
419
- lineStarts.push(match.index + match[0].length);
420
- if (mark.position <= match.index && foundLineNo < 0) {
421
- foundLineNo = lineStarts.length - 2;
422
- }
423
- }
424
- if (foundLineNo < 0)
425
- foundLineNo = lineStarts.length - 1;
426
- var result = "", i, line;
427
- var lineNoLength = Math.min(mark.line + options.linesAfter, lineEnds.length).toString().length;
428
- var maxLineLength = options.maxLength - (options.indent + lineNoLength + 3);
429
- for (i = 1;i <= options.linesBefore; i++) {
430
- if (foundLineNo - i < 0)
431
- break;
432
- line = getLine(mark.buffer, lineStarts[foundLineNo - i], lineEnds[foundLineNo - i], mark.position - (lineStarts[foundLineNo] - lineStarts[foundLineNo - i]), maxLineLength);
433
- result = common.repeat(" ", options.indent) + padStart((mark.line - i + 1).toString(), lineNoLength) + " | " + line.str + `
434
- ` + result;
435
- }
436
- line = getLine(mark.buffer, lineStarts[foundLineNo], lineEnds[foundLineNo], mark.position, maxLineLength);
437
- result += common.repeat(" ", options.indent) + padStart((mark.line + 1).toString(), lineNoLength) + " | " + line.str + `
438
- `;
439
- result += common.repeat("-", options.indent + lineNoLength + 3 + line.pos) + "^" + `
440
- `;
441
- for (i = 1;i <= options.linesAfter; i++) {
442
- if (foundLineNo + i >= lineEnds.length)
443
- break;
444
- line = getLine(mark.buffer, lineStarts[foundLineNo + i], lineEnds[foundLineNo + i], mark.position - (lineStarts[foundLineNo] - lineStarts[foundLineNo + i]), maxLineLength);
445
- result += common.repeat(" ", options.indent) + padStart((mark.line + i + 1).toString(), lineNoLength) + " | " + line.str + `
446
- `;
447
- }
448
- return result.replace(/\n$/, "");
449
- }
450
- var snippet = makeSnippet;
451
- var TYPE_CONSTRUCTOR_OPTIONS = [
452
- "kind",
453
- "multi",
454
- "resolve",
455
- "construct",
456
- "instanceOf",
457
- "predicate",
458
- "represent",
459
- "representName",
460
- "defaultStyle",
461
- "styleAliases"
462
- ];
463
- var YAML_NODE_KINDS = [
464
- "scalar",
465
- "sequence",
466
- "mapping"
467
- ];
468
- function compileStyleAliases(map) {
469
- var result = {};
470
- if (map !== null) {
471
- Object.keys(map).forEach(function(style) {
472
- map[style].forEach(function(alias) {
473
- result[String(alias)] = style;
474
- });
475
- });
476
- }
477
- return result;
478
- }
479
- function Type$1(tag, options) {
480
- options = options || {};
481
- Object.keys(options).forEach(function(name) {
482
- if (TYPE_CONSTRUCTOR_OPTIONS.indexOf(name) === -1) {
483
- throw new exception('Unknown option "' + name + '" is met in definition of "' + tag + '" YAML type.');
484
- }
485
- });
486
- this.options = options;
487
- this.tag = tag;
488
- this.kind = options["kind"] || null;
489
- this.resolve = options["resolve"] || function() {
490
- return true;
491
- };
492
- this.construct = options["construct"] || function(data) {
493
- return data;
494
- };
495
- this.instanceOf = options["instanceOf"] || null;
496
- this.predicate = options["predicate"] || null;
497
- this.represent = options["represent"] || null;
498
- this.representName = options["representName"] || null;
499
- this.defaultStyle = options["defaultStyle"] || null;
500
- this.multi = options["multi"] || false;
501
- this.styleAliases = compileStyleAliases(options["styleAliases"] || null);
502
- if (YAML_NODE_KINDS.indexOf(this.kind) === -1) {
503
- throw new exception('Unknown kind "' + this.kind + '" is specified for "' + tag + '" YAML type.');
504
- }
505
- }
506
- var type = Type$1;
507
- function compileList(schema, name) {
508
- var result = [];
509
- schema[name].forEach(function(currentType) {
510
- var newIndex = result.length;
511
- result.forEach(function(previousType, previousIndex) {
512
- if (previousType.tag === currentType.tag && previousType.kind === currentType.kind && previousType.multi === currentType.multi) {
513
- newIndex = previousIndex;
514
- }
515
- });
516
- result[newIndex] = currentType;
517
- });
518
- return result;
519
- }
520
- function compileMap() {
521
- var result = {
522
- scalar: {},
523
- sequence: {},
524
- mapping: {},
525
- fallback: {},
526
- multi: {
527
- scalar: [],
528
- sequence: [],
529
- mapping: [],
530
- fallback: []
531
- }
532
- }, index, length;
533
- function collectType(type2) {
534
- if (type2.multi) {
535
- result.multi[type2.kind].push(type2);
536
- result.multi["fallback"].push(type2);
537
- } else {
538
- result[type2.kind][type2.tag] = result["fallback"][type2.tag] = type2;
539
- }
540
- }
541
- for (index = 0, length = arguments.length;index < length; index += 1) {
542
- arguments[index].forEach(collectType);
543
- }
544
- return result;
545
- }
546
- function Schema$1(definition) {
547
- return this.extend(definition);
258
+ function Schema$1(definition) {
259
+ return this.extend(definition);
548
260
  }
549
261
  Schema$1.prototype.extend = function extend2(definition) {
550
262
  var implicit = [];
@@ -2980,24 +2692,24 @@ var jsYaml = {
2980
2692
  };
2981
2693
 
2982
2694
  // src/config/agent-loader.ts
2983
- import { homedir as homedir3 } from "os";
2984
- import { basename, extname, isAbsolute as isAbsolute2, join as join2, relative as relative2, resolve as resolve2 } from "path";
2985
- var MODEL_KEY_RE2 = /^[a-zA-Z0-9_-]{1,100}\/[a-zA-Z0-9._-]{1,100}$/;
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}$/;
2986
2698
  function isPathInside(baseDir, targetPath) {
2987
- const rel = relative2(baseDir, targetPath);
2988
- return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
2699
+ const rel = relative(baseDir, targetPath);
2700
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
2989
2701
  }
2990
- function toRelativeAgentPath(absPath, projectDirectory, homeDir = homedir3()) {
2991
- const resolvedAbs = resolve2(absPath);
2992
- const configBase = resolve2(join2(homeDir, ".config", "opencode"));
2993
- const projectBase = resolve2(projectDirectory);
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);
2994
2706
  if (isPathInside(configBase, resolvedAbs)) {
2995
- const rel = relative2(configBase, resolvedAbs);
2707
+ const rel = relative(configBase, resolvedAbs);
2996
2708
  if (rel)
2997
2709
  return rel;
2998
2710
  }
2999
2711
  if (isPathInside(projectBase, resolvedAbs)) {
3000
- const rel = relative2(projectBase, resolvedAbs);
2712
+ const rel = relative(projectBase, resolvedAbs);
3001
2713
  if (rel)
3002
2714
  return rel;
3003
2715
  }
@@ -3011,7 +2723,7 @@ function stemName(filePath) {
3011
2723
  function collectFiles(dir, recursive) {
3012
2724
  if (!existsSync(dir))
3013
2725
  return [];
3014
- const baseDir = resolve2(dir);
2726
+ const baseDir = resolve(dir);
3015
2727
  let baseRealPath = baseDir;
3016
2728
  try {
3017
2729
  baseRealPath = realpathSync(baseDir);
@@ -3026,7 +2738,7 @@ function collectFiles(dir, recursive) {
3026
2738
  entries = readdirSync(baseDir);
3027
2739
  }
3028
2740
  return entries.filter((e) => e.endsWith(".md") || e.endsWith(".json")).map((e) => {
3029
- const candidatePath = resolve2(join2(baseDir, e));
2741
+ const candidatePath = resolve(join(baseDir, e));
3030
2742
  if (!isPathInside(baseDir, candidatePath))
3031
2743
  return null;
3032
2744
  try {
@@ -3078,7 +2790,7 @@ function parseAgentFile(filePath) {
3078
2790
  return null;
3079
2791
  const validModels = [];
3080
2792
  for (const m of models) {
3081
- if (typeof m !== "string" || !MODEL_KEY_RE2.test(m)) {
2793
+ if (typeof m !== "string" || !MODEL_KEY_RE.test(m)) {
3082
2794
  console.warn(`[model-fallback] agent-loader: skipping invalid model key ${JSON.stringify(m)} in ${basename(filePath)}`);
3083
2795
  continue;
3084
2796
  }
@@ -3093,12 +2805,12 @@ function parseAgentFile(filePath) {
3093
2805
  return null;
3094
2806
  }
3095
2807
  }
3096
- function resolveAgentFile(agentName, projectDirectory, customDirs, homeDir = homedir3()) {
2808
+ function resolveAgentFile(agentName, projectDirectory, customDirs, homeDir = homedir()) {
3097
2809
  const scanDirs = customDirs && customDirs.length > 0 ? customDirs.map((d) => [d, false]) : [
3098
- [join2(homeDir, ".config", "opencode", "agents"), false],
3099
- [join2(homeDir, ".config", "opencode", "agent"), true],
3100
- [join2(projectDirectory, ".opencode", "agents"), false],
3101
- [join2(projectDirectory, ".opencode", "agent"), true]
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]
3102
2814
  ];
3103
2815
  const allFiles = [];
3104
2816
  for (const [dir, recursive] of scanDirs) {
@@ -3119,12 +2831,12 @@ function resolveAgentFile(agentName, projectDirectory, customDirs, homeDir = hom
3119
2831
  }
3120
2832
  return null;
3121
2833
  }
3122
- function loadAgentFallbackConfigs(projectDirectory, homeDir = homedir3()) {
2834
+ function loadAgentFallbackConfigs(projectDirectory, homeDir = homedir()) {
3123
2835
  const scanDirs = [
3124
- [join2(homeDir, ".config", "opencode", "agents"), false],
3125
- [join2(homeDir, ".config", "opencode", "agent"), true],
3126
- [join2(projectDirectory, ".opencode", "agents"), false],
3127
- [join2(projectDirectory, ".opencode", "agent"), true]
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]
3128
2840
  ];
3129
2841
  const result = {};
3130
2842
  for (const [dir, recursive] of scanDirs) {
@@ -3140,325 +2852,346 @@ function loadAgentFallbackConfigs(projectDirectory, homeDir = homedir3()) {
3140
2852
  }
3141
2853
 
3142
2854
  // src/config/loader.ts
3143
- var CONFIG_FILENAME = "model-fallback.json";
3144
- var OLD_CONFIG_FILENAME = "rate-limit-fallback.json";
3145
- function candidatePaths(directory) {
3146
- const home2 = homedir4();
3147
- return [
3148
- join3(directory, ".opencode", CONFIG_FILENAME),
3149
- join3(home2, ".config", "opencode", CONFIG_FILENAME),
3150
- join3(directory, ".opencode", OLD_CONFIG_FILENAME),
3151
- join3(home2, ".config", "opencode", OLD_CONFIG_FILENAME)
3152
- ];
3153
- }
3154
- function loadConfig(directory) {
3155
- const agentFileConfigs = loadAgentFallbackConfigs(directory);
3156
- const candidates = candidatePaths(directory);
3157
- for (const candidate of candidates) {
3158
- if (!existsSync2(candidate))
3159
- continue;
3160
- let raw;
3161
- try {
3162
- raw = JSON.parse(readFileSync2(candidate, "utf-8"));
3163
- } catch {
3164
- return {
3165
- config: {
3166
- ...DEFAULT_CONFIG,
3167
- agents: { ...agentFileConfigs, ...DEFAULT_CONFIG.agents }
3168
- },
3169
- path: candidate,
3170
- warnings: [`Failed to parse ${basename2(candidate)}: invalid JSON — using defaults`],
3171
- migrated: false
3172
- };
3173
- }
3174
- const isOld = isOldFormat(raw);
3175
- if (isOld) {
3176
- raw = migrateOldConfig(raw);
3177
- }
3178
- const { config: parsed, warnings } = parseConfig(raw);
3179
- const merged = mergeWithDefaults(parsed);
3180
- merged.agents = { ...agentFileConfigs, ...merged.agents };
3181
- return {
3182
- config: merged,
3183
- path: candidate,
3184
- warnings,
3185
- migrated: isOld
3186
- };
3187
- }
3188
- return {
3189
- config: {
3190
- ...DEFAULT_CONFIG,
3191
- agents: { ...agentFileConfigs, ...DEFAULT_CONFIG.agents }
3192
- },
3193
- path: null,
3194
- warnings: [],
3195
- migrated: false
3196
- };
3197
- }
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";
3198
2858
 
3199
- // src/logging/logger.ts
3200
- import { appendFileSync, existsSync as existsSync3, mkdirSync, writeFileSync } from "fs";
3201
- import { dirname } from "path";
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
+ };
3202
2893
 
3203
- class Logger {
3204
- client;
3205
- logPath;
3206
- enabled;
3207
- minLevel;
3208
- dirCreated = false;
3209
- constructor(client, logPath, enabled, minLevel = "info") {
3210
- this.client = client;
3211
- this.logPath = logPath;
3212
- this.enabled = enabled;
3213
- this.minLevel = minLevel;
3214
- }
3215
- log(level, event, fields = {}) {
3216
- const entry = {
3217
- ts: new Date().toISOString(),
3218
- level,
3219
- event,
3220
- ...fields
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] }
3221
2911
  };
3222
- const shouldWrite = this.enabled && (this.minLevel === "debug" || level !== "debug");
3223
- if (shouldWrite) {
3224
- this.writeToFile(entry);
3225
- }
3226
- if (level !== "debug") {
3227
- const message = `[model-fallback] ${event}${Object.keys(fields).length ? " " + JSON.stringify(fields) : ""}`;
3228
- this.client.app.log({
3229
- body: { service: "model-fallback", level, message }
3230
- }).catch(() => {});
3231
- }
3232
- }
3233
- info(event, fields) {
3234
- this.log("info", event, fields);
3235
- }
3236
- warn(event, fields) {
3237
- this.log("warn", event, fields);
3238
- }
3239
- error(event, fields) {
3240
- this.log("error", event, fields);
3241
- }
3242
- debug(event, fields) {
3243
- this.log("debug", event, fields);
3244
2912
  }
3245
- writeToFile(entry) {
3246
- try {
3247
- if (!this.dirCreated) {
3248
- mkdirSync(dirname(this.logPath), { recursive: true, mode: 448 });
3249
- this.dirCreated = true;
3250
- }
3251
- if (!existsSync3(this.logPath)) {
3252
- writeFileSync(this.logPath, "", { mode: 384 });
3253
- }
3254
- appendFileSync(this.logPath, JSON.stringify(entry) + `
3255
- `, "utf-8");
3256
- } catch {}
2913
+ if (typeof old.cooldownMs === "number") {
2914
+ migrated.defaults = { cooldownMs: old.cooldownMs };
3257
2915
  }
2916
+ return migrated;
3258
2917
  }
3259
2918
 
3260
- // src/state/model-health.ts
3261
- class ModelHealthStore {
3262
- store = new Map;
3263
- timer = null;
3264
- onTransition;
3265
- constructor(opts) {
3266
- this.onTransition = opts?.onTransition;
3267
- this.timer = setInterval(() => this.tick(), 30000);
3268
- }
3269
- get(modelKey2) {
3270
- return this.store.get(modelKey2) ?? this.newHealth(modelKey2);
3271
- }
3272
- markRateLimited(modelKey2, cooldownMs, retryOriginalAfterMs) {
3273
- const now = Date.now();
3274
- const existing = this.get(modelKey2);
3275
- const health = {
3276
- ...existing,
3277
- state: "rate_limited",
3278
- lastFailure: now,
3279
- failureCount: existing.failureCount + 1,
3280
- cooldownExpiresAt: now + cooldownMs,
3281
- retryOriginalAt: now + retryOriginalAfterMs
3282
- };
3283
- this.store.set(modelKey2, health);
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));
3284
2931
  }
3285
- isUsable(modelKey2) {
3286
- const h = this.get(modelKey2);
3287
- return h.state === "healthy" || h.state === "cooldown";
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 };
3288
2964
  }
3289
- preferScore(modelKey2) {
3290
- const state = this.get(modelKey2).state;
3291
- if (state === "healthy")
3292
- return 2;
3293
- if (state === "cooldown")
3294
- return 1;
3295
- return 0;
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
+ }
3296
2980
  }
3297
- getAll() {
3298
- return Array.from(this.store.values());
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
+ }
3299
2989
  }
3300
- destroy() {
3301
- if (this.timer) {
3302
- clearInterval(this.timer);
3303
- this.timer = null;
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
+ }
3304
3048
  }
3305
3049
  }
3306
- tick() {
3307
- const now = Date.now();
3308
- for (const [key, health] of this.store) {
3309
- if (health.state === "rate_limited" && health.cooldownExpiresAt && now >= health.cooldownExpiresAt) {
3310
- const next = { ...health, state: "cooldown" };
3311
- this.store.set(key, next);
3312
- this.onTransition?.(key, "rate_limited", "cooldown");
3313
- } else if (health.state === "cooldown" && health.retryOriginalAt && now >= health.retryOriginalAt) {
3314
- const next = {
3315
- ...health,
3316
- state: "healthy",
3317
- cooldownExpiresAt: null,
3318
- retryOriginalAt: null
3319
- };
3320
- this.store.set(key, next);
3321
- this.onTransition?.(key, "cooldown", "healthy");
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
+ }
3322
3065
  }
3066
+ config.agents = parsedAgents;
3323
3067
  }
3324
3068
  }
3325
- newHealth(modelKey2) {
3326
- return {
3327
- modelKey: modelKey2,
3328
- state: "healthy",
3329
- lastFailure: null,
3330
- failureCount: 0,
3331
- cooldownExpiresAt: null,
3332
- retryOriginalAt: null
3333
- };
3334
- }
3335
- }
3336
-
3337
- // src/state/session-state.ts
3338
- class SessionStateStore {
3339
- store = new Map;
3340
- get(sessionId) {
3341
- let state = this.store.get(sessionId);
3342
- if (!state) {
3343
- state = this.newState(sessionId);
3344
- this.store.set(sessionId, state);
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
+ }
3345
3078
  }
3346
- return state;
3347
- }
3348
- acquireLock(sessionId) {
3349
- const state = this.get(sessionId);
3350
- if (state.isProcessing)
3351
- return false;
3352
- state.isProcessing = true;
3353
- return true;
3354
- }
3355
- releaseLock(sessionId) {
3356
- const state = this.store.get(sessionId);
3357
- if (state)
3358
- state.isProcessing = false;
3359
- }
3360
- isInDedupWindow(sessionId, windowMs = 3000) {
3361
- const state = this.get(sessionId);
3362
- if (!state.lastFallbackAt)
3363
- return false;
3364
- return Date.now() - state.lastFallbackAt < windowMs;
3365
- }
3366
- recordFallback(sessionId, fromModel, toModel, reason, agentName) {
3367
- const state = this.get(sessionId);
3368
- const event = {
3369
- at: Date.now(),
3370
- fromModel,
3371
- toModel,
3372
- reason,
3373
- sessionId,
3374
- trigger: "reactive",
3375
- agentName
3376
- };
3377
- state.currentModel = toModel;
3378
- state.fallbackDepth++;
3379
- state.lastFallbackAt = event.at;
3380
- state.recoveryNotifiedForModel = null;
3381
- state.fallbackHistory.push(event);
3382
- if (agentName)
3383
- state.agentName = agentName;
3384
- }
3385
- recordPreemptiveRedirect(sessionId, fromModel, toModel, agentName) {
3386
- const state = this.get(sessionId);
3387
- const event = {
3388
- at: Date.now(),
3389
- fromModel,
3390
- toModel,
3391
- reason: "rate_limit",
3392
- sessionId,
3393
- trigger: "preemptive",
3394
- agentName
3395
- };
3396
- state.currentModel = toModel;
3397
- state.recoveryNotifiedForModel = null;
3398
- state.fallbackHistory.push(event);
3399
- if (agentName)
3400
- state.agentName = agentName;
3401
3079
  }
3402
- setOriginalModel(sessionId, model) {
3403
- const state = this.get(sessionId);
3404
- if (!state.originalModel) {
3405
- state.originalModel = model;
3406
- state.currentModel = model;
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`);
3407
3086
  }
3408
3087
  }
3409
- setAgentName(sessionId, agentName) {
3410
- const state = this.get(sessionId);
3411
- state.agentName = agentName;
3412
- }
3413
- setAgentFile(sessionId, agentFile) {
3414
- const state = this.get(sessionId);
3415
- state.agentFile = agentFile;
3416
- }
3417
- partialReset(sessionId) {
3418
- const state = this.store.get(sessionId);
3419
- if (!state)
3420
- return;
3421
- state.fallbackHistory = [];
3422
- state.lastFallbackAt = null;
3423
- state.isProcessing = false;
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
+ }
3424
3095
  }
3425
- delete(sessionId) {
3426
- this.store.delete(sessionId);
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
+ }
3427
3106
  }
3428
- getAll() {
3429
- return Array.from(this.store.values());
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
+ }
3430
3117
  }
3431
- newState(sessionId) {
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
+
3140
+ // src/config/loader.ts
3141
+ var CONFIG_FILENAME = "model-fallback.json";
3142
+ var OLD_CONFIG_FILENAME = "rate-limit-fallback.json";
3143
+ function candidatePaths(directory) {
3144
+ const home2 = homedir4();
3145
+ return [
3146
+ join3(directory, ".opencode", CONFIG_FILENAME),
3147
+ join3(home2, ".config", "opencode", CONFIG_FILENAME),
3148
+ join3(directory, ".opencode", OLD_CONFIG_FILENAME),
3149
+ join3(home2, ".config", "opencode", OLD_CONFIG_FILENAME)
3150
+ ];
3151
+ }
3152
+ function loadConfig(directory) {
3153
+ const agentFileConfigs = loadAgentFallbackConfigs(directory);
3154
+ const candidates = candidatePaths(directory);
3155
+ for (const candidate of candidates) {
3156
+ if (!existsSync2(candidate))
3157
+ continue;
3158
+ let raw;
3159
+ try {
3160
+ raw = JSON.parse(readFileSync2(candidate, "utf-8"));
3161
+ } catch {
3162
+ return {
3163
+ config: {
3164
+ ...DEFAULT_CONFIG,
3165
+ agents: { ...agentFileConfigs, ...DEFAULT_CONFIG.agents }
3166
+ },
3167
+ path: candidate,
3168
+ warnings: [`Failed to parse ${basename2(candidate)}: invalid JSON — using defaults`],
3169
+ migrated: false
3170
+ };
3171
+ }
3172
+ const isOld = isOldFormat(raw);
3173
+ if (isOld) {
3174
+ raw = migrateOldConfig(raw);
3175
+ }
3176
+ const { config: parsed, warnings } = parseConfig(raw);
3177
+ const merged = mergeWithDefaults(parsed);
3178
+ merged.agents = { ...agentFileConfigs, ...merged.agents };
3432
3179
  return {
3433
- sessionId,
3434
- agentName: null,
3435
- agentFile: null,
3436
- originalModel: null,
3437
- currentModel: null,
3438
- fallbackDepth: 0,
3439
- isProcessing: false,
3440
- lastFallbackAt: null,
3441
- fallbackHistory: [],
3442
- recoveryNotifiedForModel: null
3180
+ config: merged,
3181
+ path: candidate,
3182
+ warnings,
3183
+ migrated: isOld
3443
3184
  };
3444
3185
  }
3445
- }
3446
-
3447
- // src/state/store.ts
3448
- class FallbackStore {
3449
- health;
3450
- sessions;
3451
- constructor(_config, logger) {
3452
- this.sessions = new SessionStateStore;
3453
- this.health = new ModelHealthStore({
3454
- onTransition: (modelKey2, from, to) => {
3455
- logger.info("health.transition", { modelKey: modelKey2, from, to });
3456
- }
3457
- });
3458
- }
3459
- destroy() {
3460
- this.health.destroy();
3461
- }
3186
+ return {
3187
+ config: {
3188
+ ...DEFAULT_CONFIG,
3189
+ agents: { ...agentFileConfigs, ...DEFAULT_CONFIG.agents }
3190
+ },
3191
+ path: null,
3192
+ warnings: [],
3193
+ migrated: false
3194
+ };
3462
3195
  }
3463
3196
 
3464
3197
  // src/detection/patterns.ts
@@ -3514,6 +3247,107 @@ function classifyError(message, statusCode) {
3514
3247
  return "unknown";
3515
3248
  }
3516
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
+
3517
3351
  // src/resolution/agent-resolver.ts
3518
3352
  async function resolveAgentName(client, sessionId, cachedName) {
3519
3353
  if (cachedName)
@@ -3553,6 +3387,43 @@ function resolveFallbackModel(chain, currentModel, health) {
3553
3387
  return null;
3554
3388
  }
3555
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
+
3556
3427
  // src/replay/message-converter.ts
3557
3428
  function convertPartsForPrompt(parts) {
3558
3429
  const result = [];
@@ -3735,66 +3606,230 @@ async function attemptFallback(sessionId, reason, client, store, config, logger,
3735
3606
  });
3736
3607
  return { success: false, error: "prompt failed" };
3737
3608
  }
3738
- const newDepth = sessionState.fallbackDepth + 1;
3739
- store.sessions.recordFallback(sessionId, currentModel ?? fallbackModel, fallbackModel, reason, agentName);
3740
- logger.info("fallback.success", {
3741
- sessionId,
3742
- agentName,
3743
- agentFile: store.sessions.get(sessionId).agentFile,
3744
- from: currentModel,
3745
- to: fallbackModel,
3746
- reason,
3747
- depth: newDepth
3748
- });
3749
- return { success: true, fallbackModel };
3750
- } finally {
3751
- store.sessions.releaseLock(sessionId);
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
+ }
3752
3676
  }
3753
- }
3754
- function sanitizeParts(parts) {
3755
- if (!Array.isArray(parts))
3756
- return [];
3757
- return parts.filter((part) => typeof part === "object" && part !== null && ("type" in part));
3758
- }
3759
-
3760
- // src/display/notifier.ts
3761
- async function notifyFallback(client, from, to, reason) {
3762
- const fromLabel = from ? shortModelName(from) : "current model";
3763
- const message = `Model fallback: switched from ${fromLabel} to ${shortModelName(to)} (${reason})`;
3764
- await client.tui.showToast({
3765
- body: {
3766
- title: "Model Fallback",
3767
- message,
3768
- variant: "warning",
3769
- duration: 6000
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
+ }
3770
3694
  }
3771
- }).catch(() => {});
3695
+ }
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
+ }
3772
3706
  }
3773
- async function notifyFallbackActive(client, originalModel, currentModel) {
3774
- const message = `Using ${shortModelName(currentModel)} (fallback from ${shortModelName(originalModel)})`;
3775
- await client.tui.showToast({
3776
- body: {
3777
- title: "Fallback Active",
3778
- message,
3779
- variant: "warning",
3780
- duration: 4000
3707
+
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);
3781
3716
  }
3782
- }).catch(() => {});
3783
- }
3784
- async function notifyRecovery(client, originalModel) {
3785
- const message = `Original model ${shortModelName(originalModel)} is available again`;
3786
- await client.tui.showToast({
3787
- body: {
3788
- title: "Model Recovered",
3789
- message,
3790
- variant: "info",
3791
- duration: 5000
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;
3792
3778
  }
3793
- }).catch(() => {});
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
+ }
3794
3816
  }
3795
- function shortModelName(key) {
3796
- const parts = key.split("/");
3797
- return parts.length > 1 ? parts.slice(1).join("/") : key;
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
+ }
3798
3833
  }
3799
3834
 
3800
3835
  // src/tools/fallback-status.ts
@@ -3973,43 +4008,6 @@ function getLastUserModelAndAgent(data) {
3973
4008
  return null;
3974
4009
  }
3975
4010
 
3976
- // src/preemptive.ts
3977
- function tryPreemptiveRedirect(sessionId, modelKey2, agentName, store, config, logger) {
3978
- const sessionState = store.sessions.get(sessionId);
3979
- store.sessions.setOriginalModel(sessionId, modelKey2);
3980
- if (sessionState.currentModel !== modelKey2) {
3981
- const wasOnFallback = sessionState.currentModel !== null && sessionState.currentModel !== sessionState.originalModel;
3982
- sessionState.currentModel = modelKey2;
3983
- if (wasOnFallback && modelKey2 === sessionState.originalModel) {
3984
- sessionState.fallbackDepth = 0;
3985
- logger.debug("preemptive.depth.reset", { sessionId, modelKey: modelKey2 });
3986
- }
3987
- }
3988
- const health = store.health.get(modelKey2);
3989
- if (health.state !== "rate_limited") {
3990
- return { redirected: false };
3991
- }
3992
- const chain = resolveFallbackModels(config, agentName);
3993
- if (chain.length === 0) {
3994
- logger.debug("preemptive.no-chain", { sessionId, agentName });
3995
- return { redirected: false };
3996
- }
3997
- const fallbackModel = resolveFallbackModel(chain, modelKey2, store.health);
3998
- if (!fallbackModel) {
3999
- logger.debug("preemptive.all-exhausted", { sessionId });
4000
- return { redirected: false };
4001
- }
4002
- logger.info("preemptive.redirect", {
4003
- sessionId,
4004
- agentName,
4005
- agentFile: sessionState.agentFile,
4006
- from: modelKey2,
4007
- to: fallbackModel
4008
- });
4009
- store.sessions.recordPreemptiveRedirect(sessionId, modelKey2, fallbackModel, agentName);
4010
- return { redirected: true, fallbackModel };
4011
- }
4012
-
4013
4011
  // src/plugin.ts
4014
4012
  var createPlugin = async ({ client, directory }) => {
4015
4013
  const { config, path: configPath, warnings, migrated } = loadConfig(directory);