@azumag/opencode-rate-limit-fallback 1.43.0 → 1.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,10 +51,15 @@ Restart OpenCode to load the plugin.
51
51
 
52
52
  Create a configuration file at one of these locations:
53
53
 
54
- - `~/.opencode/rate-limit-fallback.json` (recommended)
55
- - `~/.config/opencode/rate-limit-fallback.json`
56
- - `<project>/.opencode/rate-limit-fallback.json`
57
- - `<project>/rate-limit-fallback.json`
54
+ **Config file search order (highest to lowest priority):**
55
+ 1. `<worktree>/.opencode/rate-limit-fallback.json`
56
+ 2. `<worktree>/rate-limit-fallback.json`
57
+ 3. `<project>/.opencode/rate-limit-fallback.json`
58
+ 4. `<project>/rate-limit-fallback.json`
59
+ 5. `~/.opencode/rate-limit-fallback.json` (recommended for most users)
60
+ 6. `~/.config/opencode/rate-limit-fallback.json`
61
+
62
+ > **Note**: Project-local and worktree configs (1-4) take precedence over global configs (5-6).
58
63
 
59
64
  ### Example Configuration
60
65
 
@@ -99,16 +104,39 @@ Create a configuration file at one of these locations:
99
104
 
100
105
  ### Configuration Options
101
106
 
102
- | Option | Type | Default | Description |
103
- |--------|------|---------|-------------|
104
- | `enabled` | boolean | `true` | Enable/disable the plugin |
105
- | `cooldownMs` | number | `60000` | Cooldown period (ms) before retrying a rate-limited model |
106
- | `fallbackMode` | string | `"cycle"` | Behavior when all models are exhausted (see below) |
107
- | `fallbackModels` | array | See below | List of fallback models in priority order |
108
- | `maxSubagentDepth` | number | `10` | Maximum nesting depth for subagent hierarchies |
109
- | `enableSubagentFallback` | boolean | `true` | Enable/disable fallback for subagent sessions |
110
- | `retryPolicy` | object | See below | Retry policy configuration (see below) |
111
- | `circuitBreaker` | object | See below | Circuit breaker configuration (see below) |
107
+ | Option | Type | Default | Description |
108
+ |--------|------|---------|-------------|
109
+ | `enabled` | boolean | `true` | Enable/disable the plugin |
110
+ | `cooldownMs` | number | `60000` | Cooldown period (ms) before retrying a rate-limited model |
111
+ | `fallbackMode` | string | `"cycle"` | Behavior when all models are exhausted (see below) |
112
+ | `fallbackModels` | array | See below | List of fallback models in priority order |
113
+ | `maxSubagentDepth` | number | `10` | Maximum nesting depth for subagent hierarchies |
114
+ | `enableSubagentFallback` | boolean | `true` | Enable/disable fallback for subagent sessions |
115
+ | `retryPolicy` | object | See below | Retry policy configuration (see below) |
116
+ | `circuitBreaker` | object | See below | Circuit breaker configuration (see below) |
117
+
118
+ ### Git Worktree Support
119
+
120
+ When using git worktrees, the plugin searches for config files in the worktree directory first, before the project directory. This allows you to have different fallback configurations for different worktrees.
121
+
122
+ **Example structure:**
123
+ ```
124
+ my-repo/
125
+ .git/
126
+ .opencode/rate-limit-fallback.json (project-level config)
127
+ my-worktree/ (worktree)
128
+ .opencode/rate-limit-fallback.json (worktree-specific, higher priority)
129
+ ```
130
+
131
+ **Config file search order with worktrees (highest to lowest priority):**
132
+ 1. `<worktree>/.opencode/rate-limit-fallback.json`
133
+ 2. `<worktree>/rate-limit-fallback.json`
134
+ 3. `<project>/.opencode/rate-limit-fallback.json`
135
+ 4. `<project>/rate-limit-fallback.json`
136
+ 5. `~/.opencode/rate-limit-fallback.json`
137
+ 6. `~/.config/opencode/rate-limit-fallback.json`
138
+
139
+ > **Note**: If you're using git worktrees and want different configurations per worktree, create config files in the worktree directories (locations 1-2). Otherwise, a single project-level or global config is sufficient.
112
140
 
113
141
  ### Fallback Modes
114
142
 
@@ -221,13 +249,119 @@ The circuit breaker maintains three states for each model:
221
249
  | Production | 5 | 60000 | 1 |
222
250
  | High Availability | 10 | 30000 | 2 |
223
251
 
