@azumag/opencode-rate-limit-fallback 1.50.0 → 1.57.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
@@ -18,10 +18,12 @@ OpenCode plugin that automatically switches to fallback models when rate limited
18
18
  - Configurable retry limits and timeouts
19
19
  - Retry statistics tracking
20
20
  - Toast notifications for user feedback
21
- - Subagent session support with automatic fallback propagation to parent sessions
22
- - Configurable maximum subagent nesting depth
23
- - **Circuit breaker pattern** to prevent cascading failures from consistently failing models
24
- - **Metrics collection** to track rate limits, fallbacks, and model performance
21
+ - Subagent session support with automatic fallback propagation to parent sessions
22
+ - Configurable maximum subagent nesting depth
23
+ - **Circuit breaker pattern** to prevent cascading failures from consistently failing models
24
+ - **Metrics collection** to track rate limits, fallbacks, and model performance
25
+ - **Configuration hot reload** - Reload configuration changes without restarting OpenCode
26
+ - **Dynamic fallback model prioritization** - Automatically reorders models based on success rate, response time, and usage frequency
25
27
 
26
28
  ## Installation
27
29
 
@@ -98,6 +100,12 @@ Create a configuration file at one of these locations:
98
100
  "recoveryTimeoutMs": 60000,
99
101
  "halfOpenMaxCalls": 1,
100
102
  "successThreshold": 2
103
+ },
104
+ "configReload": {
105
+ "enabled": true,
106
+ "watchFile": true,
107
+ "debounceMs": 1000,
108
+ "notifyOnReload": true
101
109
  }
102
110
  }
103
111
  ```
@@ -111,9 +119,99 @@ Create a configuration file at one of these locations:
111
119
  | `fallbackMode` | string | `"cycle"` | Behavior when all models are exhausted (see below) |
112
120
  | `fallbackModels` | array | See below | List of fallback models in priority order |
113
121
  | `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) |
122
+ | `enableSubagentFallback` | boolean | `true` | Enable/disable fallback for subagent sessions |
123
+ | `retryPolicy` | object | See below | Retry policy configuration (see below) |
124
+ | `circuitBreaker` | object | See below | Circuit breaker configuration (see below) |
125
+ | `configReload` | object | See below | Configuration hot reload settings (see below) |
126
+ | `dynamicPrioritization` | object | See below | Dynamic prioritization settings (see below) |
127
+
128
+ ### Dynamic Prioritization
129
+
130
+ The dynamic prioritization feature automatically reorders your fallback models based on their performance metrics, helping you use the most reliable and fastest models first.
131
+
132
+ | Option | Type | Default | Description |
133
+ |--------|------|---------|-------------|
134
+ | `enabled` | boolean | `false` | Enable/disable dynamic prioritization |
135
+ | `updateInterval` | number | `10` | Number of requests between score updates (performance optimization) |
136
+ | `successRateWeight` | number | `0.6` | Weight for success rate (0-1) |
137
+ | `responseTimeWeight` | number | `0.3` | Weight for response time (0-1) |
138
+ | `recentUsageWeight` | number | `0.1` | Weight for recent usage frequency (0-1) |
139
+ | `minSamples` | number | `3` | Minimum samples before using dynamic ordering |
140
+ | `maxHistorySize` | number | `100` | Maximum history size for usage tracking |
141
+
142
+ #### How It Works
143
+
144
+ Dynamic prioritization calculates a score for each model based on three factors:
145
+
146
+ 1. **Success Rate** (default weight: 0.6)
147
+ - Based on health score from HealthTracker
148
+ - Higher success rate = higher score
149
+
150
+ 2. **Response Time** (default weight: 0.3)
151
+ - Faster response times get higher scores
152
+ - Thresholds: <500ms (excellent), >5000ms (poor)
153
+
154
+ 3. **Recent Usage** (default weight: 0.1)
155
+ - Recently used models get a small boost
156
+ - Decays over 24 hours
157
+
158
+ The final score is calculated as:
159
+ ```
160
+ score = (healthScore / 100 * successRateWeight) +
161
+ (normalizedResponseTime * responseTimeWeight) +
162
+ (normalizedRecentUsage * recentUsageWeight)
163
+ ```
164
+
165
+ #### Learning Phase
166
+
167
+ - Uses static ordering until `minSamples` models have sufficient data
168
+ - Default: 3 models need at least 3 requests each
169
+ - Ensures reliable data before reordering
170
+
171
+ #### Configuration Examples
172
+
173
+ **Enable with defaults:**
174
+ ```json
175
+ {
176
+ "dynamicPrioritization": {
177
+ "enabled": true
178
+ }
179
+ }
180
+ ```
181
+
182
+ **Full configuration:**
183
+ ```json
184
+ {
185
+ "dynamicPrioritization": {
186
+ "enabled": true,
187
+ "updateInterval": 10,
188
+ "successRateWeight": 0.6,
189
+ "responseTimeWeight": 0.3,
190
+ "recentUsageWeight": 0.1,
191
+ "minSamples": 3,
192
+ "maxHistorySize": 100
193
+ }
194
+ }
195
+ ```
196
+
197
+ **Prioritize speed over reliability:**
198
+ ```json
199
+ {
200
+ "dynamicPrioritization": {
201
+ "enabled": true,
202
+ "successRateWeight": 0.4,
203
+ "responseTimeWeight": 0.5,
204
+ "recentUsageWeight": 0.1
205
+ }
206
+ }
207
+ ```
208
+
209
+ #### Important Notes
210
+
211
+ - **Disabled by default**: Set `enabled: true` to activate
212
+ - **Requires health tracking**: Uses HealthTracker data for success rates
213
+ - **Weights must sum to ~1.0**: Ensure optimal scoring behavior
214
+ - **Hot reload supported**: Can be enabled/disabled without restarting OpenCode
117
215
 
