@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.
- package/README.md +134 -47
- package/dist/index.d.ts +2 -1
- package/dist/index.js +58 -7
- package/dist/src/circuitbreaker/CircuitBreaker.d.ts +60 -0
- package/dist/src/circuitbreaker/CircuitBreaker.js +218 -0
- package/dist/src/circuitbreaker/CircuitState.d.ts +44 -0
- package/dist/src/circuitbreaker/CircuitState.js +128 -0
- package/dist/src/circuitbreaker/index.d.ts +8 -0
- package/dist/src/circuitbreaker/index.js +8 -0
- 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 +4 -1
- package/dist/src/fallback/FallbackHandler.js +41 -2
- package/dist/src/fallback/ModelSelector.d.ts +9 -1
- package/dist/src/fallback/ModelSelector.js +33 -4
- package/dist/src/health/HealthTracker.d.ts +96 -0
- package/dist/src/health/HealthTracker.js +353 -0
- package/dist/src/metrics/MetricsManager.d.ts +10 -1
- package/dist/src/metrics/MetricsManager.js +137 -0
- package/dist/src/types/index.d.ts +98 -0
- package/dist/src/types/index.js +10 -0
- package/dist/src/utils/config.d.ts +8 -1
- package/dist/src/utils/config.js +26 -11
- 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
|
*/
|
|
@@ -21,7 +22,9 @@ export declare class FallbackHandler {
|
|
|
21
22
|
private metricsManager;
|
|
22
23
|
private subagentTracker;
|
|
23
24
|
private retryManager;
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|