@azumag/opencode-rate-limit-fallback 1.31.0 → 1.36.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.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Error Pattern Registry for rate limit error detection
3
+ */
4
+ /**
5
+ * Error Pattern Registry class
6
+ * Manages and matches error patterns for rate limit detection
7
+ */
8
+ export class ErrorPatternRegistry {
9
+ patterns = [];
10
+ logger;
11
+ constructor(logger) {
12
+ // Initialize logger
13
+ this.logger = logger || {
14
+ debug: () => { },
15
+ info: () => { },
16
+ warn: () => { },
17
+ error: () => { },
18
+ };
19
+ this.registerDefaultPatterns();
20
+ }
21
+ /**
22
+ * Register default rate limit error patterns
23
+ */
24
+ registerDefaultPatterns() {
25
+ // Common rate limit patterns (provider-agnostic)
26
+ this.register({
27
+ name: 'http-429',
28
+ patterns: [/\\b429\\b/gi], // HTTP 429 status code with word boundaries
29
+ priority: 100,
30
+ });
31
+ this.register({
32
+ name: 'rate-limit-general',
33
+ patterns: [
34
+ 'rate limit',
35
+ 'rate_limit',
36
+ 'ratelimit',
37
+ 'too many requests',
38
+ 'quota exceeded',
39
+ ],
40
+ priority: 90,
41
+ });
42
+ // Anthropic-specific patterns
43
+ this.register({
44
+ name: 'anthropic-rate-limit',
45
+ provider: 'anthropic',
46
+ patterns: [
47
+ 'rate limit exceeded',
48
+ 'too many requests',
49
+ 'quota exceeded',
50
+ 'rate_limit_error',
51
+ 'overloaded',
52
+ ],
53
+ priority: 80,
54
+ });
55
+ // Google/Gemini-specific patterns
56
+ this.register({
57
+ name: 'google-rate-limit',
58
+ provider: 'google',
59
+ patterns: [
60
+ 'quota exceeded',
61
+ 'resource exhausted',
62
+ 'rate limit exceeded',
63
+ 'user rate limit exceeded',
64
+ 'daily limit exceeded',
65
+ '429',
66
+ ],
67
+ priority: 80,
68
+ });
69
+ // OpenAI-specific patterns
70
+ this.register({
71
+ name: 'openai-rate-limit',
72
+ provider: 'openai',
73
+ patterns: [
74
+ 'rate limit exceeded',
75
+ 'you exceeded your current quota',
76
+ 'quota exceeded',
77
+ 'maximum requests per minute reached',
78
+ 'insufficient_quota',
79
+ ],
80
+ priority: 80,
81
+ });
82
+ }
83
+ /**
84
+ * Register a new error pattern
85
+ */
86
+ register(pattern) {
87
+ // Check for duplicate names
88
+ const existingIndex = this.patterns.findIndex(p => p.name === pattern.name);
89
+ if (existingIndex >= 0) {
90
+ // Update existing pattern
91
+ this.patterns[existingIndex] = pattern;
92
+ }
93
+ else {
94
+ // Add new pattern, sorted by priority (higher priority first)
95
+ this.patterns.push(pattern);
96
+ this.patterns.sort((a, b) => b.priority - a.priority);
97
+ }
98
+ }
99
+ /**
100
+ * Register multiple error patterns
101
+ */
102
+ registerMany(patterns) {
103
+ for (const pattern of patterns) {
104
+ this.register(pattern);
105
+ }
106
+ }
107
+ /**
108
+ * Check if an error matches any registered rate limit pattern
109
+ */
110
+ isRateLimitError(error) {
111
+ return this.getMatchedPattern(error) !== null;
112
+ }
113
+ /**
114
+ * Get the matched pattern for an error, or null if no match
115
+ */
116
+ getMatchedPattern(error) {
117
+ if (!error || typeof error !== 'object') {
118
+ return null;
119
+ }
120
+ const err = error;
121
+ // Extract error text to search
122
+ const responseBody = String(err.data?.responseBody || '');
123
+ const message = String(err.data?.message || err.message || '');
124
+ const name = String(err.name || '');
125
+ const statusCode = err.data?.statusCode?.toString() || '';
126
+ // Combine all text sources for matching
127
+ const allText = [responseBody, message, name, statusCode].join(' ').toLowerCase();
128
+ // Check each pattern
129
+ for (const pattern of this.patterns) {
130
+ for (const patternStr of pattern.patterns) {
131
+ let match = false;
132
+ if (typeof patternStr === 'string') {
133
+ // String matching (case-insensitive)
134
+ if (allText.includes(patternStr.toLowerCase())) {
135
+ match = true;
136
+ }
137
+ }
138
+ else if (patternStr instanceof RegExp) {
139
+ // RegExp matching
140
+ if (patternStr.test(allText)) {
141
+ match = true;
142
+ }
143
+ }
144
+ if (match) {
145
+ return pattern;
146
+ }
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+ /**
152
+ * Get all registered patterns
153
+ */
154
+ getAllPatterns() {
155
+ return [...this.patterns];
156
+ }
157
+ /**
158
+ * Get patterns for a specific provider
159
+ */
160
+ getPatternsForProvider(provider) {
161
+ return this.patterns.filter(p => !p.provider || p.provider === provider);
162
+ }
163
+ /**
164
+ * Get patterns by name
165
+ */
166
+ getPatternByName(name) {
167
+ return this.patterns.find(p => p.name === name);
168
+ }
169
+ /**
170
+ * Remove a pattern by name
171
+ */
172
+ removePattern(name) {
173
+ const index = this.patterns.findIndex(p => p.name === name);
174
+ if (index >= 0) {
175
+ this.patterns.splice(index, 1);
176
+ return true;
177
+ }
178
+ return false;
179
+ }
180
+ /**
181
+ * Clear all patterns (including default ones)
182
+ */
183
+ clearAllPatterns() {
184
+ this.patterns = [];
185
+ }
186
+ /**
187
+ * Reset to default patterns only
188
+ */
189
+ resetToDefaults() {
190
+ this.clearAllPatterns();
191
+ this.registerDefaultPatterns();
192
+ }
193
+ /**
194
+ * Learn a new pattern from an error (for future ML-based learning)
195
+ * Currently disabled - patterns must be manually registered
196
+ */
197
+ addLearnedPattern(_error) {
198
+ // Placeholder for future ML-based pattern learning
199
+ // For now, patterns must be manually registered via config
200
+ this.logger.warn('[ErrorPatternRegistry] Automatic pattern learning is not enabled. Patterns must be manually registered via configuration.');
201
+ }
202
+ /**
203
+ * Get statistics about registered patterns
204
+ */
205
+ getStats() {
206
+ const byProvider = {};
207
+ const byPriority = {};
208
+ for (const pattern of this.patterns) {
209
+ // Count by provider
210
+ const provider = pattern.provider || 'generic';
211
+ byProvider[provider] = (byProvider[provider] || 0) + 1;
212
+ // Count by priority range
213
+ const priorityRange = this.getPriorityRange(pattern.priority);
214
+ byPriority[priorityRange] = (byPriority[priorityRange] || 0) + 1;
215
+ }
216
+ return {
217
+ total: this.patterns.length,
218
+ byProvider,
219
+ byPriority,
220
+ };
221
+ }
222
+ /**
223
+ * Get a readable priority range string
224
+ */
225
+ getPriorityRange(priority) {
226
+ if (priority >= 90)
227
+ return 'high (90-100)';
228
+ if (priority >= 70)
229
+ return 'medium (70-89)';
230
+ if (priority >= 50)
231
+ return 'low (50-69)';
232
+ return 'very low (<50)';
233
+ }
234
+ }
@@ -5,6 +5,7 @@ import type { Logger } from '../../logger.js';
5
5
  import type { FallbackModel, PluginConfig, OpenCodeClient, MessagePart, SessionHierarchy } from '../types/index.js';
6
6
  import { MetricsManager } from '../metrics/MetricsManager.js';
7
7
  import type { SubagentTracker } from '../session/SubagentTracker.js';
8
+ import type { HealthTracker } from '../health/HealthTracker.js';
8
9
  /**
9
10
  * Fallback Handler class for orchestrating the fallback retry flow
10
11
  */
@@ -21,7 +22,9 @@ export declare class FallbackHandler {
21
22
  private metricsManager;
22
23
  private subagentTracker;
23
24
  private retryManager;
24
- constructor(config: PluginConfig, client: OpenCodeClient, logger: Logger, metricsManager: MetricsManager, subagentTracker: SubagentTracker);
25
+ private circuitBreaker?;
26
+ private healthTracker?;
27
+ constructor(config: PluginConfig, client: OpenCodeClient, logger: Logger, metricsManager: MetricsManager, subagentTracker: SubagentTracker, healthTracker?: HealthTracker);
25
28
  /**
26
29
  * Check and mark fallback in progress for deduplication
27
30
  */
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { SESSION_ENTRY_TTL_MS } from '../types/index.js';
5
5
  import { ModelSelector } from './ModelSelector.js';
6
+ import { CircuitBreaker } from '../circuitbreaker/CircuitBreaker.js';
6
7
  import { extractMessageParts, convertPartsToSDKFormat, safeShowToast, getStateKey, getModelKey, DEDUP_WINDOW_MS, STATE_TIMEOUT_MS } from '../utils/helpers.js';
7
8
  import { RetryManager } from '../retry/RetryManager.js';
8
9
  /**
@@ -24,13 +25,22 @@ export class FallbackHandler {
24
25
  subagentTracker;
25
26
  // Retry manager reference
26
27
  retryManager;
27
- constructor(config, client, logger, metricsManager, subagentTracker) {
28
+ // Circuit breaker reference
29
+ circuitBreaker;
30
+ // Health tracker reference
31
+ healthTracker;
32
+ constructor(config, client, logger, metricsManager, subagentTracker, healthTracker) {
28
33
  this.config = config;
29
34
  this.client = client;
30
35
  this.logger = logger;
31
- this.modelSelector = new ModelSelector(config, client);
32
36
  this.metricsManager = metricsManager;
33
37
  this.subagentTracker = subagentTracker;
38
+ this.healthTracker = healthTracker;
39
+ // Initialize circuit breaker if enabled
40
+ if (config.circuitBreaker?.enabled) {
41
+ this.circuitBreaker = new CircuitBreaker(config.circuitBreaker, logger, metricsManager, client);
42
+ }
43
+ this.modelSelector = new ModelSelector(config, client, this.circuitBreaker, healthTracker);
34
44
  this.currentSessionModel = new Map();
35
45
  this.modelRequestStartTimes = new Map();
36
46
  this.retryState = new Map();
@@ -154,6 +164,10 @@ export class FallbackHandler {
154
164
  if (currentProviderID && currentModelID && this.metricsManager) {
155
165
  this.metricsManager.recordRateLimit(currentProviderID, currentModelID);
156
166
  }
167
+ // Record health failure for current model (if health tracking is enabled)
168
+ if (this.healthTracker && currentProviderID && currentModelID) {
169
+ this.healthTracker.recordFailure(currentProviderID, currentModelID);
170
+ }
157
171
  // Abort current session with error handling
158
172
  await this.abortSession(targetSessionID);
159
173
  await safeShowToast(this.client, {
@@ -269,8 +283,15 @@ export class FallbackHandler {
269
283
  messageID: lastUserMessage.info.id,
270
284
  timestamp: Date.now(),
271
285
  });
286
+ // Record retry start time for health tracking
287
+ const retryStartTime = Date.now();
272
288
  // Retry with the selected model
273
289
  await this.retryWithModel(dedupSessionID, nextModel, parts, hierarchy);
290
+ // Record health success for fallback model
291
+ if (this.healthTracker) {
292
+ const responseTime = Date.now() - retryStartTime;
293
+ this.healthTracker.recordSuccess(nextModel.providerID, nextModel.modelID, responseTime);
294
+ }
274
295
  // Record retry success
275
296
  this.retryManager.recordSuccess(dedupSessionID, nextModel.modelID);
276
297
  if (this.metricsManager) {
@@ -301,6 +322,11 @@ export class FallbackHandler {
301
322
  // Non-rate-limit error - record model failure metric
302
323
  const tracked = this.currentSessionModel.get(sessionID);
303
324
  if (tracked) {
325
+ // Record failure to circuit breaker (isRateLimit = false)
326
+ if (this.circuitBreaker) {
327
+ const modelKey = getModelKey(tracked.providerID, tracked.modelID);
328
+ this.circuitBreaker.recordFailure(modelKey, false);
329
+ }
304
330
  if (this.metricsManager) {
305
331
  this.metricsManager.recordModelFailure(tracked.providerID, tracked.modelID);
306
332
  // Check if this was a fallback attempt and record failure
@@ -326,6 +352,11 @@ export class FallbackHandler {
326
352
  // Record fallback success metric
327
353
  const tracked = this.currentSessionModel.get(sessionID);
328
354
  if (tracked) {
355
+ // Record success to circuit breaker
356
+ if (this.circuitBreaker) {
357
+ const modelKey = getModelKey(tracked.providerID, tracked.modelID);
358
+ this.circuitBreaker.recordSuccess(modelKey);
359
+ }
329
360
  if (this.metricsManager) {
330
361
  this.metricsManager.recordFallbackSuccess(tracked.providerID, tracked.modelID, fallbackInfo.timestamp);
331
362
  // Record model performance metric
@@ -374,6 +405,10 @@ export class FallbackHandler {
374
405
  }
375
406
  this.modelSelector.cleanupStaleEntries();
376
407
  this.retryManager.cleanupStaleEntries(SESSION_ENTRY_TTL_MS);
408
+ // Clean up circuit breaker stale entries
409
+ if (this.circuitBreaker) {
410
+ this.circuitBreaker.cleanupStaleEntries();
411
+ }
377
412
  }
378
413
  /**
379
414
  * Clean up all resources
@@ -385,5 +420,9 @@ export class FallbackHandler {
385
420
  this.fallbackInProgress.clear();
386
421
  this.fallbackMessages.clear();
387
422
  this.retryManager.destroy();
423
+ // Destroy circuit breaker
424
+ if (this.circuitBreaker) {
425
+ this.circuitBreaker.destroy();
426
+ }
388
427
  }
389
428
  }
@@ -2,6 +2,8 @@
2
2
  * Model selection logic based on fallback mode
3
3
  */
4
4
  import type { FallbackModel, PluginConfig, OpenCodeClient } from '../types/index.js';
5
+ import type { CircuitBreaker } from '../circuitbreaker/index.js';
6
+ import type { HealthTracker } from '../health/HealthTracker.js';
5
7
  /**
6
8
  * Model Selector class for handling model selection strategies
7
9
  */
@@ -9,7 +11,9 @@ export declare class ModelSelector {
9
11
  private rateLimitedModels;
10
12
  private config;
11
13
  private client;
12
- constructor(config: PluginConfig, client: OpenCodeClient);
14
+ private circuitBreaker?;
15
+ private healthTracker?;
16
+ constructor(config: PluginConfig, client: OpenCodeClient, circuitBreaker?: CircuitBreaker, healthTracker?: HealthTracker);
13
17
  /**
14
18
  * Check if a model is currently rate limited
15
19
  */
@@ -22,6 +26,10 @@ export declare class ModelSelector {
22
26
  * Find the next available model that is not rate limited
23
27
  */
24
28
  private findNextAvailableModel;
29
+ /**
30
+ * Check if a model is available (not rate limited and not blocked by circuit breaker)
31
+ */
32
+ private isModelAvailable;
25
33
  /**
26
34
  * Apply the fallback mode logic
27
35
  */
@@ -10,9 +10,13 @@ export class ModelSelector {
10
10
  rateLimitedModels;
11
11
  config;
12
12
  client;
13
- constructor(config, client) {
13
+ circuitBreaker;
14
+ healthTracker;
15
+ constructor(config, client, circuitBreaker, healthTracker) {
14
16
  this.config = config;
15
17
  this.client = client;
18
+ this.circuitBreaker = circuitBreaker;
19
+ this.healthTracker = healthTracker;
16
20
  this.rateLimitedModels = new Map();
17
21
  }
18
22
  /**
@@ -44,11 +48,25 @@ export class ModelSelector {
44
48
  const startIndex = this.config.fallbackModels.findIndex(m => getModelKey(m.providerID, m.modelID) === currentKey);
45
49
  // If current model is not in the fallback list (startIndex is -1), start from 0
46
50
  const searchStartIndex = Math.max(0, startIndex);
51
+ // Get available models
52
+ const candidates = [];
53
+ for (let i = 0; i < this.config.fallbackModels.length; i++) {
54
+ const model = this.config.fallbackModels[i];
55
+ const key = getModelKey(model.providerID, model.modelID);
56
+ if (!attemptedModels.has(key) && !this.isModelRateLimited(model.providerID, model.modelID) && this.isModelAvailable(model.providerID, model.modelID)) {
57
+ candidates.push(model);
58
+ }
59
+ }
60
+ // Sort by health score if health tracker is enabled
61
+ if (this.healthTracker && this.config.enableHealthBasedSelection) {
62
+ const healthiest = this.healthTracker.getHealthiestModels(candidates);
63
+ return healthiest[0] || null;
64
+ }
47
65
  // Search forward from current position
48
66
  for (let i = searchStartIndex + 1; i < this.config.fallbackModels.length; i++) {
49
67
  const model = this.config.fallbackModels[i];
50
68
  const key = getModelKey(model.providerID, model.modelID);
51
- if (!attemptedModels.has(key) && !this.isModelRateLimited(model.providerID, model.modelID)) {
69
+ if (!attemptedModels.has(key) && !this.isModelRateLimited(model.providerID, model.modelID) && this.isModelAvailable(model.providerID, model.modelID)) {
52
70
  return model;
53
71
  }
54
72
  }
@@ -56,12 +74,23 @@ export class ModelSelector {
56
74
  for (let i = 0; i <= searchStartIndex && i < this.config.fallbackModels.length; i++) {
57
75
  const model = this.config.fallbackModels[i];
58
76
  const key = getModelKey(model.providerID, model.modelID);
59
- if (!attemptedModels.has(key) && !this.isModelRateLimited(model.providerID, model.modelID)) {
77
+ if (!attemptedModels.has(key) && !this.isModelRateLimited(model.providerID, model.modelID) && this.isModelAvailable(model.providerID, model.modelID)) {
60
78
  return model;
61
79
  }
62
80
  }
63
81
  return null;
64
82
  }
83
+ /**
84
+ * Check if a model is available (not rate limited and not blocked by circuit breaker)
85
+ */
86
+ isModelAvailable(providerID, modelID) {
87
+ // Check circuit breaker if enabled
88
+ if (this.circuitBreaker && this.config.circuitBreaker?.enabled) {
89
+ const modelKey = getModelKey(providerID, modelID);
90
+ return this.circuitBreaker.canExecute(modelKey);
91
+ }
92
+ return true;
93
+ }
65
94
  /**
66
95
  * Apply the fallback mode logic
67
96
  */
@@ -79,7 +108,7 @@ export class ModelSelector {
79
108
  const lastModel = this.config.fallbackModels[this.config.fallbackModels.length - 1];
80
109
  if (lastModel) {
81
110
  const isLastModelCurrent = currentProviderID === lastModel.providerID && currentModelID === lastModel.modelID;
82
- if (!isLastModelCurrent && !this.isModelRateLimited(lastModel.providerID, lastModel.modelID)) {
111
+ if (!isLastModelCurrent && !this.isModelRateLimited(lastModel.providerID, lastModel.modelID) && this.isModelAvailable(lastModel.providerID, lastModel.modelID)) {
83
112
  // Use the last model for one more try
84
113
  safeShowToast(this.client, {
85
114
  body: {
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Model Health Tracker
3
+ * Tracks model success rates and response times for health-based selection
4
+ */
5
+ import { Logger } from '../../logger.js';
6
+ import type { FallbackModel, PluginConfig, ModelHealth } from '../types/index.js';
7
+ /**
8
+ * Model Health Tracker class
9
+ */
10
+ export declare class HealthTracker {
11
+ private healthData;
12
+ private persistenceEnabled;
13
+ private persistencePath;
14
+ private healthBasedSelectionEnabled;
15
+ private logger;
16
+ private savePending;
17
+ private saveTimeout?;
18
+ private responseTimeThreshold;
19
+ private responseTimePenaltyDivisor;
20
+ private failurePenaltyMultiplier;
21
+ private persistenceDebounceMs;
22
+ constructor(config: PluginConfig, logger: Logger);
23
+ /**
24
+ * Record a successful request for a model
25
+ */
26
+ recordSuccess(providerID: string, modelID: string, responseTime: number): void;
27
+ /**
28
+ * Record a failed request for a model
29
+ */
30
+ recordFailure(providerID: string, modelID: string): void;
31
+ /**
32
+ * Get the health score for a model (0-100)
33
+ */
34
+ getHealthScore(providerID: string, modelID: string): number;
35
+ /**
36
+ * Get full health data for a model
37
+ */
38
+ getModelHealth(providerID: string, modelID: string): ModelHealth | null;
39
+ /**
40
+ * Get all health data
41
+ */
42
+ getAllHealthData(): ModelHealth[];
43
+ /**
44
+ * Get healthiest models from a list of candidates
45
+ * Returns models sorted by health score (highest first)
46
+ */
47
+ getHealthiestModels(candidates: FallbackModel[], limit?: number): FallbackModel[];
48
+ /**
49
+ * Calculate health score based on metrics
50
+ * Score is 0-100, higher is healthier
51
+ */
52
+ private calculateHealthScore;
53
+ /**
54
+ * Save health state to file (with debouncing)
55
+ */
56
+ saveState(): void;
57
+ /**
58
+ * Perform the actual save operation
59
+ */
60
+ private performSave;
61
+ /**
62
+ * Load health state from file
63
+ */
64
+ loadState(): void;
65
+ /**
66
+ * Reset health data for a specific model
67
+ */
68
+ resetModelHealth(providerID: string, modelID: string): void;
69
+ /**
70
+ * Reset all health data
71
+ */
72
+ resetAllHealth(): void;
73
+ /**
74
+ * Check if health-based selection is enabled
75
+ */
76
+ isEnabled(): boolean;
77
+ /**
78
+ * Get statistics about tracked models
79
+ */
80
+ getStats(): {
81
+ totalTracked: number;
82
+ totalRequests: number;
83
+ totalSuccesses: number;
84
+ totalFailures: number;
85
+ avgHealthScore: number;
86
+ modelsWithReliableData: number;
87
+ };
88
+ /**
89
+ * Clean up old health data (models not used recently)
90
+ */
91
+ cleanupOldEntries(maxAgeMs?: number): number;
92
+ /**
93
+ * Destroy the health tracker
94
+ */
95
+ destroy(): void;
96
+ }