118
216
  ### Git Worktree Support
119
217
 
@@ -249,6 +347,75 @@ The circuit breaker maintains three states for each model:
249
347
  | Production | 5 | 60000 | 1 |
250
348
  | High Availability | 10 | 30000 | 2 |
251
349
 
350
+ ### Configuration Hot Reload
351
+
352
+ The plugin supports automatic configuration reloading without requiring you to restart OpenCode. When you edit your configuration file, the plugin detects the changes and applies them seamlessly.
353
+
354
+ #### Configuration Options
355
+
356
+ | Option | Type | Default | Description |
357
+ |--------|------|---------|-------------|
358
+ | `configReload.enabled` | boolean | `false` | Enable/disable configuration hot reload |
359
+ | `configReload.watchFile` | boolean | `true` | Watch config file for changes |
360
+ | `configReload.debounceMs` | number | `1000` | Debounce delay (ms) to handle multiple file writes |
361
+ | `configReload.notifyOnReload` | boolean | `true` | Show toast notifications on reload |
362
+
363
+ #### How It Works
364
+
365
+ 1. **File Watching**: When enabled, the plugin watches your configuration file for changes
366
+ 2. **Debouncing**: Multiple file writes (e.g., from editors) are debounced to prevent unnecessary reloads
367
+ 3. **Validation**: New configuration is validated before applying it
368
+ 4. **Graceful Application**: If valid, the new configuration is applied without interrupting active sessions
369
+ 5. **Toast Notifications**: You receive toast notifications for successful or failed reloads
370
+
371
+ #### Behavior
372
+
373
+ **What gets reloaded:**
374
+ - Fallback model list
375
+ - Cooldown periods
376
+ - Fallback mode
377
+ - Retry policies
378
+ - Circuit breaker settings
379
+ - Metrics configuration
380
+ - Log configuration
381
+ - Health tracking settings
382
+
383
+ **What doesn't change:**
384
+ - Active session states
385
+ - Rate-limited model tracking
386
+ - Health tracking data
387
+ - Metrics history
388
+
389
+ #### Configuration Examples
390
+
391
+ **Enable hot reload:**
392
+ ```json
393
+ {
394
+ "configReload": {
395
+ "enabled": true
396
+ }
397
+ }
398
+ ```
399
+
400
+ **Full configuration:**
401
+ ```json
402
+ {
403
+ "configReload": {
404
+ "enabled": true,
405
+ "watchFile": true,
406
+ "debounceMs": 1000,
407
+ "notifyOnReload": true
408
+ }
409
+ }
410
+ ```
411
+
412
+ #### Important Notes
413
+
414
+ - **Disabled by default**: Set `configReload.enabled: true` to activate this feature
415
+ - **Valid configs only**: Invalid configurations are rejected, and old config is preserved
416
+ - **No restart needed**: You can experiment with different configurations without restarting OpenCode
417
+ - **Session preservation**: Active sessions continue working during reload
418
+
252
419
  ### ⚠️ Important: Configuration Required