224
- ### Default Fallback Models
252
+ ### ⚠️ Important: Configuration Required
253
+
254
+ **As of v1.43.0, this plugin requires explicit configuration.**
255
+
256
+ The default fallback models array is empty, meaning no fallback behavior will occur until you create a configuration file.
257
+
258
+ **You must create a config file at one of these locations:**
259
+
260
+ **Config file search order (highest to lowest priority):**
261
+ 1. `<worktree>/.opencode/rate-limit-fallback.json`
262
+ 2. `<worktree>/rate-limit-fallback.json`
263
+ 3. `<project>/.opencode/rate-limit-fallback.json`
264
+ 4. `<project>/rate-limit-fallback.json`
265
+ 5. `~/.opencode/rate-limit-fallback.json` (recommended for most users)
266
+ 6. `~/.config/opencode/rate-limit-fallback.json`
267
+
268
+ > **Note**: Project-local and worktree configs (1-4) take precedence over global configs (5-6).
269
+
270
+ **If no config file is found, the plugin will:**
271
+ - Log a warning message
272
+ - Not perform any fallback operations
273
+ - Continue functioning normally with rate-limited models
274
+
275
+ **Minimum working configuration:**
276
+ ```json
277
+ {
278
+ "fallbackModels": [
279
+ { "providerID": "anthropic", "modelID": "claude-3-5-sonnet-20250514" }
280
+ ]
281
+ }
282
+ ```
283
+
284
+ ## Migrating from v1.42.x or earlier
285
+
286
+ ### Breaking Change: Empty Default Models
287
+
288
+ **What changed?**
289
+ - v1.43.0 removed the default fallback models
290
+ - You must now explicitly configure your fallback models
291
+ - The plugin will not work without a configuration file
292
+
293
+ **Why was this changed?**
294
+ - To prevent unintended model usage (e.g., Gemini when not wanted)
295
+ - To make configuration errors obvious immediately
296
+ - To give users explicit control over which models to use
297
+
298
+ ### How to Migrate
299
+
300
+ 1. **Create a config file** at one of the locations listed above
301
+ 2. **Add your desired fallback models** to the `fallbackModels` array
302
+ 3. **Restart OpenCode** to load the new configuration
303
+
304
+ ### Example Migration
305
+
306
+ **Before v1.43.0** (no config needed, used defaults):
307
+ ```
308
+ Plugin automatically used Claude and Gemini models as fallbacks
309
+ ```
310
+
311
+ **After v1.43.0** (must create config):
312
+ ```json
313
+ {
314
+ "fallbackModels": [
315
+ { "providerID": "anthropic", "modelID": "claude-3-5-sonnet-20250514" },
316
+ { "providerID": "google", "modelID": "gemini-2.5-pro" }
317
+ ],
318
+ "enabled": true
319
+ }
320
+ ```
321
+
322
+ ## Troubleshooting
323
+
324
+ ### "No fallback models configured" warning
325
+
326
+ **Problem**: You see a warning about no fallback models configured.
327
+
328
+ **Solution**: Create a config file with your desired fallback models. See the Configuration section above for details.
329
+
330
+ ### Plugin isn't falling back when rate limited
331
+
332
+ **Problem**: Rate limits occur but no fallback happens.
333
+
334
+ **Solutions**:
335
+ 1. Check that a config file exists and is valid
336
+ 2. Verify that `fallbackModels` is not empty in your config
337
+ 3. Check that `enabled: true` is set in your config
338
+ 4. Review logs for error messages
339
+
340
+ ### "Config file not found" warning
341
+
342
+ **Problem**: You see warnings about config file not being found.
343
+
344
+ **Solution**: Create a config file at one of the recommended locations:
345
+
346
+ **Config file search order (highest to lowest priority):**
347
+ 1. `<worktree>/.opencode/rate-limit-fallback.json`
348
+ 2. `<worktree>/rate-limit-fallback.json`
349
+ 3. `<project>/.opencode/rate-limit-fallback.json`
350
+ 4. `<project>/rate-limit-fallback.json`
351
+ 5. `~/.opencode/rate-limit-fallback.json` (recommended for most users)
352
+ 6. `~/.config/opencode/rate-limit-fallback.json`
353
+
354
+ > **Note**: Project-local and worktree configs (1-4) take precedence over global configs (5-6).
355
+
356
+ ### All models exhausted quickly
225
357
 
226
- If no configuration is provided, the following models are used:
358
+ **Problem**: Fallback models are exhausted in a short time.
227
359
 
228
- 1. `anthropic/claude-3-5-sonnet-20250514`
229
- 2. `google/gemini-2.5-pro`
230
- 3. `google/gemini-2.5-flash`
360
+ **Solutions**:
361
+ 1. Add more fallback models to your config
362
+ 2. Increase `cooldownMs` to allow models to recover
363
+ 3. Consider using `fallbackMode: "cycle"` to reset automatically
364
+ 4. Check your API rate limits
231
365
 
232
366
  ## How It Works
