@azumag/opencode-rate-limit-fallback 1.35.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.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +50 -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,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Health Tracker
|
|
3
|
+
* Tracks model success rates and response times for health-based selection
|
|
4
|
+
*/
|
|
5
|
+
import { getModelKey } from '../utils/helpers.js';
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
7
|
+
import { join, dirname } from 'path';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
/**
|
|
10
|
+
* Default health persistence path
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_HEALTH_PERSISTENCE_PATH = join(homedir(), '.opencode', 'rate-limit-fallback-health.json');
|
|
13
|
+
/**
|
|
14
|
+
* Minimum requests before health score is considered reliable
|
|
15
|
+
*/
|
|
16
|
+
const MIN_REQUESTS_FOR_RELIABLE_SCORE = 3;
|
|
17
|
+
/**
|
|
18
|
+
* Default health configuration
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULT_HEALTH_CONFIG = {
|
|
21
|
+
enabled: true,
|
|
22
|
+
path: DEFAULT_HEALTH_PERSISTENCE_PATH,
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Model Health Tracker class
|
|
26
|
+
*/
|
|
27
|
+
export class HealthTracker {
|
|
28
|
+
healthData;
|
|
29
|
+
persistenceEnabled;
|
|
30
|
+
persistencePath;
|
|
31
|
+
healthBasedSelectionEnabled;
|
|
32
|
+
logger;
|
|
33
|
+
savePending;
|
|
34
|
+
saveTimeout;
|
|
35
|
+
// Configurable thresholds
|
|
36
|
+
responseTimeThreshold;
|
|
37
|
+
responseTimePenaltyDivisor;
|
|
38
|
+
failurePenaltyMultiplier;
|
|
39
|
+
persistenceDebounceMs;
|
|
40
|
+
constructor(config, logger) {
|
|
41
|
+
this.healthData = new Map();
|
|
42
|
+
// Parse health persistence config
|
|
43
|
+
const healthPersistence = config.healthPersistence || DEFAULT_HEALTH_CONFIG;
|
|
44
|
+
this.persistenceEnabled = healthPersistence.enabled !== false;
|
|
45
|
+
this.persistencePath = healthPersistence.path || DEFAULT_HEALTH_PERSISTENCE_PATH;
|
|
46
|
+
this.healthBasedSelectionEnabled = config.enableHealthBasedSelection || false;
|
|
47
|
+
// Initialize logger
|
|
48
|
+
this.logger = logger;
|
|
49
|
+
// Initialize save state
|
|
50
|
+
this.savePending = false;
|
|
51
|
+
// Initialize configurable thresholds (can be customized via config if needed)
|
|
52
|
+
this.responseTimeThreshold = 2000; // ms - threshold for response time penalty
|
|
53
|
+
this.responseTimePenaltyDivisor = 200; // divisor for response time penalty calculation
|
|
54
|
+
this.failurePenaltyMultiplier = 15; // penalty per consecutive failure
|
|
55
|
+
this.persistenceDebounceMs = 30000; // 30 seconds debounce for persistence
|
|
56
|
+
// Load existing state
|
|
57
|
+
if (this.persistenceEnabled) {
|
|
58
|
+
this.loadState();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Record a successful request for a model
|
|
63
|
+
*/
|
|
64
|
+
recordSuccess(providerID, modelID, responseTime) {
|
|
65
|
+
const key = getModelKey(providerID, modelID);
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
let health = this.healthData.get(key);
|
|
68
|
+
if (!health) {
|
|
69
|
+
// Initialize new health entry
|
|
70
|
+
health = {
|
|
71
|
+
modelKey: key,
|
|
72
|
+
providerID,
|
|
73
|
+
modelID,
|
|
74
|
+
totalRequests: 0,
|
|
75
|
+
successfulRequests: 0,
|
|
76
|
+
failedRequests: 0,
|
|
77
|
+
consecutiveFailures: 0,
|
|
78
|
+
avgResponseTime: 0,
|
|
79
|
+
lastUsed: now,
|
|
80
|
+
lastSuccess: now,
|
|
81
|
+
lastFailure: 0,
|
|
82
|
+
healthScore: 100, // Start with perfect score
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// Update metrics
|
|
86
|
+
health.totalRequests++;
|
|
87
|
+
health.successfulRequests++;
|
|
88
|
+
health.consecutiveFailures = 0;
|
|
89
|
+
health.lastUsed = now;
|
|
90
|
+
health.lastSuccess = now;
|
|
91
|
+
// Update average response time (weighted moving average)
|
|
92
|
+
if (health.avgResponseTime === 0) {
|
|
93
|
+
health.avgResponseTime = responseTime;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
// Weight new response at 30%
|
|
97
|
+
health.avgResponseTime = Math.round(health.avgResponseTime * 0.7 + responseTime * 0.3);
|
|
98
|
+
}
|
|
99
|
+
// Recalculate health score
|
|
100
|
+
health.healthScore = this.calculateHealthScore(health);
|
|
101
|
+
this.healthData.set(key, health);
|
|
102
|
+
// Persist if enabled
|
|
103
|
+
if (this.persistenceEnabled) {
|
|
104
|
+
this.saveState();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Record a failed request for a model
|
|
109
|
+
*/
|
|
110
|
+
recordFailure(providerID, modelID) {
|
|
111
|
+
const key = getModelKey(providerID, modelID);
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
let health = this.healthData.get(key);
|
|
114
|
+
if (!health) {
|
|
115
|
+
// Initialize new health entry
|
|
116
|
+
health = {
|
|
117
|
+
modelKey: key,
|
|
118
|
+
providerID,
|
|
119
|
+
modelID,
|
|
120
|
+
totalRequests: 0,
|
|
121
|
+
successfulRequests: 0,
|
|
122
|
+
failedRequests: 0,
|
|
123
|
+
consecutiveFailures: 0,
|
|
124
|
+
avgResponseTime: 0,
|
|
125
|
+
lastUsed: now,
|
|
126
|
+
lastSuccess: 0,
|
|
127
|
+
lastFailure: now,
|
|
128
|
+
healthScore: 100,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// Update metrics
|
|
132
|
+
health.totalRequests++;
|
|
133
|
+
health.failedRequests++;
|
|
134
|
+
health.consecutiveFailures++;
|
|
135
|
+
health.lastUsed = now;
|
|
136
|
+
health.lastFailure = now;
|
|
137
|
+
// Recalculate health score
|
|
138
|
+
health.healthScore = this.calculateHealthScore(health);
|
|
139
|
+
this.healthData.set(key, health);
|
|
140
|
+
// Persist if enabled
|
|
141
|
+
if (this.persistenceEnabled) {
|
|
142
|
+
this.saveState();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Get the health score for a model (0-100)
|
|
147
|
+
*/
|
|
148
|
+
getHealthScore(providerID, modelID) {
|
|
149
|
+
const key = getModelKey(providerID, modelID);
|
|
150
|
+
const health = this.healthData.get(key);
|
|
151
|
+
if (!health) {
|
|
152
|
+
return 100; // No data yet - assume healthy
|
|
153
|
+
}
|
|
154
|
+
return health.healthScore;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Get full health data for a model
|
|
158
|
+
*/
|
|
159
|
+
getModelHealth(providerID, modelID) {
|
|
160
|
+
const key = getModelKey(providerID, modelID);
|
|
161
|
+
return this.healthData.get(key) || null;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Get all health data
|
|
165
|
+
*/
|
|
166
|
+
getAllHealthData() {
|
|
167
|
+
return Array.from(this.healthData.values());
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Get healthiest models from a list of candidates
|
|
171
|
+
* Returns models sorted by health score (highest first)
|
|
172
|
+
*/
|
|
173
|
+
getHealthiestModels(candidates, limit) {
|
|
174
|
+
// Map candidates with health scores
|
|
175
|
+
const scored = candidates.map(model => ({
|
|
176
|
+
model,
|
|
177
|
+
score: this.getHealthScore(model.providerID, model.modelID),
|
|
178
|
+
}));
|
|
179
|
+
// Sort by health score (descending)
|
|
180
|
+
scored.sort((a, b) => b.score - a.score);
|
|
181
|
+
// Return limited results or all
|
|
182
|
+
const result = scored.map(item => item.model);
|
|
183
|
+
return limit ? result.slice(0, limit) : result;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Calculate health score based on metrics
|
|
187
|
+
* Score is 0-100, higher is healthier
|
|
188
|
+
*/
|
|
189
|
+
calculateHealthScore(health) {
|
|
190
|
+
let score = 100;
|
|
191
|
+
// Penalize based on success rate
|
|
192
|
+
if (health.totalRequests >= MIN_REQUESTS_FOR_RELIABLE_SCORE) {
|
|
193
|
+
const successRate = health.successfulRequests / health.totalRequests;
|
|
194
|
+
score = Math.round(score * successRate);
|
|
195
|
+
}
|
|
196
|
+
// Penalize consecutive failures heavily
|
|
197
|
+
const failurePenalty = Math.min(health.consecutiveFailures * this.failurePenaltyMultiplier, 80);
|
|
198
|
+
score -= failurePenalty;
|
|
199
|
+
// Penalize slow response times (if we have data)
|
|
200
|
+
if (health.avgResponseTime > 0) {
|
|
201
|
+
const responseTimePenalty = Math.min(Math.round((health.avgResponseTime - this.responseTimeThreshold) / this.responseTimePenaltyDivisor), 30);
|
|
202
|
+
if (responseTimePenalty > 0) {
|
|
203
|
+
score -= responseTimePenalty;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Ensure score is within valid range
|
|
207
|
+
return Math.max(0, Math.min(100, score));
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Save health state to file (with debouncing)
|
|
211
|
+
*/
|
|
212
|
+
saveState() {
|
|
213
|
+
if (!this.persistenceEnabled) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// If a save is already pending, don't schedule another one
|
|
217
|
+
if (this.savePending) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.savePending = true;
|
|
221
|
+
// Clear any existing timeout
|
|
222
|
+
if (this.saveTimeout) {
|
|
223
|
+
clearTimeout(this.saveTimeout);
|
|
224
|
+
}
|
|
225
|
+
// Schedule debounced save
|
|
226
|
+
this.saveTimeout = setTimeout(() => {
|
|
227
|
+
this.performSave();
|
|
228
|
+
this.savePending = false;
|
|
229
|
+
}, this.persistenceDebounceMs);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Perform the actual save operation
|
|
233
|
+
*/
|
|
234
|
+
performSave() {
|
|
235
|
+
try {
|
|
236
|
+
// Ensure directory exists
|
|
237
|
+
const dir = dirname(this.persistencePath);
|
|
238
|
+
if (!existsSync(dir)) {
|
|
239
|
+
mkdirSync(dir, { recursive: true });
|
|
240
|
+
}
|
|
241
|
+
const state = {
|
|
242
|
+
models: Object.fromEntries(this.healthData.entries()),
|
|
243
|
+
lastUpdated: Date.now(),
|
|
244
|
+
};
|
|
245
|
+
writeFileSync(this.persistencePath, JSON.stringify(state, null, 2), 'utf-8');
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
// Use logger instead of console
|
|
249
|
+
this.logger.warn('[HealthTracker] Failed to save state', { error });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Load health state from file
|
|
254
|
+
*/
|
|
255
|
+
loadState() {
|
|
256
|
+
if (!this.persistenceEnabled || !existsSync(this.persistencePath)) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const content = readFileSync(this.persistencePath, 'utf-8');
|
|
261
|
+
const state = JSON.parse(content);
|
|
262
|
+
// Validate state structure
|
|
263
|
+
if (state.models && typeof state.models === 'object') {
|
|
264
|
+
for (const [key, health] of Object.entries(state.models)) {
|
|
265
|
+
// Validate health object structure
|
|
266
|
+
if (health && typeof health === 'object' && health.modelKey === key) {
|
|
267
|
+
this.healthData.set(key, health);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
// Use logger instead of console
|
|
274
|
+
this.logger.warn('[HealthTracker] Failed to load state, starting fresh', { error });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Reset health data for a specific model
|
|
279
|
+
*/
|
|
280
|
+
resetModelHealth(providerID, modelID) {
|
|
281
|
+
const key = getModelKey(providerID, modelID);
|
|
282
|
+
this.healthData.delete(key);
|
|
283
|
+
if (this.persistenceEnabled) {
|
|
284
|
+
this.saveState();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Reset all health data
|
|
289
|
+
*/
|
|
290
|
+
resetAllHealth() {
|
|
291
|
+
this.healthData.clear();
|
|
292
|
+
if (this.persistenceEnabled) {
|
|
293
|
+
this.saveState();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Check if health-based selection is enabled
|
|
298
|
+
*/
|
|
299
|
+
isEnabled() {
|
|
300
|
+
return this.healthBasedSelectionEnabled;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Get statistics about tracked models
|
|
304
|
+
*/
|
|
305
|
+
getStats() {
|
|
306
|
+
const models = Array.from(this.healthData.values());
|
|
307
|
+
const totalRequests = models.reduce((sum, h) => sum + h.totalRequests, 0);
|
|
308
|
+
const totalSuccesses = models.reduce((sum, h) => sum + h.successfulRequests, 0);
|
|
309
|
+
const totalFailures = models.reduce((sum, h) => sum + h.failedRequests, 0);
|
|
310
|
+
const avgHealthScore = models.length > 0
|
|
311
|
+
? Math.round(models.reduce((sum, h) => sum + h.healthScore, 0) / models.length)
|
|
312
|
+
: 100;
|
|
313
|
+
const modelsWithReliableData = models.filter(h => h.totalRequests >= MIN_REQUESTS_FOR_RELIABLE_SCORE).length;
|
|
314
|
+
return {
|
|
315
|
+
totalTracked: models.length,
|
|
316
|
+
totalRequests,
|
|
317
|
+
totalSuccesses,
|
|
318
|
+
totalFailures,
|
|
319
|
+
avgHealthScore,
|
|
320
|
+
modelsWithReliableData,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Clean up old health data (models not used recently)
|
|
325
|
+
*/
|
|
326
|
+
cleanupOldEntries(maxAgeMs = 30 * 24 * 60 * 60 * 1000) {
|
|
327
|
+
// Default: 30 days
|
|
328
|
+
const now = Date.now();
|
|
329
|
+
let cleaned = 0;
|
|
330
|
+
for (const [key, health] of this.healthData.entries()) {
|
|
331
|
+
if (now - health.lastUsed > maxAgeMs) {
|
|
332
|
+
this.healthData.delete(key);
|
|
333
|
+
cleaned++;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (cleaned > 0 && this.persistenceEnabled) {
|
|
337
|
+
this.saveState();
|
|
338
|
+
}
|
|
339
|
+
return cleaned;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Destroy the health tracker
|
|
343
|
+
*/
|
|
344
|
+
destroy() {
|
|
345
|
+
// Cancel any pending save
|
|
346
|
+
if (this.saveTimeout) {
|
|
347
|
+
clearTimeout(this.saveTimeout);
|
|
348
|
+
}
|
|
349
|
+
// Save state immediately before cleanup
|
|
350
|
+
this.performSave();
|
|
351
|
+
this.healthData.clear();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -74,6 +74,53 @@ export interface MetricsConfig {
|
|
|
74
74
|
output: MetricsOutputConfig;
|
|
75
75
|
resetInterval: "hourly" | "daily" | "weekly";
|
|
76
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Configuration validation options
|
|
79
|
+
*/
|
|
80
|
+
export interface ConfigValidationOptions {
|
|
81
|
+
strict?: boolean;
|
|
82
|
+
logWarnings?: boolean;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Health persistence configuration
|
|
86
|
+
*/
|
|
87
|
+
export interface HealthPersistenceConfig {
|
|
88
|
+
enabled: boolean;
|
|
89
|
+
path?: string;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Health metrics for a model
|
|
93
|
+
*/
|
|
94
|
+
export interface ModelHealth {
|
|
95
|
+
modelKey: string;
|
|
96
|
+
providerID: string;
|
|
97
|
+
modelID: string;
|
|
98
|
+
totalRequests: number;
|
|
99
|
+
successfulRequests: number;
|
|
100
|
+
failedRequests: number;
|
|
101
|
+
consecutiveFailures: number;
|
|
102
|
+
avgResponseTime: number;
|
|
103
|
+
lastUsed: number;
|
|
104
|
+
lastSuccess: number;
|
|
105
|
+
lastFailure: number;
|
|
106
|
+
healthScore: number;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Error pattern definition
|
|
110
|
+
*/
|
|
111
|
+
export interface ErrorPattern {
|
|
112
|
+
name: string;
|
|
113
|
+
provider?: string;
|
|
114
|
+
patterns: (string | RegExp)[];
|
|
115
|
+
priority: number;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Error pattern configuration
|
|
119
|
+
*/
|
|
120
|
+
export interface ErrorPatternsConfig {
|
|
121
|
+
custom?: ErrorPattern[];
|
|
122
|
+
enableLearning?: boolean;
|
|
123
|
+
}
|
|
77
124
|
/**
|
|
78
125
|
* Plugin configuration
|
|
79
126
|
*/
|
|
@@ -88,6 +135,11 @@ export interface PluginConfig {
|
|
|
88
135
|
circuitBreaker?: CircuitBreakerConfig;
|
|
89
136
|
log?: LogConfig;
|
|
90
137
|
metrics?: MetricsConfig;
|
|
138
|
+
configValidation?: ConfigValidationOptions;
|
|
139
|
+
enableHealthBasedSelection?: boolean;
|
|
140
|
+
healthPersistence?: HealthPersistenceConfig;
|
|
141
|
+
verbose?: boolean;
|
|
142
|
+
errorPatterns?: ErrorPatternsConfig;
|
|
91
143
|
}
|
|
92
144
|
/**
|
|
93
145
|
* Fallback state for tracking progress
|
package/package.json
CHANGED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Rate limit error detection
|
|
3
|
-
*/
|
|
4
|
-
/**
|
|
5
|
-
* Check if error is rate limit related
|
|
6
|
-
*/
|
|
7
|
-
export function isRateLimitError(error) {
|
|
8
|
-
if (!error || typeof error !== "object")
|
|
9
|
-
return false;
|
|
10
|
-
// More type-safe error object structure
|
|
11
|
-
const err = error;
|
|
12
|
-
// Check for 429 status code in APIError (strict check)
|
|
13
|
-
if (err.name === "APIError" && err.data?.statusCode === 429) {
|
|
14
|
-
return true;
|
|
15
|
-
}
|
|
16
|
-
// Type-safe access to error fields
|
|
17
|
-
const responseBody = String(err.data?.responseBody || "").toLowerCase();
|
|
18
|
-
const message = String(err.data?.message || err.message || "").toLowerCase();
|
|
19
|
-
// Strict rate limit indicators only - avoid false positives
|
|
20
|
-
const strictRateLimitIndicators = [
|
|
21
|
-
"rate limit",
|
|
22
|
-
"rate_limit",
|
|
23
|
-
"ratelimit",
|
|
24
|
-
"too many requests",
|
|
25
|
-
"quota exceeded",
|
|
26
|
-
];
|
|
27
|
-
// Check for 429 in text (explicit HTTP status code, word-boundary to avoid false positives like "4291")
|
|
28
|
-
if (/\b429\b/.test(responseBody) || /\b429\b/.test(message)) {
|
|
29
|
-
return true;
|
|
30
|
-
}
|
|
31
|
-
// Check for strict rate limit keywords
|
|
32
|
-
return strictRateLimitIndicators.some((indicator) => responseBody.includes(indicator) ||
|
|
33
|
-
message.includes(indicator));
|
|
34
|
-
}
|