253
420
 
254
421
  **As of v1.43.0, this plugin requires explicit configuration.**
@@ -395,11 +562,12 @@ When OpenCode uses subagents (e.g., for complex tasks requiring specialized agen
395
562
  ## Metrics
396
563
 
397
564
  The plugin includes a metrics collection feature that tracks:
398
- - Rate limit events per provider/model
399
- - Fallback statistics (total, successful, failed, average duration)
400
- - **Retry statistics** (total attempts, successes, failures, average delay)
401
- - Model performance (requests, successes, failures, response time)
402
- - **Circuit breaker statistics** (state transitions, open/closed counts)
565
+ - Rate limit events per provider/model
566
+ - Fallback statistics (total, successful, failed, average duration)
567
+ - **Retry statistics** (total attempts, successes, failures, average delay)
568
+ - Model performance (requests, successes, failures, response time)
569
+ - **Circuit breaker statistics** (state transitions, open/closed counts)
570
+ - **Dynamic prioritization statistics** (enabled status, reorder count, models with scores)
403
571
 
404
572
  ### Metrics Configuration
405
573
 
@@ -480,19 +648,25 @@ Model Performance:
480
648
  Avg Response: 0.85s
481
649
  Success Rate: 90.0%
482
650
 
483
- Circuit Breaker:
484
- ----------------------------------------
485
- anthropic/claude-3-5-sonnet-20250514:
486
- State: OPEN
487
- Failures: 5
488
- Successes: 0
489
- State Transitions: 2
490
- google/gemini-2.5-pro:
491
- State: CLOSED
492
- Failures: 2
493
- Successes: 8
494
- State Transitions: 3
495
- ```
651
+ Circuit Breaker:
652
+ ----------------------------------------
653
+ anthropic/claude-3-5-sonnet-20250514:
654
+ State: OPEN
655
+ Failures: 5
656
+ Successes: 0
657
+ State Transitions: 2
658
+ google/gemini-2.5-pro:
659
+ State: CLOSED
660
+ Failures: 2
661
+ Successes: 8
662
+ State Transitions: 3
663
+
664
+ Dynamic Prioritization:
665
+ ----------------------------------------
666
+ Enabled: Yes
667
+ Reorders: 5
668
+ Models with dynamic scores: 3
669
+ ```
496
670
 
497
671
  **JSON** (machine-readable):
498
672
  ```json
@@ -554,12 +728,17 @@ Model Performance:
554
728
  "failures": 2,
555
729
  "successes": 8,
556
730
  "stateTransitions": 3
557
- }
558
- },
559
- "startedAt": 1739148000000,
560
- "generatedAt": 1739149800000
561
- }
562
- ```
731
+ }
732
+ },
733
+ "dynamicPrioritization": {
734
+ "enabled": true,
735
+ "reorders": 5,
736
+ "modelsWithDynamicScores": 3
737
+ },
738
+ "startedAt": 1739148000000,
739
+ "generatedAt": 1739149800000
740
+ }
741
+ ```
563
742
 
564
743
  **CSV** (spreadsheet-friendly):
565
744
  ```