233
367
 
package/dist/index.js CHANGED
@@ -13,6 +13,8 @@ import { ConfigValidator } from "./src/config/Validator.js";
13
13
  import { ErrorPatternRegistry } from "./src/errors/PatternRegistry.js";
14
14
  import { HealthTracker } from "./src/health/HealthTracker.js";
15
15
  import { DiagnosticReporter } from "./src/diagnostics/Reporter.js";
16
+ import { ConfigWatcher } from "./src/config/Watcher.js";
17
+ import { ConfigReloader } from "./src/main/ConfigReloader.js";
16
18
  // ============================================================================
17
19
  // Helper Functions
18
20
  // ============================================================================
@@ -178,6 +180,26 @@ export const RateLimitFallback = async ({ client, directory, worktree }) => {
178
180
  const subagentTracker = new SubagentTracker(config);
179
181
  const metricsManager = new MetricsManager(config.metrics ?? { enabled: false, output: { console: true, format: "pretty" }, resetInterval: "daily" }, logger);
180
182
  const fallbackHandler = new FallbackHandler(config, client, logger, metricsManager, subagentTracker, healthTracker);
183
+ // Initialize config reloader if hot reload is enabled
184
+ let configWatcher;
185
+ if (config.configReload?.enabled) {
186
+ const componentRefs = {
187
+ fallbackHandler,
188
+ metricsManager,
189
+ };
190
+ const configReloader = new ConfigReloader(config, configSource, logger, validator, client, componentRefs, directory, worktree, config.configReload?.notifyOnReload ?? true);
191
+ configWatcher = new ConfigWatcher(configSource || '', logger, async () => { await configReloader.reloadConfig(); }, {
192
+ enabled: config.configReload.enabled,
193
+ watchFile: config.configReload.watchFile,
194
+ debounceMs: config.configReload.debounceMs,
195
+ });
196
+ configWatcher.start();
197
+ logger.info('Config hot reload enabled', {
198
+ configPath: configSource || 'none',
199
+ debounceMs: config.configReload.debounceMs,
200
+ notifyOnReload: config.configReload.notifyOnReload,
201
+ });
202
+ }
181
203
  // Cleanup stale entries periodically
182
204
  const cleanupInterval = setInterval(() => {
183
205
  subagentTracker.cleanupStaleEntries();
@@ -244,6 +266,9 @@ export const RateLimitFallback = async ({ client, directory, worktree }) => {
244
266
  if (healthTracker) {
245
267
  healthTracker.destroy();
246
268
  }
269
+ if (configWatcher) {
270
+ configWatcher.stop();
271
+ }
247
272
  },
248
273
  };
249
274
  };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Configuration file watcher for hot reload functionality
3
+ */
4
+ import type { Logger } from '../../logger.js';
5
+ /**
6
+ * Options for config watching
7
+ */
8
+ export interface ConfigWatchOptions {
9
+ enabled: boolean;
10
+ watchFile: boolean;
11
+ debounceMs: number;
12
+ }
13
+ /**
14
+ * ConfigWatcher class - watches config file for changes
15
+ */
16
+ export declare class ConfigWatcher {
17
+ private watcher?;
18
+ private debounceTimer?;
19
+ private configPath;
20
+ private logger;
21
+ private onReload;
22
+ private options;
23
+ private isReloading;
24
+ constructor(configPath: string, logger: Logger, onReload: () => Promise<void>, options: ConfigWatchOptions);
25
+ /**
26
+ * Start watching the config file
27
+ */
28
+ start(): void;
29
+ /**
30
+ * Stop watching the config file
31
+ */
32
+ stop(): void;
33
+ /**
34
+ * Handle config file change event
35
+ */
36
+ private handleConfigChange;
37
+ /**
38
+ * Check if watcher is currently active
39
+ */
40
+ isActive(): boolean;
41
+ /**
42
+ * Check if a reload is currently in progress
43
+ */
44
+ isReloadingInProgress(): boolean;
45
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Configuration file watcher for hot reload functionality
3
+ */
4
+ import { watch } from 'fs';
5
+ /**
6
+ * ConfigWatcher class - watches config file for changes
7
+ */
8
+ export class ConfigWatcher {
9
+ watcher;
10
+ debounceTimer;
11
+ configPath;
12
+ logger;
13
+ onReload;
14
+ options;
15
+ isReloading;
16
+ constructor(configPath, logger, onReload, options) {
17
+ this.configPath = configPath;
18
+ this.logger = logger;
19
+ this.onReload = onReload;
20
+ this.options = options;
21
+ this.isReloading = false;
22
+ }
23
+ /**
24
+ * Start watching the config file
25
+ */
26
+ start() {
27
+ if (!this.options.enabled || !this.options.watchFile) {
28
+ this.logger.info('Config hot reload is disabled');
29
+ return;
30
+ }
31
+ if (!this.configPath) {
32
+ this.logger.warn('No config file path provided, cannot watch for changes');
33
+ return;
34
+ }
35
+ try {
36
+ this.watcher = watch(this.configPath, (eventType, filename) => {
37
+ this.logger.debug(`Config file event: ${eventType} ${filename || ''}`);
38
+ // Debounce the reload
39
+ if (this.debounceTimer) {
40
+ clearTimeout(this.debounceTimer);
41
+ }
42
+ this.debounceTimer = setTimeout(() => {
43
+ this.handleConfigChange();
44
+ }, this.options.debounceMs);
45
+ });
46
+ this.logger.info(`Watching config file for changes: ${this.configPath}`);
47
+ }
48
+ catch (error) {
49
+ const errorMessage = error instanceof Error ? error.message : String(error);
50
+ this.logger.warn(`Failed to watch config file: ${errorMessage}`);
51
+ this.logger.warn('Hot reload will not be available');
52
+ }
53
+ }
54
+ /**
55
+ * Stop watching the config file
56
+ */
57
+ stop() {
58
+ if (this.debounceTimer) {
59
+ clearTimeout(this.debounceTimer);
60
+ this.debounceTimer = undefined;
61
+ }
62
+ if (this.watcher) {
63
+ try {
64
+ this.watcher.close();
65
+ this.watcher = undefined;
66
+ this.logger.info('Stopped watching config file');
67
+ }
68
+ catch (error) {
69
+ const errorMessage = error instanceof Error ? error.message : String(error);
70
+ this.logger.warn(`Failed to stop watching config file: ${errorMessage}`);
71
+ this.watcher = undefined;
72
+ }
73
+ }
74
+ }
75
+ /**
76
+ * Handle config file change event
77
+ */
78
+ async handleConfigChange() {
79
+ if (this.isReloading) {
80
+ this.logger.warn('Config changed while reload in progress, changes will be picked up after current reload completes');
81
+ return;
82
+ }
83
+ this.isReloading = true;
84
+ try {
85
+ this.logger.info('Config file changed, reloading...');
86
+ await this.onReload();
87
+ this.logger.info('Config reload completed successfully');
88
+ }
89
+ catch (error) {
90
+ const errorMessage = error instanceof Error ? error.message : String(error);
91
+ this.logger.error('Config reload failed', { error: errorMessage });
92
+ }
93
+ finally {
94
+ this.isReloading = false;
95
+ }
96
+ }
97
+ /**
98
+ * Check if watcher is currently active
99
+ */
100
+ isActive() {
101
+ return this.watcher !== undefined;
102
+ }
103
+ /**
104
+ * Check if a reload is currently in progress
105
+ */
106
+ isReloadingInProgress() {
107
+ return this.isReloading;
108
+ }
109
+ }
@@ -69,3 +69,12 @@ export declare const DEFAULT_METRICS_CONFIG: {
69
69
  };
70
70
  readonly resetInterval: "daily";
71
71
  };
72
+ /**
73
+ * Default config reload configuration
74
+ */
75
+ export declare const DEFAULT_CONFIG_RELOAD_CONFIG: {
76
+ readonly enabled: false;
77
+ readonly watchFile: true;
78
+ readonly debounceMs: 1000;
79
+ readonly notifyOnReload: true;
80
+ };
@@ -89,3 +89,15 @@ export const DEFAULT_METRICS_CONFIG = {
89
89
  },
90
90
  resetInterval: "daily",
91
91
  };
