@azumag/opencode-rate-limit-fallback 1.40.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 +93 -5
- 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 +7 -1
- package/dist/src/fallback/FallbackHandler.js +35 -4
- 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 +51 -0
- package/dist/src/types/index.js +12 -5
- package/dist/src/utils/config.d.ts +3 -1
- package/dist/src/utils/config.js +49 -4
- 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,36 @@ 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";
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Helper Functions
|
|
20
|
+
// ============================================================================
|
|
21
|
+
/**
|
|
22
|
+
* Get the difference between two objects (returns keys with different values)
|
|
23
|
+
*/
|
|
24
|
+
function getObjectDiff(obj1, obj2) {
|
|
25
|
+
const diffs = [];
|
|
26
|
+
const allKeys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
|
|
27
|
+
for (const key of allKeys) {
|
|
28
|
+
const val1 = obj1[key];
|
|
29
|
+
const val2 = obj2[key];
|
|
30
|
+
if (typeof val1 !== typeof val2) {
|
|
31
|
+
diffs.push(`${key}: ${val1} → ${val2}`);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (val1 === undefined && val2 !== undefined) {
|
|
35
|
+
diffs.push(`${key}: undefined → ${JSON.stringify(val2)}`);
|
|
36
|
+
}
|
|
37
|
+
else if (val1 !== undefined && val2 === undefined) {
|
|
38
|
+
diffs.push(`${key}: ${JSON.stringify(val1)} → undefined`);
|
|
39
|
+
}
|
|
40
|
+
else if (val1 !== val2) {
|
|
41
|
+
diffs.push(`${key}: ${JSON.stringify(val1)} → ${JSON.stringify(val2)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return diffs;
|
|
45
|
+
}
|
|
16
46
|
// ============================================================================
|
|
17
47
|
// Event Type Guards
|
|
18
48
|
// ============================================================================
|
|
@@ -57,22 +87,58 @@ function isSubagentSessionCreatedEvent(event) {
|
|
|
57
87
|
// Main Plugin Export
|
|
58
88
|
// ============================================================================
|
|
59
89
|
export const RateLimitFallback = async ({ client, directory, worktree }) => {
|
|
60
|
-
|
|
61
|
-
// Detect headless mode (no TUI)
|
|
90
|
+
// Detect headless mode (no TUI) before loading config for logging
|
|
62
91
|
const isHeadless = !client.tui;
|
|
92
|
+
// We need a temporary logger to log config loading process
|
|
93
|
+
// Use a minimal config initially
|
|
94
|
+
const tempLogConfig = {
|
|
95
|
+
level: isHeadless ? 'info' : 'warn',
|
|
96
|
+
format: 'simple',
|
|
97
|
+
enableTimestamp: true,
|
|
98
|
+
};
|
|
99
|
+
const tempLogger = createLogger(tempLogConfig, "RateLimitFallback");
|
|
100
|
+
// Log headless mode detection
|
|
101
|
+
if (isHeadless) {
|
|
102
|
+
tempLogger.info("Running in headless mode (no TUI detected)");
|
|
103
|
+
}
|
|
104
|
+
const configLoadResult = loadConfig(directory, worktree, tempLogger);
|
|
105
|
+
const { config, source: configSource } = configLoadResult;
|
|
63
106
|
// Auto-adjust log level for headless mode to ensure visibility
|
|
64
107
|
const logConfig = {
|
|
65
108
|
...config.log,
|
|
66
109
|
level: isHeadless ? 'info' : (config.log?.level ?? 'warn'),
|
|
67
110
|
};
|
|
68
|
-
// Create logger instance
|
|
111
|
+
// Create final logger instance with loaded config
|
|
69
112
|
const logger = createLogger(logConfig, "RateLimitFallback");
|
|
70
113
|
if (configSource) {
|
|
71
|
-
logger.info(`Config loaded from ${configSource}`);
|
|
114
|
+
logger.info(`Config loaded from: ${configSource}`);
|
|
72
115
|
}
|
|
73
116
|
else {
|
|
74
117
|
logger.info("No config file found, using defaults");
|
|
75
118
|
}
|
|
119
|
+
// Log verbose mode status
|
|
120
|
+
if (config.verbose) {
|
|
121
|
+
logger.info("Verbose mode enabled - showing diagnostic information");
|
|
122
|
+
}
|
|
123
|
+
// Log config merge diff in verbose mode
|
|
124
|
+
if (config.verbose && configSource) {
|
|
125
|
+
if (configLoadResult.rawUserConfig &&
|
|
126
|
+
typeof configLoadResult.rawUserConfig === 'object' &&
|
|
127
|
+
configLoadResult.rawUserConfig !== null &&
|
|
128
|
+
!Array.isArray(configLoadResult.rawUserConfig) &&
|
|
129
|
+
Object.keys(configLoadResult.rawUserConfig).length > 0) {
|
|
130
|
+
logger.info("Configuration merge details:");
|
|
131
|
+
const diffs = getObjectDiff(configLoadResult.rawUserConfig, config);
|
|
132
|
+
if (diffs.length > 0) {
|
|
133
|
+
for (const diff of diffs) {
|
|
134
|
+
logger.info(` ${diff}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
logger.info(" No changes from defaults");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
76
142
|
// Initialize configuration validator
|
|
77
143
|
const validator = new ConfigValidator(logger);
|
|
78
144
|
const validation = configSource
|
|
@@ -108,13 +174,32 @@ export const RateLimitFallback = async ({ client, directory, worktree }) => {
|
|
|
108
174
|
errorPatternRegistry, logger);
|
|
109
175
|
// Log startup diagnostics if verbose mode
|
|
110
176
|
if (config.verbose) {
|
|
111
|
-
logger.debug("Verbose mode enabled - showing diagnostic information");
|
|
112
177
|
diagnostics.logCurrentConfig();
|
|
113
178
|
}
|
|
114
179
|
// Initialize components
|
|
115
180
|
const subagentTracker = new SubagentTracker(config);
|
|
116
181
|
const metricsManager = new MetricsManager(config.metrics ?? { enabled: false, output: { console: true, format: "pretty" }, resetInterval: "daily" }, logger);
|
|
117
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
|
+
}
|
|
118
203
|
// Cleanup stale entries periodically
|
|
119
204
|
const cleanupInterval = setInterval(() => {
|
|
120
205
|
subagentTracker.cleanupStaleEntries();
|
|
@@ -181,6 +266,9 @@ export const RateLimitFallback = async ({ client, directory, worktree }) => {
|
|
|
181
266
|
if (healthTracker) {
|
|
182
267
|
healthTracker.destroy();
|
|
183
268
|
}
|
|
269
|
+
if (configWatcher) {
|
|
270
|
+
configWatcher.stop();
|
|
271
|
+
}
|
|
184
272
|
},
|
|
185
273
|
};
|
|
186
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
|
+
};
|
|
@@ -46,7 +46,9 @@ export declare class FallbackHandler {
|
|
|
46
46
|
*/
|
|
47
47
|
private abortSession;
|
|
48
48
|
/**
|
|
49
|
-
*
|
|
49
|
+
* Queue the prompt asynchronously (non-blocking), then abort the retry loop.
|
|
50
|
+
* promptAsync FIRST queues pending work so the server doesn't dispose on idle.
|
|
51
|
+
* abort SECOND cancels the retry loop; the server sees the queued prompt and processes it.
|
|
50
52
|
*/
|
|
51
53
|
retryWithModel(targetSessionID: string, model: FallbackModel, parts: MessagePart[], hierarchy: SessionHierarchy | null): Promise<void>;
|
|
52
54
|
/**
|
|
@@ -69,4 +71,8 @@ export declare class FallbackHandler {
|
|
|
69
71
|
* Clean up all resources
|
|
70
72
|
*/
|
|
71
73
|
destroy(): void;
|
|
74
|
+
/**
|
|
75
|
+
* Update configuration (for hot reload)
|
|
76
|
+
*/
|
|
77
|
+
updateConfig(newConfig: PluginConfig): void;
|
|
72
78
|
}
|
|
@@ -95,7 +95,9 @@ export class FallbackHandler {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
/**
|
|
98
|
-
*
|
|
98
|
+
* Queue the prompt asynchronously (non-blocking), then abort the retry loop.
|
|
99
|
+
* promptAsync FIRST queues pending work so the server doesn't dispose on idle.
|
|
100
|
+
* abort SECOND cancels the retry loop; the server sees the queued prompt and processes it.
|
|
99
101
|
*/
|
|
100
102
|
async retryWithModel(targetSessionID, model, parts, hierarchy) {
|
|
101
103
|
// Track the new model for this session
|
|
@@ -129,13 +131,16 @@ export class FallbackHandler {
|
|
|
129
131
|
}
|
|
130
132
|
// Convert internal MessagePart to SDK-compatible format
|
|
131
133
|
const sdkParts = convertPartsToSDKFormat(parts);
|
|
132
|
-
|
|
134
|
+
// 1. promptAsync: queue the new prompt (returns immediately, non-blocking)
|
|
135
|
+
await this.client.session.promptAsync({
|
|
133
136
|
path: { id: targetSessionID },
|
|
134
137
|
body: {
|
|
135
138
|
parts: sdkParts,
|
|
136
139
|
model: { providerID: model.providerID, modelID: model.modelID },
|
|
137
140
|
},
|
|
138
141
|
});
|
|
142
|
+
// 2. abort: cancel the retry loop; server sees queued prompt and processes it
|
|
143
|
+
await this.abortSession(targetSessionID);
|
|
139
144
|
await safeShowToast(this.client, {
|
|
140
145
|
body: {
|
|
141
146
|
title: "Fallback Successful",
|
|
@@ -176,8 +181,6 @@ export class FallbackHandler {
|
|
|
176
181
|
if (this.healthTracker && currentProviderID && currentModelID) {
|
|
177
182
|
this.healthTracker.recordFailure(currentProviderID, currentModelID);
|
|
178
183
|
}
|
|
179
|
-
// Abort current session with error handling
|
|
180
|
-
await this.abortSession(targetSessionID);
|
|
181
184
|
await safeShowToast(this.client, {
|
|
182
185
|
body: {
|
|
183
186
|
title: "Rate Limit Detected",
|
|
@@ -435,4 +438,32 @@ export class FallbackHandler {
|
|
|
435
438
|
this.circuitBreaker.destroy();
|
|
436
439
|
}
|
|
437
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
|
+
}
|
|
438
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
|
|
@@ -413,6 +441,18 @@ export type OpenCodeClient = {
|
|
|
413
441
|
};
|
|
414
442
|
};
|
|
415
443
|
}) => Promise<unknown>;
|
|
444
|
+
promptAsync: (args: {
|
|
445
|
+
path: {
|
|
446
|
+
id: string;
|
|
447
|
+
};
|
|
448
|
+
body: {
|
|
449
|
+
parts: SDKMessagePartInput[];
|
|
450
|
+
model: {
|
|
451
|
+
providerID: string;
|
|
452
|
+
modelID: string;
|
|
453
|
+
};
|
|
454
|
+
};
|
|
455
|
+
}) => Promise<unknown>;
|
|
416
456
|
};
|
|
417
457
|
tui?: {
|
|
418
458
|
showToast: (toast: ToastMessage) => Promise<unknown>;
|
|
@@ -427,6 +467,17 @@ export type PluginContext = {
|
|
|
427
467
|
};
|
|
428
468
|
/**
|
|
429
469
|
* Default fallback models
|
|
470
|
+
*
|
|
471
|
+
* NOTE: This is intentionally empty to force users to explicitly configure
|
|
472
|
+
* their fallback models. This prevents unintended model usage (e.g., gemini
|
|
473
|
+
* when not wanted) and makes configuration errors obvious immediately.
|
|
474
|
+
*
|
|
475
|
+
* Users must create a config file in one of these locations:
|
|
476
|
+
* - <worktree>/.opencode/rate-limit-fallback.json
|
|
477
|
+
* - <directory>/.opencode/rate-limit-fallback.json
|
|
478
|
+
* - <directory>/rate-limit-fallback.json
|
|
479
|
+
* - ~/.opencode/rate-limit-fallback.json
|
|
480
|
+
* - $XDG_CONFIG_HOME/opencode/rate-limit-fallback.json
|
|
430
481
|
*/
|
|
431
482
|
export declare const DEFAULT_FALLBACK_MODELS: FallbackModel[];
|
|
432
483
|
/**
|
package/dist/src/types/index.js
CHANGED
|
@@ -6,12 +6,19 @@
|
|
|
6
6
|
// ============================================================================
|
|
7
7
|
/**
|
|
8
8
|
* Default fallback models
|
|
9
|
+
*
|
|
10
|
+
* NOTE: This is intentionally empty to force users to explicitly configure
|
|
11
|
+
* their fallback models. This prevents unintended model usage (e.g., gemini
|
|
12
|
+
* when not wanted) and makes configuration errors obvious immediately.
|
|
13
|
+
*
|
|
14
|
+
* Users must create a config file in one of these locations:
|
|
15
|
+
* - <worktree>/.opencode/rate-limit-fallback.json
|
|
16
|
+
* - <directory>/.opencode/rate-limit-fallback.json
|
|
17
|
+
* - <directory>/rate-limit-fallback.json
|
|
18
|
+
* - ~/.opencode/rate-limit-fallback.json
|
|
19
|
+
* - $XDG_CONFIG_HOME/opencode/rate-limit-fallback.json
|
|
9
20
|
*/
|
|
10
|
-
export const DEFAULT_FALLBACK_MODELS = [
|
|
11
|
-
{ providerID: "anthropic", modelID: "claude-3-5-sonnet-20250514" },
|
|
12
|
-
{ providerID: "google", modelID: "gemini-2.5-pro" },
|
|
13
|
-
{ providerID: "google", modelID: "gemini-2.5-flash" },
|
|
14
|
-
];
|
|
21
|
+
export const DEFAULT_FALLBACK_MODELS = [];
|
|
15
22
|
/**
|
|
16
23
|
* Default retry policy
|
|
17
24
|
*/
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Configuration loading and validation
|
|
3
3
|
*/
|
|
4
4
|
import type { PluginConfig } from '../types/index.js';
|
|
5
|
+
import type { Logger } from '../../logger.js';
|
|
5
6
|
/**
|
|
6
7
|
* Default plugin configuration
|
|
7
8
|
*/
|
|
@@ -12,6 +13,7 @@ export declare const DEFAULT_CONFIG: PluginConfig;
|
|
|
12
13
|
export interface ConfigLoadResult {
|
|
13
14
|
config: PluginConfig;
|
|
14
15
|
source: string | null;
|
|
16
|
+
rawUserConfig?: Partial<PluginConfig>;
|
|
15
17
|
}
|
|
16
18
|
/**
|
|
17
19
|
* Validate configuration values
|
|
@@ -20,4 +22,4 @@ export declare function validateConfig(config: Partial<PluginConfig>): PluginCon
|
|
|
20
22
|
/**
|
|
21
23
|
* Load and validate config from file paths
|
|
22
24
|
*/
|
|
23
|
-
export declare function loadConfig(directory: string, worktree?: string): ConfigLoadResult;
|
|
25
|
+
export declare function loadConfig(directory: string, worktree?: string, logger?: Logger): ConfigLoadResult;
|
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,12 +55,16 @@ 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
|
/**
|
|
60
65
|
* Load and validate config from file paths
|
|
61
66
|
*/
|
|
62
|
-
export function loadConfig(directory, worktree) {
|
|
67
|
+
export function loadConfig(directory, worktree, logger) {
|
|
63
68
|
const homedir = process.env.HOME || "";
|
|
64
69
|
const xdgConfigHome = process.env.XDG_CONFIG_HOME || join(homedir, ".config");
|
|
65
70
|
// Build search paths: worktree first, then directory, then home locations
|
|
@@ -77,17 +82,57 @@ export function loadConfig(directory, worktree) {
|
|
|
77
82
|
}
|
|
78
83
|
configPaths.push(join(homedir, ".opencode", "rate-limit-fallback.json"));
|
|
79
84
|
configPaths.push(join(xdgConfigHome, "opencode", "rate-limit-fallback.json"));
|
|
85
|
+
// Log search paths for debugging
|
|
86
|
+
if (logger) {
|
|
87
|
+
logger.debug(`Searching for config file in ${configPaths.length} locations`);
|
|
88
|
+
for (const configPath of configPaths) {
|
|
89
|
+
const exists = existsSync(configPath);
|
|
90
|
+
logger.debug(` ${exists ? "✓" : "✗"} ${configPath}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
80
93
|
for (const configPath of configPaths) {
|
|
81
94
|
if (existsSync(configPath)) {
|
|
82
95
|
try {
|
|
83
96
|
const content = readFileSync(configPath, "utf-8");
|
|
84
97
|
const userConfig = JSON.parse(content);
|
|
85
|
-
|
|
98
|
+
if (logger) {
|
|
99
|
+
logger.info(`Config loaded from: ${configPath}`);
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
config: validateConfig(userConfig),
|
|
103
|
+
source: configPath,
|
|
104
|
+
rawUserConfig: userConfig,
|
|
105
|
+
};
|
|
86
106
|
}
|
|
87
|
-
catch {
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (logger) {
|
|
109
|
+
logger.warn(`Failed to parse config file: ${configPath}`, { error: error instanceof Error ? error.message : String(error) });
|
|
110
|
+
}
|
|
88
111
|
// Skip invalid config files silently - caller will log via structured logger
|
|
89
112
|
}
|
|
90
113
|
}
|
|
91
114
|
}
|
|
115
|
+
if (logger) {
|
|
116
|
+
// Log that no config file was found
|
|
117
|
+
logger.info(`No config file found in any of the ${configPaths.length} search paths. Using default configuration.`);
|
|
118
|
+
// Show a warning if default fallback models is empty (which is now the case)
|
|
119
|
+
if (DEFAULT_CONFIG.fallbackModels.length === 0) {
|
|
120
|
+
logger.warn('No fallback models configured. The plugin will not be able to fallback when rate limited.');
|
|
121
|
+
logger.warn('Please create a config file with your fallback models.');
|
|
122
|
+
logger.warn('Config file locations (in order of priority):');
|
|
123
|
+
for (const configPath of configPaths) {
|
|
124
|
+
logger.warn(` - ${configPath}`);
|
|
125
|
+
}
|
|
126
|
+
logger.warn('Example config:');
|
|
127
|
+
logger.warn(JSON.stringify({
|
|
128
|
+
fallbackModels: [
|
|
129
|
+
{ providerID: "anthropic", modelID: "claude-3-5-sonnet-20250514" },
|
|
130
|
+
],
|
|
131
|
+
cooldownMs: 60000,
|
|
132
|
+
enabled: true,
|
|
133
|
+
fallbackMode: "cycle",
|
|
134
|
+
}, null, 2));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
92
137
|
return { config: DEFAULT_CONFIG, source: null };
|
|
93
138
|
}
|
package/package.json
CHANGED