@@ -584,11 +763,15 @@ google/gemini-2.5-pro,7,5,71.4
584
763
  model,requests,successes,failures,avg_response_time_ms,success_rate
585
764
  google/gemini-2.5-pro,10,9,1,850,90.0
586
765
 
587
- === CIRCUIT_BREAKER ===
588
- model,current_state,failures,successes,state_transitions
589
- anthropic/claude-3-5-sonnet-20250514,OPEN,5,0,2
590
- google/gemini-2.5-pro,CLOSED,2,8,3
591
- ```
766
+ === CIRCUIT_BREAKER ===
767
+ model,current_state,failures,successes,state_transitions
768
+ anthropic/claude-3-5-sonnet-20250514,OPEN,5,0,2
769
+ google/gemini-2.5-pro,CLOSED,2,8,3
770
+
771
+ === DYNAMIC_PRIORITIZATION ===
772
+ enabled,reorders,models_with_dynamic_scores
773
+ Yes,5,3
774
+ ```
592
775
 
593
776
  ## License
594
777
 
@@ -466,6 +466,100 @@ export class ConfigValidator {
466
466
  }
467
467
  }
468
468
  }
469
+ // Validate dynamicPrioritization
470
+ if (config.dynamicPrioritization) {
471
+ if (typeof config.dynamicPrioritization !== 'object') {
472
+ errors.push({
473
+ path: 'dynamicPrioritization',
474
+ message: 'dynamicPrioritization must be an object',
475
+ severity: 'error',
476
+ value: config.dynamicPrioritization,
477
+ });
478
+ }
479
+ else {
480
+ if (config.dynamicPrioritization.enabled !== undefined && typeof config.dynamicPrioritization.enabled !== 'boolean') {
481
+ errors.push({
482
+ path: 'dynamicPrioritization.enabled',
483
+ message: 'enabled must be a boolean',
484
+ severity: 'error',
485
+ value: config.dynamicPrioritization.enabled,
486
+ });
487
+ }
488
+ if (config.dynamicPrioritization.updateInterval !== undefined) {
489
+ if (typeof config.dynamicPrioritization.updateInterval !== 'number' || config.dynamicPrioritization.updateInterval < 1) {
490
+ errors.push({
491
+ path: 'dynamicPrioritization.updateInterval',
492
+ message: 'updateInterval must be a positive number',
493
+ severity: 'error',
494
+ value: config.dynamicPrioritization.updateInterval,
495
+ });
496
+ }
497
+ }
498
+ if (config.dynamicPrioritization.successRateWeight !== undefined) {
499
+ if (typeof config.dynamicPrioritization.successRateWeight !== 'number' || config.dynamicPrioritization.successRateWeight < 0 || config.dynamicPrioritization.successRateWeight > 1) {
500
+ errors.push({
501
+ path: 'dynamicPrioritization.successRateWeight',
502
+ message: 'successRateWeight must be a number between 0 and 1',
503
+ severity: 'error',
504
+ value: config.dynamicPrioritization.successRateWeight,
505
+ });
506
+ }
507
+ }
508
+ if (config.dynamicPrioritization.responseTimeWeight !== undefined) {
509
+ if (typeof config.dynamicPrioritization.responseTimeWeight !== 'number' || config.dynamicPrioritization.responseTimeWeight < 0 || config.dynamicPrioritization.responseTimeWeight > 1) {
510
+ errors.push({
511
+ path: 'dynamicPrioritization.responseTimeWeight',
512
+ message: 'responseTimeWeight must be a number between 0 and 1',
513
+ severity: 'error',
514
+ value: config.dynamicPrioritization.responseTimeWeight,
515
+ });
516
+ }
517
+ }
518
+ if (config.dynamicPrioritization.recentUsageWeight !== undefined) {
519
+ if (typeof config.dynamicPrioritization.recentUsageWeight !== 'number' || config.dynamicPrioritization.recentUsageWeight < 0 || config.dynamicPrioritization.recentUsageWeight > 1) {
520
+ errors.push({
521
+ path: 'dynamicPrioritization.recentUsageWeight',
522
+ message: 'recentUsageWeight must be a number between 0 and 1',
523
+ severity: 'error',
524
+ value: config.dynamicPrioritization.recentUsageWeight,
525
+ });
526
+ }
527
+ }
528
+ // Validate that weights sum to approximately 1.0
529
+ const successRateWeight = config.dynamicPrioritization.successRateWeight ?? 0.6;
530
+ const responseTimeWeight = config.dynamicPrioritization.responseTimeWeight ?? 0.3;
531
+ const recentUsageWeight = config.dynamicPrioritization.recentUsageWeight ?? 0.1;
532
+ const totalWeight = successRateWeight + responseTimeWeight + recentUsageWeight;
533
+ if (Math.abs(totalWeight - 1.0) > 0.1) {
534
+ warnings.push({
535
+ path: 'dynamicPrioritization',
536
+ message: `Weights sum to ${totalWeight.toFixed(2)}, which is significantly different from 1.0. This may affect prioritization behavior.`,
537
+ severity: 'warning',
538
+ value: { successRateWeight, responseTimeWeight, recentUsageWeight, totalWeight },
539
+ });
540
+ }
541
+ if (config.dynamicPrioritization.minSamples !== undefined) {
542
+ if (typeof config.dynamicPrioritization.minSamples !== 'number' || config.dynamicPrioritization.minSamples < 1) {
543
+ errors.push({
544
+ path: 'dynamicPrioritization.minSamples',
545
+ message: 'minSamples must be a positive number',
546
+ severity: 'error',
547
+ value: config.dynamicPrioritization.minSamples,
548
+ });
549
+ }
550
+ }
551
+ if (config.dynamicPrioritization.maxHistorySize !== undefined) {
552
+ if (typeof config.dynamicPrioritization.maxHistorySize !== 'number' || config.dynamicPrioritization.maxHistorySize < 1) {
553
+ errors.push({
554
+ path: 'dynamicPrioritization.maxHistorySize',
555
+ message: 'maxHistorySize must be a positive number',
556
+ severity: 'error',
557
+ value: config.dynamicPrioritization.maxHistorySize,
558
+ });
559
+ }
560
+ }
561
+ }
562
+ }
469
563
  // Log warnings if enabled
