@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.
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@azumag/opencode-rate-limit-fallback",
3
- "version": "1.35.0",
3
+ "version": "1.36.0",
4
4
  "description": "OpenCode plugin that automatically switches to fallback models when rate limited",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,7 +0,0 @@
1
- /**
2
- * Rate limit error detection
3
- */
4
- /**
5
- * Check if error is rate limit related
6
- */
7
- export declare function isRateLimitError(error: unknown): boolean;
@@ -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
- }