@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/dist/index.js CHANGED
@@ -1,288 +1,10 @@
1
1
  // src/plugin.ts
2
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync } from "fs";
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, readFileSync, readdirSync, realpathSync, statSync } from "fs";
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
- 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}$/;
2974
2698
  function isPathInside(baseDir, targetPath) {
2975
- const rel = relative2(baseDir, targetPath);
2976
- return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
2699
+ const rel = relative(baseDir, targetPath);
2700
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
2977
2701
  }
2978
- function toRelativeAgentPath(absPath, projectDirectory, homeDir = homedir3()) {
2979
- const resolvedAbs = resolve2(absPath);
2980
- const configBase = resolve2(join2(homeDir, ".config", "opencode"));
2981
- 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);
2982
2706
  if (isPathInside(configBase, resolvedAbs)) {
2983
- const rel = relative2(configBase, resolvedAbs);
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 = relative2(projectBase, resolvedAbs);
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 = resolve2(dir);
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 = resolve2(join2(baseDir, e));
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" || !MODEL_KEY_RE2.test(m)) {
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 = homedir3()) {
2808
+ function resolveAgentFile(agentName, projectDirectory, customDirs, homeDir = homedir()) {
3085
2809
  const scanDirs = customDirs && customDirs.length > 0 ? customDirs.map((d) => [d, false]) : [
3086
- [join2(homeDir, ".config", "opencode", "agents"), false],
3087
- [join2(homeDir, ".config", "opencode", "agent"), true],
3088
- [join2(projectDirectory, ".opencode", "agents"), false],
3089
- [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]
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 = homedir3()) {
2834
+ function loadAgentFallbackConfigs(projectDirectory, homeDir = homedir()) {
3111
2835
  const scanDirs = [
3112
- [join2(homeDir, ".config", "opencode", "agents"), false],
3113
- [join2(homeDir, ".config", "opencode", "agent"), true],
3114
- [join2(projectDirectory, ".opencode", "agents"), false],
3115
- [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]
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
- sessionId,
3414
- agentName: null,
3415
- agentFile: null,
3416
- originalModel: null,
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
- // src/state/store.ts
3428
- class FallbackStore {
3429
- health;
3430
- sessions;
3431
- constructor(_config, logger) {
3432
- this.sessions = new SessionStateStore;
3433
- this.health = new ModelHealthStore({
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
- function sanitizeParts(parts) {
3729
- if (!Array.isArray(parts))
3730
- return [];
3731
- return parts.filter((part) => typeof part === "object" && part !== null && ("type" in part));
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/display/notifier.ts
3735
- async function notifyFallback(client, from, to, reason) {
3736
- const fromLabel = from ? shortModelName(from) : "current model";
3737
- const message = `Model fallback: switched from ${fromLabel} to ${shortModelName(to)} (${reason})`;
3738
- await client.tui.showToast({
3739
- body: {
3740
- title: "Model Fallback",
3741
- message,
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
- }).catch(() => {});
3757
- }
3758
- async function notifyRecovery(client, originalModel) {
3759
- const message = `Original model ${shortModelName(originalModel)} is available again`;
3760
- await client.tui.showToast({
3761
- body: {
3762
- title: "Model Recovered",
3763
- message,
3764
- variant: "info",
3765
- 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;
3766
3778
  }
3767
- }).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
+ }
3768
3816
  }
3769
- function shortModelName(key) {
3770
- const parts = key.split("/");
3771
- 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
+ }
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 (!existsSync3(cmdPath)) {
4017
+ if (!existsSync4(cmdPath)) {
3992
4018
  mkdirSync2(dirname2(cmdPath), { recursive: true });
3993
- writeFileSync(cmdPath, `Call the fallback-status tool and display the full output.
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);