92
+ // ============================================================================
93
+ // Config Reload Defaults
94
+ // ============================================================================
95
+ /**
96
+ * Default config reload configuration
97
+ */
98
+ export const DEFAULT_CONFIG_RELOAD_CONFIG = {
99
+ enabled: false,
100
+ watchFile: true,
101
+ debounceMs: 1000,
102
+ notifyOnReload: true,
103
+ };
@@ -71,4 +71,8 @@ export declare class FallbackHandler {
71
71
  * Clean up all resources
72
72
  */
73
73
  destroy(): void;
74
+ /**
75
+ * Update configuration (for hot reload)
76
+ */
77
+ updateConfig(newConfig: PluginConfig): void;
74
78
  }
@@ -438,4 +438,32 @@ export class FallbackHandler {
438
438
  this.circuitBreaker.destroy();
439
439
  }
440
440
  }
441
+ /**
442
+ * Update configuration (for hot reload)
443
+ */
444
+ updateConfig(newConfig) {
445
+ this.config = newConfig;
446
+ // Update model selector
447
+ this.modelSelector.updateConfig(newConfig);
448
+ // Update retry manager
449
+ this.retryManager.updateConfig(newConfig.retryPolicy || {});
450
+ // Recreate circuit breaker if configuration changed significantly
451
+ const oldCircuitBreakerEnabled = this.circuitBreaker !== undefined;
452
+ if (newConfig.circuitBreaker?.enabled !== oldCircuitBreakerEnabled) {
453
+ // Destroy existing circuit breaker
454
+ if (this.circuitBreaker) {
455
+ this.circuitBreaker.destroy();
456
+ }
457
+ // Create new circuit breaker if enabled
458
+ if (newConfig.circuitBreaker?.enabled) {
459
+ this.circuitBreaker = new CircuitBreaker(newConfig.circuitBreaker, this.logger, this.metricsManager, this.client);
460
+ this.modelSelector.setCircuitBreaker(this.circuitBreaker);
461
+ }
462
+ else {
463
+ this.circuitBreaker = undefined;
464
+ this.modelSelector.setCircuitBreaker(undefined);
465
+ }
466
+ }
467
+ this.logger.debug('FallbackHandler configuration updated');
468
+ }
441
469
  }
