@azumag/opencode-rate-limit-fallback 1.35.0 → 1.36.1

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
  */
@@ -22,7 +23,8 @@ export declare class FallbackHandler {
22
23
  private subagentTracker;
23
24
  private retryManager;
24
25
  private circuitBreaker?;
25
- constructor(config: PluginConfig, client: OpenCodeClient, logger: Logger, metricsManager: MetricsManager, subagentTracker: SubagentTracker);
26
+ private healthTracker?;
27
+ constructor(config: PluginConfig, client: OpenCodeClient, logger: Logger, metricsManager: MetricsManager, subagentTracker: SubagentTracker, healthTracker?: HealthTracker);
26
28
  /**
27
29
  * Check and mark fallback in progress for deduplication
28
30
  */
@@ -27,17 +27,20 @@ export class FallbackHandler {
27
27
  retryManager;
28
28
  // Circuit breaker reference
29
29
  circuitBreaker;
30
- constructor(config, client, logger, metricsManager, subagentTracker) {
30
+ // Health tracker reference
31
+ healthTracker;
32
+ constructor(config, client, logger, metricsManager, subagentTracker, healthTracker) {
31
33
  this.config = config;
32
34
  this.client = client;
33
35
  this.logger = logger;
34
36
  this.metricsManager = metricsManager;
35
37
  this.subagentTracker = subagentTracker;
38
+ this.healthTracker = healthTracker;
36
39
  // Initialize circuit breaker if enabled
37
40
  if (config.circuitBreaker?.enabled) {
38
41
  this.circuitBreaker = new CircuitBreaker(config.circuitBreaker, logger, metricsManager, client);
39
42
  }
40
- this.modelSelector = new ModelSelector(config, client, this.circuitBreaker);
43
+ this.modelSelector = new ModelSelector(config, client, this.circuitBreaker, healthTracker);
41
44
  this.currentSessionModel = new Map();
42
45
  this.modelRequestStartTimes = new Map();
43
46
  this.retryState = new Map();
@@ -161,6 +164,10 @@ export class FallbackHandler {
161
164
  if (currentProviderID && currentModelID && this.metricsManager) {
162
165
  this.metricsManager.recordRateLimit(currentProviderID, currentModelID);
163
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
+ }
164
171
  // Abort current session with error handling
165
172
  await this.abortSession(targetSessionID);
166
173
  await safeShowToast(this.client, {
@@ -276,8 +283,15 @@ export class FallbackHandler {
276
283
  messageID: lastUserMessage.info.id,
277
284
  timestamp: Date.now(),
278
285
  });
286
+ // Record retry start time for health tracking
287
+ const retryStartTime = Date.now();
279
288
  // Retry with the selected model
280
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
+ }
281
295
  // Record retry success
282
296
  this.retryManager.recordSuccess(dedupSessionID, nextModel.modelID);
283
297
  if (this.metricsManager) {
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import type { FallbackModel, PluginConfig, OpenCodeClient } from '../types/index.js';
5
5
  import type { CircuitBreaker } from '../circuitbreaker/index.js';
6
+ import type { HealthTracker } from '../health/HealthTracker.js';
6
7
  /**
7
8
  * Model Selector class for handling model selection strategies
8
9
  */
@@ -11,7 +12,8 @@ export declare class ModelSelector {
11
12
  private config;
12
13
  private client;
13
14
  private circuitBreaker?;
14
- constructor(config: PluginConfig, client: OpenCodeClient, circuitBreaker?: CircuitBreaker);
15
+ private healthTracker?;
16
+ constructor(config: PluginConfig, client: OpenCodeClient, circuitBreaker?: CircuitBreaker, healthTracker?: HealthTracker);
15
17
  /**
16
18
  * Check if a model is currently rate limited
17
19
  */
@@ -11,10 +11,12 @@ export class ModelSelector {
11
11
  config;
12
12
  client;
13
13
  circuitBreaker;
14
- constructor(config, client, circuitBreaker) {
14
+ healthTracker;
15
+ constructor(config, client, circuitBreaker, healthTracker) {
15
16
  this.config = config;
16
17
  this.client = client;
17
18
  this.circuitBreaker = circuitBreaker;
19
+ this.healthTracker = healthTracker;
18
20
  this.rateLimitedModels = new Map();
19
21
  }
20
22
  /**
@@ -46,6 +48,20 @@ export class ModelSelector {
46
48
  const startIndex = this.config.fallbackModels.findIndex(m => getModelKey(m.providerID, m.modelID) === currentKey);
47
49
  // If current model is not in the fallback list (startIndex is -1), start from 0
48
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
+ }
49
65
  // Search forward from current position
50
66
  for (let i = searchStartIndex + 1; i < this.config.fallbackModels.length; i++) {
51
67
  const model = this.config.fallbackModels[i];
@@ -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
+ }