470
564
  if (logWarnings && warnings.length > 0 && this.logger) {
471
565
  for (const warning of warnings) {
@@ -78,3 +78,25 @@ export declare const DEFAULT_CONFIG_RELOAD_CONFIG: {
78
78
  readonly debounceMs: 1000;
79
79
  readonly notifyOnReload: true;
80
80
  };
81
+ /**
82
+ * Default dynamic prioritization configuration
83
+ */
84
+ export declare const DEFAULT_DYNAMIC_PRIORITIZATION_CONFIG: {
85
+ readonly enabled: false;
86
+ readonly updateInterval: 10;
87
+ readonly successRateWeight: 0.6;
88
+ readonly responseTimeWeight: 0.3;
89
+ readonly recentUsageWeight: 0.1;
90
+ readonly minSamples: 3;
91
+ readonly maxHistorySize: 100;
92
+ };
93
+ /**
94
+ * Default error pattern learning configuration
95
+ */
96
+ export declare const DEFAULT_ERROR_PATTERN_LEARNING_CONFIG: {
97
+ readonly enableLearning: false;
98
+ readonly autoApproveThreshold: 0.8;
99
+ readonly maxLearnedPatterns: 20;
100
+ readonly minErrorFrequency: 3;
101
+ readonly learningWindowMs: number;
102
+ };
@@ -101,3 +101,31 @@ export const DEFAULT_CONFIG_RELOAD_CONFIG = {
101
101
  debounceMs: 1000,
102
102
  notifyOnReload: true,
103
103
  };
104
+ // ============================================================================
105
+ // Dynamic Prioritization Defaults
106
+ // ============================================================================
107
+ /**
108
+ * Default dynamic prioritization configuration
109
+ */
110
+ export const DEFAULT_DYNAMIC_PRIORITIZATION_CONFIG = {
111
+ enabled: false,
112
+ updateInterval: 10,
113
+ successRateWeight: 0.6,
114
+ responseTimeWeight: 0.3,
115
+ recentUsageWeight: 0.1,
116
+ minSamples: 3,
117
+ maxHistorySize: 100,
118
+ };
119
+ // ============================================================================
120
+ // Error Pattern Learning Defaults
121
+ // ============================================================================
122
+ /**
123
+ * Default error pattern learning configuration
124
+ */
125
+ export const DEFAULT_ERROR_PATTERN_LEARNING_CONFIG = {
126
+ enableLearning: false,
127
+ autoApproveThreshold: 0.8,
128
+ maxLearnedPatterns: 20,
129
+ minErrorFrequency: 3,
130
+ learningWindowMs: 24 * 60 * 60 * 1000, // 24 hours
131
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Dynamic Prioritizer
3
+ * Dynamically prioritizes fallback models based on performance metrics
4
+ */
5
+ import type { Logger } from '../../logger.js';
6
+ import type { FallbackModel, DynamicPrioritizationConfig } from '../types/index.js';
7
+ import type { HealthTracker } from '../health/HealthTracker.js';
8
+ import type { MetricsManager } from '../metrics/MetricsManager.js';
9
+ /**
10
+ * Dynamic Prioritizer class for calculating dynamic model scores
11
+ */
12
+ export declare class DynamicPrioritizer {
13
+ private config;
14
+ private healthTracker;
15
+ private logger;
16
+ private metricsManager?;
17
+ private modelScores;
18
+ private modelUsageHistory;
19
+ private requestCount;
20
+ constructor(config: DynamicPrioritizationConfig, healthTracker: HealthTracker, logger: Logger, metricsManager?: MetricsManager);
21
+ /**
22
+ * Record usage of a model for tracking recent activity
23
+ */
24
+ recordUsage(providerID: string, modelID: string): void;
25
+ /**
26
+ * Calculate dynamic score for a model
27
+ * Score is 0-1, higher is better
28
+ */
29
+ calculateScore(providerID: string, modelID: string): number;
30
+ /**
31
+ * Get prioritized models based on dynamic scores
32
+ * Returns models sorted by score (highest first)
33
+ */
34
+ getPrioritizedModels(candidates: FallbackModel[]): FallbackModel[];
35
+ /**
36
+ * Check if dynamic ordering should be used
37
+ * Returns true if dynamic prioritization is enabled and we have enough data for reliable ordering
38
+ */
39
+ shouldUseDynamicOrdering(): boolean;
40
+ /**
41
+ * Update configuration
42
+ */
43
+ updateConfig(newConfig: DynamicPrioritizationConfig): void;
44
+ /**
45
+ * Get current scores for all tracked models
46
+ */
47
+ getAllScores(): Map<string, number>;
48
+ /**
49
+ * Check if dynamic prioritization is enabled
50
+ */
51
+ isEnabled(): boolean;
52
+ /**
53
+ * Get number of models with calculated scores
54
+ */
55
+ getModelsWithDynamicScores(): number;
56
+ /**
57
+ * Update metrics with current dynamic prioritization state
58
+ */
59
+ updateMetrics(): void;
60
+ /**
61
+ * Reset all scores and usage history
62
+ */
63
+ reset(): void;
64
+ /**
65
+ * Normalize response time (inverse - faster is better)
66
+ * Returns 0-1, higher is better
67
+ */
68
+ private normalizeResponseTime;
69
+ /**
70
+ * Calculate recent usage score
71
+ * Returns 0-1, higher for more recent usage
72
+ */
73
+ private calculateRecentUsageScore;
74
+ }