@@ -42,4 +42,12 @@ export declare class ModelSelector {
42
42
  * Clean up stale rate-limited models
43
43
  */
44
44
  cleanupStaleEntries(): void;
45
+ /**
46
+ * Update configuration (for hot reload)
47
+ */
48
+ updateConfig(newConfig: PluginConfig): void;
49
+ /**
50
+ * Set circuit breaker (for hot reload)
51
+ */
52
+ setCircuitBreaker(circuitBreaker: CircuitBreaker | undefined): void;
45
53
  }
@@ -160,4 +160,16 @@ export class ModelSelector {
160
160
  }
161
161
  }
162
162
  }
163
+ /**
164
+ * Update configuration (for hot reload)
165
+ */
166
+ updateConfig(newConfig) {
167
+ this.config = newConfig;
168
+ }
169
+ /**
170
+ * Set circuit breaker (for hot reload)
171
+ */
172
+ setCircuitBreaker(circuitBreaker) {
173
+ this.circuitBreaker = circuitBreaker;
174
+ }
163
175
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Configuration reloader for hot reload functionality
3
+ */
4
+ import type { Logger } from '../../logger.js';
5
+ import type { PluginConfig, OpenCodeClient, ReloadResult, ReloadMetrics } from '../types/index.js';
6
+ import { ConfigValidator } from '../config/Validator.js';
7
+ /**
8
+ * Component references for updating on reload
9
+ */
10
+ export interface ComponentRefs {
11
+ fallbackHandler: {
12
+ updateConfig: (newConfig: PluginConfig) => void;
13
+ };
14
+ metricsManager?: {
15
+ updateConfig: (newConfig: PluginConfig) => void;
16
+ };
17
+ }
18
+ /**
19
+ * ConfigReloader class - handles configuration reload logic
20
+ */
21
+ export declare class ConfigReloader {
22
+ private config;
23
+ private configPath;
24
+ private logger;
25
+ private validator;
26
+ private client;
27
+ private components;
28
+ private directory;
29
+ private worktree?;
30
+ private notifyOnReload;
31
+ private reloadMetrics;
32
+ constructor(config: PluginConfig, configPath: string | null, logger: Logger, validator: ConfigValidator, client: OpenCodeClient, components: ComponentRefs, directory: string, worktree?: string, notifyOnReload?: boolean);
33
+ /**
34
+ * Reload configuration from file
35
+ */
36
+ reloadConfig(): Promise<ReloadResult>;
37
+ /**
38
+ * Apply configuration changes to components
39
+ */
40
+ private applyConfigChanges;
41
+ /**
42
+ * Get list of changed configuration settings
43
+ */
44
+ private getChangedSettings;
45
+ /**
46
+ * Get current configuration
47
+ */
48
+ getCurrentConfig(): PluginConfig;
49
+ /**
50
+ * Get reload metrics
51
+ */
52
+ getReloadMetrics(): ReloadMetrics;
53
+ /**
54
+ * Show success toast notification
55
+ */
56
+ private showSuccessToast;
57
+ /**
58
+ * Show error toast notification
59
+ */
60
+ private showErrorToast;
61
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Configuration reloader for hot reload functionality
3
+ */
4
+ import { loadConfig } from '../utils/config.js';
5
+ import { safeShowToast } from '../utils/helpers.js';
6
+ /**
7
+ * ConfigReloader class - handles configuration reload logic
8
+ */
9
+ export class ConfigReloader {
10
+ config;
11
+ configPath;
12
+ logger;
13
+ validator;
14
+ client;
15
+ components;
16
+ directory;
17
+ worktree;
18
+ notifyOnReload;
19
+ reloadMetrics;
20
+ constructor(config, configPath, logger, validator, client, components, directory, worktree, notifyOnReload = true) {
21
+ this.config = config;
22
+ this.configPath = configPath;
23
+ this.logger = logger;
24
+ this.validator = validator;
25
+ this.client = client;
26
+ this.components = components;
27
+ this.directory = directory;
28
+ this.worktree = worktree;
29
+ this.notifyOnReload = notifyOnReload;
30
+ this.reloadMetrics = {
31
+ totalReloads: 0,
32
+ successfulReloads: 0,
33
+ failedReloads: 0,
34
+ };
35
+ }
36
+ /**
37
+ * Reload configuration from file
38
+ */
39
+ async reloadConfig() {
40
+ const result = {
41
+ success: false,
42
+ timestamp: Date.now(),
43
+ };
44
+ // Track reload metrics
45
+ this.reloadMetrics.totalReloads++;
46
+ this.reloadMetrics.lastReloadTime = result.timestamp;
47
+ if (!this.configPath) {
48
+ result.error = 'No config file path available';
49
+ this.reloadMetrics.failedReloads++;
50
+ this.reloadMetrics.lastReloadSuccess = false;
51
+ return result;
52
+ }
53
+ try {
54
+ // Load new config
55
+ this.logger.debug(`Loading config from: ${this.configPath}`);
56
+ const loadResult = loadConfig(this.directory, this.worktree, this.logger);
57
+ const newConfig = loadResult.config;
58
+ const source = loadResult.source;
59
+ // Validate new config
60
+ const validation = source
61
+ ? this.validator.validateFile(source, newConfig.configValidation)
62
+ : this.validator.validate(newConfig, newConfig.configValidation);
63
+ if (!validation.isValid && newConfig.configValidation?.strict) {
64
+ result.error = `Validation failed: ${validation.errors.map(e => `${e.path}: ${e.message}`).join(', ')}`;
65
+ this.logger.error('Config validation failed in strict mode');
66
+ this.logger.error(`Errors: ${result.error}`);
67
+ this.showErrorToast('Config Reload Failed', result.error);
68
+ this.reloadMetrics.failedReloads++;
69
+ this.reloadMetrics.lastReloadSuccess = false;
70
+ return result;
71
+ }
72
+ if (validation.errors.length > 0) {
73
+ this.logger.warn(`Config validation found ${validation.errors.length} error(s)`);
74
+ for (const error of validation.errors) {
75
+ this.logger.warn(` ${error.path}: ${error.message}`);
76
+ }
77
+ }
78
+ // Apply the new configuration
79
+ this.applyConfigChanges(newConfig);
80
+ result.success = true;
81
+ this.reloadMetrics.successfulReloads++;
82
+ this.reloadMetrics.lastReloadSuccess = true;
83
+ this.logger.info('Configuration reloaded successfully');
84
+ this.logger.debug(`Reload metrics: ${this.reloadMetrics.successfulReloads}/${this.reloadMetrics.totalReloads} successful`);
85
+ if (this.notifyOnReload) {
86
+ this.showSuccessToast('Configuration Reloaded', 'Settings have been applied');
87
+ }
88
+ return result;
89
+ }
90
+ catch (error) {
91
+ const errorMessage = error instanceof Error ? error.message : String(error);
92
+ result.error = `Failed to reload config: ${errorMessage}`;
93
+ this.logger.error(result.error);
94
+ this.showErrorToast('Config Reload Failed', errorMessage);
95
+ this.reloadMetrics.failedReloads++;
96
+ this.reloadMetrics.lastReloadSuccess = false;
97
+ return result;
98
+ }
99
+ }
100
+ /**
101
+ * Apply configuration changes to components
102
+ */
103
+ applyConfigChanges(newConfig) {
104
+ const oldConfig = this.config;
105
+ // Update internal config reference
106
+ this.config = newConfig;
107
+ // Update components with new config
108
+ this.components.fallbackHandler.updateConfig(newConfig);
109
+ if (this.components.metricsManager) {
110
+ this.components.metricsManager.updateConfig(newConfig);
111
+ }
112
+ // Log configuration changes
113
+ const changedSettings = this.getChangedSettings(oldConfig, newConfig);
114
+ if (changedSettings.length > 0) {
115
+ this.logger.info('Configuration changes applied:');
116
+ for (const change of changedSettings) {
117
+ this.logger.info(` ${change}`);
118
+ }
119
+ }
120
+ }
121
+ /**
122
+ * Get list of changed configuration settings
123
+ */
124
+ getChangedSettings(oldConfig, newConfig) {
125
+ const changes = [];
126
+ // Check fallbackModels
127
+ if (JSON.stringify(oldConfig.fallbackModels) !== JSON.stringify(newConfig.fallbackModels)) {
128
+ changes.push(`fallbackModels: ${oldConfig.fallbackModels.length} → ${newConfig.fallbackModels.length} models`);
129
+ }
130
+ // Check cooldownMs
131
+ if (oldConfig.cooldownMs !== newConfig.cooldownMs) {
132
+ changes.push(`cooldownMs: ${oldConfig.cooldownMs}ms → ${newConfig.cooldownMs}ms`);
133
+ }
134
+ // Check fallbackMode
135
+ if (oldConfig.fallbackMode !== newConfig.fallbackMode) {
136
+ changes.push(`fallbackMode: ${oldConfig.fallbackMode} → ${newConfig.fallbackMode}`);
137
+ }
138
+ // Check retryPolicy
139
+ if (JSON.stringify(oldConfig.retryPolicy) !== JSON.stringify(newConfig.retryPolicy)) {
140
+ changes.push('retryPolicy: updated');
141
+ }
142
+ // Check circuitBreaker
143
+ if (JSON.stringify(oldConfig.circuitBreaker) !== JSON.stringify(newConfig.circuitBreaker)) {
144
+ changes.push('circuitBreaker: updated');
145
+ }
146
+ // Check metrics
147
+ if (JSON.stringify(oldConfig.metrics) !== JSON.stringify(newConfig.metrics)) {
148
+ changes.push('metrics: updated');
149
+ }
150
+ // Check log
151
+ if (JSON.stringify(oldConfig.log) !== JSON.stringify(newConfig.log)) {
152
+ changes.push('log: updated');
153
+ }
154
+ // Check enableHealthBasedSelection
155
+ if (oldConfig.enableHealthBasedSelection !== newConfig.enableHealthBasedSelection) {
156
+ changes.push(`enableHealthBasedSelection: ${oldConfig.enableHealthBasedSelection} → ${newConfig.enableHealthBasedSelection}`);
157
+ }
158
+ // Check verbose
159
+ if (oldConfig.verbose !== newConfig.verbose) {
160
+ changes.push(`verbose: ${oldConfig.verbose} → ${newConfig.verbose}`);
161
+ }
162
+ return changes;
163
+ }
164
+ /**
165
+ * Get current configuration
166
+ */
167
+ getCurrentConfig() {
168
+ return this.config;
169
+ }
170
+ /**
171
+ * Get reload metrics
172
+ */
173
+ getReloadMetrics() {
174
+ return { ...this.reloadMetrics };
175
+ }
176
+ /**
177
+ * Show success toast notification
178
+ */
179
+ showSuccessToast(title, message) {
180
+ safeShowToast(this.client, {
181
+ body: {
182
+ title,
183
+ message,
184
+ variant: 'success',
185
+ duration: 3000,
186
+ },
187
+ });
188
+ }
189
+ /**
190
+ * Show error toast notification
191
+ */
192
+ showErrorToast(title, message) {
193
+ safeShowToast(this.client, {
194
+ body: {
195
+ title,
196
+ message,
197
+ variant: 'error',
198
+ duration: 5000,
199
+ },
200
+ });
201
+ }
202
+ }
@@ -2,7 +2,7 @@
2
2
  * Metrics Manager - Handles metrics collection, aggregation, and reporting
3
3
  */
4
4
  import type { Logger } from '../../logger.js';
5
- import type { MetricsConfig, MetricsData, RateLimitMetrics, FallbackTargetMetrics, ModelPerformanceMetrics, CircuitBreakerStateType } from '../types/index.js';
5
+ import type { MetricsConfig, MetricsData, RateLimitMetrics, FallbackTargetMetrics, ModelPerformanceMetrics, CircuitBreakerStateType, PluginConfig } from '../types/index.js';
6
6
  /**
7
7
  * Metrics Manager class for collecting and reporting metrics
8
8
  */
@@ -98,5 +98,9 @@ export declare class MetricsManager {
98
98
  * Clean up resources
99
99
  */
100
100
  destroy(): void;
101
+ /**
102
+ * Update configuration (for hot reload)
103
+ */
104
+ updateConfig(newConfig: PluginConfig): void;
101
105
  }
102
106
  export type { MetricsConfig, MetricsData, RateLimitMetrics, FallbackTargetMetrics, ModelPerformanceMetrics };
@@ -613,4 +613,31 @@ export class MetricsManager {
613
613
  this.resetTimer = null;
614
614
  }
615
615
  }
