@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +49 -5
- package/dist/src/circuitbreaker/CircuitBreaker.d.ts +8 -1
- package/dist/src/circuitbreaker/CircuitBreaker.js +11 -1
- package/dist/src/config/Validator.d.ts +64 -0
- package/dist/src/config/Validator.js +618 -0
- package/dist/src/diagnostics/Reporter.d.ts +128 -0
- package/dist/src/diagnostics/Reporter.js +285 -0
- package/dist/src/errors/PatternRegistry.d.ts +75 -0
- package/dist/src/errors/PatternRegistry.js +234 -0
- package/dist/src/fallback/FallbackHandler.d.ts +3 -1
- package/dist/src/fallback/FallbackHandler.js +16 -2
- package/dist/src/fallback/ModelSelector.d.ts +3 -1
- package/dist/src/fallback/ModelSelector.js +17 -1
- package/dist/src/health/HealthTracker.d.ts +96 -0
- package/dist/src/health/HealthTracker.js +353 -0
- package/dist/src/types/index.d.ts +52 -0
- package/package.json +1 -1
- package/dist/src/utils/errorDetection.d.ts +0 -7
- package/dist/src/utils/errorDetection.js +0 -34
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|