@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 +153 -19
- package/dist/index.js +25 -0
- package/dist/src/config/Watcher.d.ts +45 -0
- package/dist/src/config/Watcher.js +109 -0
- package/dist/src/config/defaults.d.ts +9 -0
- package/dist/src/config/defaults.js +12 -0
- package/dist/src/fallback/FallbackHandler.d.ts +4 -0
- package/dist/src/fallback/FallbackHandler.js +28 -0
- package/dist/src/fallback/ModelSelector.d.ts +8 -0
- package/dist/src/fallback/ModelSelector.js +12 -0
- package/dist/src/main/ConfigReloader.d.ts +61 -0
- package/dist/src/main/ConfigReloader.js +202 -0
- package/dist/src/metrics/MetricsManager.d.ts +5 -1
- package/dist/src/metrics/MetricsManager.js +27 -0
- package/dist/src/types/index.d.ts +28 -0
- package/dist/src/utils/config.js +6 -1
- package/package.json +1 -1
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
358
|
+
**Problem**: Fallback models are exhausted in a short time.
|
|
227
359
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
+
};
|
|
@@ -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
|
package/dist/src/utils/config.js
CHANGED
|
@@ -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