616
+ /**
617
+ * Update configuration (for hot reload)
618
+ */
619
+ updateConfig(newConfig) {
620
+ const oldEnabled = this.config.enabled;
621
+ const oldResetInterval = this.config.resetInterval;
622
+ this.config = newConfig.metrics || { enabled: false, output: { console: true, format: "pretty" }, resetInterval: "daily" };
623
+ // Restart reset timer if reset interval changed
624
+ if (oldResetInterval !== this.config.resetInterval && this.config.enabled) {
625
+ this.startResetTimer();
626
+ }
627
+ // Enable/disable reset timer based on config.enabled change
628
+ if (oldEnabled !== this.config.enabled) {
629
+ if (this.config.enabled) {
630
+ this.startResetTimer();
631
+ }
632
+ else if (this.resetTimer) {
633
+ clearInterval(this.resetTimer);
634
+ this.resetTimer = null;
635
+ }
636
+ }
637
+ this.logger.debug('MetricsManager configuration updated', {
638
+ enabled: this.config.enabled,
639
+ resetInterval: this.config.resetInterval,
640
+ output: this.config.output,
641
+ });
642
+ }
616
643
  }
@@ -146,6 +146,33 @@ export interface ErrorPatternsConfig {
146
146
  custom?: ErrorPattern[];
147
147
  enableLearning?: boolean;
148
148
  }
149
+ /**
150
+ * Configuration hot reload settings
151
+ */
152
+ export interface ConfigReloadConfig {
153
+ enabled: boolean;
154
+ watchFile: boolean;
155
+ debounceMs: number;
156
+ notifyOnReload: boolean;
157
+ }
158
+ /**
159
+ * Result of a configuration reload operation
160
+ */
161
+ export interface ReloadResult {
162
+ success: boolean;
163
+ error?: string;
164
+ timestamp: number;
165
+ }
166
+ /**
167
+ * Metrics for configuration reload operations
168
+ */
169
+ export interface ReloadMetrics {
170
+ totalReloads: number;
171
+ successfulReloads: number;
172
+ failedReloads: number;
173
+ lastReloadTime?: number;
174
+ lastReloadSuccess?: boolean;
175
+ }
149
176
  /**
150
177
  * Plugin configuration
151
178
  */
@@ -165,6 +192,7 @@ export interface PluginConfig {
165
192
  healthPersistence?: HealthPersistenceConfig;
166
193
  verbose?: boolean;
167
194
  errorPatterns?: ErrorPatternsConfig;
195
+ configReload?: ConfigReloadConfig;
168
196
  }
169
197
  /**
170
198
  * Fallback state for tracking progress
@@ -4,7 +4,7 @@
4
4
  import { existsSync, readFileSync } from "fs";
5
5
  import { join } from "path";
6
6
  import { DEFAULT_FALLBACK_MODELS, VALID_FALLBACK_MODES, VALID_RESET_INTERVALS, DEFAULT_RETRY_POLICY, VALID_RETRY_STRATEGIES, DEFAULT_CIRCUIT_BREAKER_CONFIG, } from '../types/index.js';
7
- import { DEFAULT_HEALTH_TRACKER_CONFIG, DEFAULT_COOLDOWN_MS, DEFAULT_FALLBACK_MODE, DEFAULT_LOG_CONFIG, DEFAULT_METRICS_CONFIG, } from '../config/defaults.js';
7
+ import { DEFAULT_HEALTH_TRACKER_CONFIG, DEFAULT_COOLDOWN_MS, DEFAULT_FALLBACK_MODE, DEFAULT_LOG_CONFIG, DEFAULT_METRICS_CONFIG, DEFAULT_CONFIG_RELOAD_CONFIG, } from '../config/defaults.js';
8
8
  /**
9
9
  * Default plugin configuration
10
10
  */
@@ -18,6 +18,7 @@ export const DEFAULT_CONFIG = {
18
18
  healthPersistence: DEFAULT_HEALTH_TRACKER_CONFIG,
19
19
  log: DEFAULT_LOG_CONFIG,
20
20
  metrics: DEFAULT_METRICS_CONFIG,
21
+ configReload: DEFAULT_CONFIG_RELOAD_CONFIG,
21
22
  };
22
23
  /**
23
24
  * Validate configuration values
@@ -54,6 +55,10 @@ export function validateConfig(config) {
54
55
  } : DEFAULT_CONFIG.metrics.output,
55
56
  resetInterval: resetInterval && VALID_RESET_INTERVALS.includes(resetInterval) ? resetInterval : DEFAULT_CONFIG.metrics.resetInterval,
56
57
  } : DEFAULT_CONFIG.metrics,
58
+ configReload: config.configReload ? {
59
+ ...DEFAULT_CONFIG.configReload,
60
+ ...config.configReload,
61
+ } : DEFAULT_CONFIG.configReload,
57
62
  };
58
63
  }
59
64
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azumag/opencode-rate-limit-fallback",
3
- "version": "1.43.0",
3
+ "version": "1.49.0",
4
4
  "description": "OpenCode plugin that automatically switches to fallback models when rate limited",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",