@azumag/opencode-rate-limit-fallback 1.24.0 → 1.29.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 +116 -0
- package/dist/src/fallback/FallbackHandler.d.ts +1 -0
- package/dist/src/fallback/FallbackHandler.js +49 -1
- package/dist/src/metrics/MetricsManager.d.ts +12 -0
- package/dist/src/metrics/MetricsManager.js +103 -1
- package/dist/src/retry/RetryManager.d.ts +87 -0
- package/dist/src/retry/RetryManager.js +289 -0
- package/dist/src/types/index.d.ts +88 -1
- package/dist/src/types/index.js +15 -0
- package/dist/src/utils/config.d.ts +1 -1
- package/dist/src/utils/config.js +10 -3
- package/dist/src/utils/helpers.d.ts +3 -3
- package/package.json +1 -1
- package/dist/src/metrics/types.d.ts +0 -11
- package/dist/src/metrics/types.js +0 -11
package/README.md
CHANGED
|
@@ -12,6 +12,11 @@ OpenCode plugin that automatically switches to fallback models when rate limited
|
|
|
12
12
|
- Three fallback modes: `cycle`, `stop`, and `retry-last`
|
|
13
13
|
- Session model tracking for sequential fallback across multiple rate limits
|
|
14
14
|
- Cooldown period to prevent immediate retry on rate-limited models
|
|
15
|
+
- **Exponential backoff with configurable retry policies**
|
|
16
|
+
- Supports immediate, exponential, and linear backoff strategies
|
|
17
|
+
- Jitter to prevent thundering herd problem
|
|
18
|
+
- Configurable retry limits and timeouts
|
|
19
|
+
- Retry statistics tracking
|
|
15
20
|
- Toast notifications for user feedback
|
|
16
21
|
- Subagent session support with automatic fallback propagation to parent sessions
|
|
17
22
|
- Configurable maximum subagent nesting depth
|
|
@@ -64,6 +69,15 @@ Create a configuration file at one of these locations:
|
|
|
64
69
|
{ "providerID": "google", "modelID": "gemini-2.5-pro" },
|
|
65
70
|
{ "providerID": "google", "modelID": "gemini-2.5-flash" }
|
|
66
71
|
],
|
|
72
|
+
"retryPolicy": {
|
|
73
|
+
"maxRetries": 3,
|
|
74
|
+
"strategy": "exponential",
|
|
75
|
+
"baseDelayMs": 1000,
|
|
76
|
+
"maxDelayMs": 30000,
|
|
77
|
+
"jitterEnabled": true,
|
|
78
|
+
"jitterFactor": 0.1,
|
|
79
|
+
"timeoutMs": 60000
|
|
80
|
+
},
|
|
67
81
|
"metrics": {
|
|
68
82
|
"enabled": true,
|
|
69
83
|
"output": {
|
|
@@ -85,6 +99,7 @@ Create a configuration file at one of these locations:
|
|
|
85
99
|
| `fallbackModels` | array | See below | List of fallback models in priority order |
|
|
86
100
|
| `maxSubagentDepth` | number | `10` | Maximum nesting depth for subagent hierarchies |
|
|
87
101
|
| `enableSubagentFallback` | boolean | `true` | Enable/disable fallback for subagent sessions |
|
|
102
|
+
| `retryPolicy` | object | See below | Retry policy configuration (see below) |
|
|
88
103
|
|
|
89
104
|
### Fallback Modes
|
|
90
105
|
|
|
@@ -94,6 +109,64 @@ Create a configuration file at one of these locations:
|
|
|
94
109
|
| `"stop"` | Stop and show error when all models are exhausted |
|
|
95
110
|
| `"retry-last"` | Try the last model once more, then reset to first on next prompt |
|
|
96
111
|
|
|
112
|
+
### Retry Policy
|
|
113
|
+
|
|
114
|
+
The retry policy controls how the plugin handles retry attempts after rate limits, with support for exponential backoff to reduce API pressure.
|
|
115
|
+
|
|
116
|
+
| Option | Type | Default | Description |
|
|
117
|
+
|--------|------|---------|-------------|
|
|
118
|
+
| `maxRetries` | number | `3` | Maximum retry attempts before giving up |
|
|
119
|
+
| `strategy` | string | `"immediate"` | Backoff strategy: `"immediate"`, `"exponential"`, or `"linear"` |
|
|
120
|
+
| `baseDelayMs` | number | `1000` | Base delay in milliseconds for backoff calculation |
|
|
121
|
+
| `maxDelayMs` | number | `30000` | Maximum delay in milliseconds |
|
|
122
|
+
| `jitterEnabled` | boolean | `false` | Add random jitter to delays to prevent thundering herd |
|
|
123
|
+
| `jitterFactor` | number | `0.1` | Jitter factor (0.1 = 10% variance) |
|
|
124
|
+
| `timeoutMs` | number | `undefined` | Overall timeout for all retry attempts (optional) |
|
|
125
|
+
|
|
126
|
+
#### Retry Strategies
|
|
127
|
+
|
|
128
|
+
**Immediate** (default, no backoff)
|
|
129
|
+
```
|
|
130
|
+
delay = 0ms
|
|
131
|
+
```
|
|
132
|
+
Retries immediately without any delay. This is the original behavior and maintains backward compatibility.
|
|
133
|
+
|
|
134
|
+
**Exponential** (recommended for production)
|
|
135
|
+
```
|
|
136
|
+
delay = min(baseDelayMs * (2 ^ attempt), maxDelayMs)
|
|
137
|
+
delay = delay * (1 + random(-jitterFactor, jitterFactor)) // if jitter enabled
|
|
138
|
+
```
|
|
139
|
+
Exponential backoff that doubles the delay after each attempt. This is the standard pattern for rate limit handling.
|
|
140
|
+
|
|
141
|
+
Example with `baseDelayMs: 1000`, `maxDelayMs: 30000`, and `jitterFactor: 0.1`:
|
|
142
|
+
- Attempt 0: ~1000ms (with jitter: 900-1100ms)
|
|
143
|
+
- Attempt 1: ~2000ms (with jitter: 1800-2200ms)
|
|
144
|
+
- Attempt 2: ~4000ms (with jitter: 3600-4400ms)
|
|
145
|
+
- Attempt 3: ~8000ms (with jitter: 7200-8800ms)
|
|
146
|
+
- Attempt 4+: ~16000ms (capped at maxDelayMs: 30000ms)
|
|
147
|
+
|
|
148
|
+
**Linear**
|
|
149
|
+
```
|
|
150
|
+
delay = min(baseDelayMs * (attempt + 1), maxDelayMs)
|
|
151
|
+
delay = delay * (1 + random(-jitterFactor, jitterFactor)) // if jitter enabled
|
|
152
|
+
```
|
|
153
|
+
Linear backoff that increases delay by a constant amount after each attempt.
|
|
154
|
+
|
|
155
|
+
Example with `baseDelayMs: 1000` and `maxDelayMs: 5000`:
|
|
156
|
+
- Attempt 0: ~1000ms
|
|
157
|
+
- Attempt 1: ~2000ms
|
|
158
|
+
- Attempt 2: ~3000ms
|
|
159
|
+
- Attempt 3: ~4000ms
|
|
160
|
+
- Attempt 4+: ~5000ms (capped at maxDelayMs)
|
|
161
|
+
|
|
162
|
+
#### Jitter
|
|
163
|
+
|
|
164
|
+
Jitter adds random variation to delay times to prevent the "thundering herd" problem, where multiple clients retry simultaneously and overwhelm the API.
|
|
165
|
+
|
|
166
|
+
- Recommended for production environments with multiple concurrent users
|
|
167
|
+
- `jitterFactor: 0.1` adds ±10% variance to delay times
|
|
168
|
+
- Example: With base delay of 1000ms and jitterFactor 0.1, actual delay will be 900-1100ms
|
|
169
|
+
|
|
97
170
|
### Default Fallback Models
|
|
98
171
|
|
|
99
172
|
If no configuration is provided, the following models are used:
|
|
@@ -136,6 +209,7 @@ When OpenCode uses subagents (e.g., for complex tasks requiring specialized agen
|
|
|
136
209
|
The plugin includes a metrics collection feature that tracks:
|
|
137
210
|
- Rate limit events per provider/model
|
|
138
211
|
- Fallback statistics (total, successful, failed, average duration)
|
|
212
|
+
- **Retry statistics** (total attempts, successes, failures, average delay)
|
|
139
213
|
- Model performance (requests, successes, failures, response time)
|
|
140
214
|
|
|
141
215
|
### Metrics Configuration
|
|
@@ -191,6 +265,23 @@ Fallbacks:
|
|
|
191
265
|
Failed: 1
|
|
192
266
|
Avg Duration: 1.25s
|
|
193
267
|
|
|
268
|
+
Retries:
|
|
269
|
+
----------------------------------------
|
|
270
|
+
Total: 12
|
|
271
|
+
Successful: 8
|
|
272
|
+
Failed: 4
|
|
273
|
+
Avg Delay: 2.5s
|
|
274
|
+
|
|
275
|
+
By Model:
|
|
276
|
+
anthropic/claude-3-5-sonnet-20250514:
|
|
277
|
+
Attempts: 5
|
|
278
|
+
Successes: 3
|
|
279
|
+
Success Rate: 60.0%
|
|
280
|
+
google/gemini-2.5-pro:
|
|
281
|
+
Attempts: 7
|
|
282
|
+
Successes: 5
|
|
283
|
+
Success Rate: 71.4%
|
|
284
|
+
|
|
194
285
|
Model Performance:
|
|
195
286
|
----------------------------------------
|
|
196
287
|
google/gemini-2.5-pro:
|
|
@@ -225,6 +316,22 @@ Model Performance:
|
|
|
225
316
|
}
|
|
226
317
|
}
|
|
227
318
|
},
|
|
319
|
+
"retries": {
|
|
320
|
+
"total": 12,
|
|
321
|
+
"successful": 8,
|
|
322
|
+
"failed": 4,
|
|
323
|
+
"averageDelay": 2500,
|
|
324
|
+
"byModel": {
|
|
325
|
+
"anthropic/claude-3-5-sonnet-20250514": {
|
|
326
|
+
"attempts": 5,
|
|
327
|
+
"successes": 3
|
|
328
|
+
},
|
|
329
|
+
"google/gemini-2.5-pro": {
|
|
330
|
+
"attempts": 7,
|
|
331
|
+
"successes": 5
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
},
|
|
228
335
|
"modelPerformance": {
|
|
229
336
|
"google/gemini-2.5-pro": {
|
|
230
337
|
"requests": 10,
|
|
@@ -248,6 +355,15 @@ anthropic/claude-3-5-sonnet-20250514,5,1739148000000,1739149740000,3500
|
|
|
248
355
|
total,successful,failed,avg_duration_ms
|
|
249
356
|
3,2,1,1250
|
|
250
357
|
|
|
358
|
+
=== RETRIES_SUMMARY ===
|
|
359
|
+
total,successful,failed,avg_delay_ms
|
|
360
|
+
12,8,4,2500
|
|
361
|
+
|
|
362
|
+
=== RETRIES_BY_MODEL ===
|
|
363
|
+
model,attempts,successes,success_rate
|
|
364
|
+
anthropic/claude-3-5-sonnet-20250514,5,3,60.0
|
|
365
|
+
google/gemini-2.5-pro,7,5,71.4
|
|
366
|
+
|
|
251
367
|
=== MODEL_PERFORMANCE ===
|
|
252
368
|
model,requests,successes,failures,avg_response_time_ms,success_rate
|
|
253
369
|
google/gemini-2.5-pro,10,9,1,850,90.0
|
|
@@ -20,6 +20,7 @@ export declare class FallbackHandler {
|
|
|
20
20
|
private fallbackMessages;
|
|
21
21
|
private metricsManager;
|
|
22
22
|
private subagentTracker;
|
|
23
|
+
private retryManager;
|
|
23
24
|
constructor(config: PluginConfig, client: OpenCodeClient, logger: Logger, metricsManager: MetricsManager, subagentTracker: SubagentTracker);
|
|
24
25
|
/**
|
|
25
26
|
* Check and mark fallback in progress for deduplication
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { SESSION_ENTRY_TTL_MS } from '../types/index.js';
|
|
5
5
|
import { ModelSelector } from './ModelSelector.js';
|
|
6
6
|
import { extractMessageParts, convertPartsToSDKFormat, safeShowToast, getStateKey, getModelKey, DEDUP_WINDOW_MS, STATE_TIMEOUT_MS } from '../utils/helpers.js';
|
|
7
|
+
import { RetryManager } from '../retry/RetryManager.js';
|
|
7
8
|
/**
|
|
8
9
|
* Fallback Handler class for orchestrating the fallback retry flow
|
|
9
10
|
*/
|
|
@@ -21,6 +22,8 @@ export class FallbackHandler {
|
|
|
21
22
|
metricsManager;
|
|
22
23
|
// Subagent tracker reference
|
|
23
24
|
subagentTracker;
|
|
25
|
+
// Retry manager reference
|
|
26
|
+
retryManager;
|
|
24
27
|
constructor(config, client, logger, metricsManager, subagentTracker) {
|
|
25
28
|
this.config = config;
|
|
26
29
|
this.client = client;
|
|
@@ -33,6 +36,8 @@ export class FallbackHandler {
|
|
|
33
36
|
this.retryState = new Map();
|
|
34
37
|
this.fallbackInProgress = new Map();
|
|
35
38
|
this.fallbackMessages = new Map();
|
|
39
|
+
// Initialize retry manager
|
|
40
|
+
this.retryManager = new RetryManager(config.retryPolicy || {}, logger);
|
|
36
41
|
}
|
|
37
42
|
/**
|
|
38
43
|
* Check and mark fallback in progress for deduplication
|
|
@@ -188,6 +193,32 @@ export class FallbackHandler {
|
|
|
188
193
|
const state = this.getOrCreateRetryState(sessionID, lastUserMessage.info.id);
|
|
189
194
|
const stateKey = getStateKey(sessionID, lastUserMessage.info.id);
|
|
190
195
|
const fallbackKey = getStateKey(dedupSessionID, lastUserMessage.info.id);
|
|
196
|
+
// Check if retry should be attempted (using retry manager)
|
|
197
|
+
if (!this.retryManager.canRetry(dedupSessionID, lastUserMessage.info.id)) {
|
|
198
|
+
await safeShowToast(this.client, {
|
|
199
|
+
body: {
|
|
200
|
+
title: "Fallback Exhausted",
|
|
201
|
+
message: "All retry attempts failed",
|
|
202
|
+
variant: "error",
|
|
203
|
+
duration: 5000,
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
this.logger.warn('Retry exhausted', { sessionID: dedupSessionID, messageID: lastUserMessage.info.id });
|
|
207
|
+
this.retryState.delete(stateKey);
|
|
208
|
+
this.fallbackInProgress.delete(fallbackKey);
|
|
209
|
+
// Record retry failure metric
|
|
210
|
+
if (this.metricsManager) {
|
|
211
|
+
this.metricsManager.recordRetryFailure();
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// Get delay for next retry
|
|
216
|
+
const delay = this.retryManager.getRetryDelay(dedupSessionID, lastUserMessage.info.id);
|
|
217
|
+
// Apply delay if configured
|
|
218
|
+
if (delay > 0) {
|
|
219
|
+
this.logger.debug(`Applying retry delay`, { delayMs: delay });
|
|
220
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
221
|
+
}
|
|
191
222
|
// Select the next fallback model
|
|
192
223
|
const nextModel = await this.modelSelector.selectFallbackModel(currentProviderID, currentModelID, state.attemptedModels);
|
|
193
224
|
// Show error if no model is available
|
|
@@ -208,6 +239,12 @@ export class FallbackHandler {
|
|
|
208
239
|
}
|
|
209
240
|
state.attemptedModels.add(getModelKey(nextModel.providerID, nextModel.modelID));
|
|
210
241
|
state.lastAttemptTime = Date.now();
|
|
242
|
+
// Record retry attempt
|
|
243
|
+
this.retryManager.recordRetry(dedupSessionID, lastUserMessage.info.id, nextModel.modelID, delay);
|
|
244
|
+
// Record retry metric
|
|
245
|
+
if (this.metricsManager) {
|
|
246
|
+
this.metricsManager.recordRetryAttempt(nextModel.modelID, delay);
|
|
247
|
+
}
|
|
211
248
|
// Extract message parts
|
|
212
249
|
const parts = extractMessageParts(lastUserMessage);
|
|
213
250
|
if (parts.length === 0) {
|
|
@@ -217,7 +254,7 @@ export class FallbackHandler {
|
|
|
217
254
|
await safeShowToast(this.client, {
|
|
218
255
|
body: {
|
|
219
256
|
title: "Retrying",
|
|
220
|
-
message: `Using ${nextModel.providerID}/${nextModel.modelID}`,
|
|
257
|
+
message: `Using ${nextModel.providerID}/${nextModel.modelID}${delay > 0 ? ` (after ${delay}ms)` : ''}`,
|
|
221
258
|
variant: "info",
|
|
222
259
|
duration: 3000,
|
|
223
260
|
},
|
|
@@ -234,6 +271,11 @@ export class FallbackHandler {
|
|
|
234
271
|
});
|
|
235
272
|
// Retry with the selected model
|
|
236
273
|
await this.retryWithModel(dedupSessionID, nextModel, parts, hierarchy);
|
|
274
|
+
// Record retry success
|
|
275
|
+
this.retryManager.recordSuccess(dedupSessionID, nextModel.modelID);
|
|
276
|
+
if (this.metricsManager) {
|
|
277
|
+
this.metricsManager.recordRetrySuccess(nextModel.modelID);
|
|
278
|
+
}
|
|
237
279
|
// Clean up state
|
|
238
280
|
this.retryState.delete(stateKey);
|
|
239
281
|
}
|
|
@@ -245,6 +287,10 @@ export class FallbackHandler {
|
|
|
245
287
|
error: errorMessage,
|
|
246
288
|
name: errorName,
|
|
247
289
|
});
|
|
290
|
+
// Record retry failure on error
|
|
291
|
+
const rootSessionID = this.subagentTracker.getRootSession(sessionID);
|
|
292
|
+
const targetSessionID = rootSessionID || sessionID;
|
|
293
|
+
this.retryManager.recordFailure(targetSessionID);
|
|
248
294
|
}
|
|
249
295
|
}
|
|
250
296
|
/**
|
|
@@ -327,6 +373,7 @@ export class FallbackHandler {
|
|
|
327
373
|
}
|
|
328
374
|
}
|
|
329
375
|
this.modelSelector.cleanupStaleEntries();
|
|
376
|
+
this.retryManager.cleanupStaleEntries(SESSION_ENTRY_TTL_MS);
|
|
330
377
|
}
|
|
331
378
|
/**
|
|
332
379
|
* Clean up all resources
|
|
@@ -337,5 +384,6 @@ export class FallbackHandler {
|
|
|
337
384
|
this.retryState.clear();
|
|
338
385
|
this.fallbackInProgress.clear();
|
|
339
386
|
this.fallbackMessages.clear();
|
|
387
|
+
this.retryManager.destroy();
|
|
340
388
|
}
|
|
341
389
|
}
|
|
@@ -49,6 +49,18 @@ export declare class MetricsManager {
|
|
|
49
49
|
* Record a failed model request
|
|
50
50
|
*/
|
|
51
51
|
recordModelFailure(providerID: string, modelID: string): void;
|
|
52
|
+
/**
|
|
53
|
+
* Record a retry attempt
|
|
54
|
+
*/
|
|
55
|
+
recordRetryAttempt(modelID: string, delay: number): void;
|
|
56
|
+
/**
|
|
57
|
+
* Record a successful retry
|
|
58
|
+
*/
|
|
59
|
+
recordRetrySuccess(modelID: string): void;
|
|
60
|
+
/**
|
|
61
|
+
* Record a failed retry
|
|
62
|
+
*/
|
|
63
|
+
recordRetryFailure(): void;
|
|
52
64
|
/**
|
|
53
65
|
* Get a copy of the current metrics
|
|
54
66
|
*/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Metrics Manager - Handles metrics collection, aggregation, and reporting
|
|
3
3
|
*/
|
|
4
|
-
import { RESET_INTERVAL_MS } from '
|
|
4
|
+
import { RESET_INTERVAL_MS } from '../types/index.js';
|
|
5
5
|
import { getModelKey } from '../utils/helpers.js';
|
|
6
6
|
/**
|
|
7
7
|
* Metrics Manager class for collecting and reporting metrics
|
|
@@ -23,6 +23,13 @@ export class MetricsManager {
|
|
|
23
23
|
averageDuration: 0,
|
|
24
24
|
byTargetModel: new Map(),
|
|
25
25
|
},
|
|
26
|
+
retries: {
|
|
27
|
+
total: 0,
|
|
28
|
+
successful: 0,
|
|
29
|
+
failed: 0,
|
|
30
|
+
averageDelay: 0,
|
|
31
|
+
byModel: new Map(),
|
|
32
|
+
},
|
|
26
33
|
modelPerformance: new Map(),
|
|
27
34
|
startedAt: Date.now(),
|
|
28
35
|
generatedAt: Date.now(),
|
|
@@ -56,6 +63,13 @@ export class MetricsManager {
|
|
|
56
63
|
averageDuration: 0,
|
|
57
64
|
byTargetModel: new Map(),
|
|
58
65
|
},
|
|
66
|
+
retries: {
|
|
67
|
+
total: 0,
|
|
68
|
+
successful: 0,
|
|
69
|
+
failed: 0,
|
|
70
|
+
averageDelay: 0,
|
|
71
|
+
byModel: new Map(),
|
|
72
|
+
},
|
|
59
73
|
modelPerformance: new Map(),
|
|
60
74
|
startedAt: Date.now(),
|
|
61
75
|
generatedAt: Date.now(),
|
|
@@ -177,6 +191,44 @@ export class MetricsManager {
|
|
|
177
191
|
existing.failures++;
|
|
178
192
|
this.metrics.modelPerformance.set(key, existing);
|
|
179
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Record a retry attempt
|
|
196
|
+
*/
|
|
197
|
+
recordRetryAttempt(modelID, delay) {
|
|
198
|
+
if (!this.config.enabled)
|
|
199
|
+
return;
|
|
200
|
+
this.metrics.retries.total++;
|
|
201
|
+
// Update average delay
|
|
202
|
+
const totalDelay = this.metrics.retries.averageDelay * (this.metrics.retries.total - 1);
|
|
203
|
+
this.metrics.retries.averageDelay = (totalDelay + delay) / this.metrics.retries.total;
|
|
204
|
+
// Update model-specific stats
|
|
205
|
+
let modelStats = this.metrics.retries.byModel.get(modelID);
|
|
206
|
+
if (!modelStats) {
|
|
207
|
+
modelStats = { attempts: 0, successes: 0 };
|
|
208
|
+
this.metrics.retries.byModel.set(modelID, modelStats);
|
|
209
|
+
}
|
|
210
|
+
modelStats.attempts++;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Record a successful retry
|
|
214
|
+
*/
|
|
215
|
+
recordRetrySuccess(modelID) {
|
|
216
|
+
if (!this.config.enabled)
|
|
217
|
+
return;
|
|
218
|
+
this.metrics.retries.successful++;
|
|
219
|
+
const modelStats = this.metrics.retries.byModel.get(modelID);
|
|
220
|
+
if (modelStats) {
|
|
221
|
+
modelStats.successes++;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Record a failed retry
|
|
226
|
+
*/
|
|
227
|
+
recordRetryFailure() {
|
|
228
|
+
if (!this.config.enabled)
|
|
229
|
+
return;
|
|
230
|
+
this.metrics.retries.failed++;
|
|
231
|
+
}
|
|
180
232
|
/**
|
|
181
233
|
* Get a copy of the current metrics
|
|
182
234
|
*/
|
|
@@ -209,6 +261,10 @@ export class MetricsManager {
|
|
|
209
261
|
...metrics.fallbacks,
|
|
210
262
|
byTargetModel: Object.fromEntries(Array.from(metrics.fallbacks.byTargetModel.entries()).map(([k, v]) => [k, v])),
|
|
211
263
|
},
|
|
264
|
+
retries: {
|
|
265
|
+
...metrics.retries,
|
|
266
|
+
byModel: Object.fromEntries(Array.from(metrics.retries.byModel.entries()).map(([k, v]) => [k, v])),
|
|
267
|
+
},
|
|
212
268
|
modelPerformance: Object.fromEntries(Array.from(metrics.modelPerformance.entries()).map(([k, v]) => [k, v])),
|
|
213
269
|
startedAt: metrics.startedAt,
|
|
214
270
|
generatedAt: metrics.generatedAt,
|
|
@@ -263,6 +319,29 @@ export class MetricsManager {
|
|
|
263
319
|
}
|
|
264
320
|
}
|
|
265
321
|
lines.push("");
|
|
322
|
+
// Retries
|
|
323
|
+
lines.push("Retries:");
|
|
324
|
+
lines.push("-".repeat(40));
|
|
325
|
+
lines.push(` Total: ${metrics.retries.total}`);
|
|
326
|
+
lines.push(` Successful: ${metrics.retries.successful}`);
|
|
327
|
+
lines.push(` Failed: ${metrics.retries.failed}`);
|
|
328
|
+
if (metrics.retries.averageDelay > 0) {
|
|
329
|
+
lines.push(` Avg Delay: ${(metrics.retries.averageDelay / 1000).toFixed(2)}s`);
|
|
330
|
+
}
|
|
331
|
+
if (metrics.retries.byModel.size > 0) {
|
|
332
|
+
lines.push("");
|
|
333
|
+
lines.push(" By Model:");
|
|
334
|
+
for (const [model, data] of metrics.retries.byModel.entries()) {
|
|
335
|
+
lines.push(` ${model}:`);
|
|
336
|
+
lines.push(` Attempts: ${data.attempts}`);
|
|
337
|
+
lines.push(` Successes: ${data.successes}`);
|
|
338
|
+
if (data.attempts > 0) {
|
|
339
|
+
const successRate = ((data.successes / data.attempts) * 100).toFixed(1);
|
|
340
|
+
lines.push(` Success Rate: ${successRate}%`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
lines.push("");
|
|
266
345
|
// Model Performance
|
|
267
346
|
lines.push("Model Performance:");
|
|
268
347
|
lines.push("-".repeat(40));
|
|
@@ -326,6 +405,29 @@ export class MetricsManager {
|
|
|
326
405
|
].join(","));
|
|
327
406
|
}
|
|
328
407
|
lines.push("");
|
|
408
|
+
// Retries Summary CSV
|
|
409
|
+
lines.push("=== RETRIES_SUMMARY ===");
|
|
410
|
+
lines.push(`total,successful,failed,avg_delay_ms`);
|
|
411
|
+
lines.push([
|
|
412
|
+
metrics.retries.total,
|
|
413
|
+
metrics.retries.successful,
|
|
414
|
+
metrics.retries.failed,
|
|
415
|
+
metrics.retries.averageDelay || 0,
|
|
416
|
+
].join(","));
|
|
417
|
+
lines.push("");
|
|
418
|
+
// Retries by Model CSV
|
|
419
|
+
lines.push("=== RETRIES_BY_MODEL ===");
|
|
420
|
+
lines.push("model,attempts,successes,success_rate");
|
|
421
|
+
for (const [model, data] of metrics.retries.byModel.entries()) {
|
|
422
|
+
const successRate = data.attempts > 0 ? ((data.successes / data.attempts) * 100).toFixed(1) : "0";
|
|
423
|
+
lines.push([
|
|
424
|
+
model,
|
|
425
|
+
data.attempts,
|
|
426
|
+
data.successes,
|
|
427
|
+
successRate,
|
|
428
|
+
].join(","));
|
|
429
|
+
}
|
|
430
|
+
lines.push("");
|
|
329
431
|
// Model Performance CSV
|
|
330
432
|
lines.push("=== MODEL_PERFORMANCE ===");
|
|
331
433
|
lines.push("model,requests,successes,failures,avg_response_time_ms,success_rate");
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry Manager - Manages retry attempts with exponential backoff
|
|
3
|
+
*/
|
|
4
|
+
import type { Logger } from '../../logger.js';
|
|
5
|
+
import type { RetryPolicy, RetryAttempt, RetryStats } from '../types/index.js';
|
|
6
|
+
/**
|
|
7
|
+
* Retry Manager class for managing retry attempts with configurable backoff strategies
|
|
8
|
+
*/
|
|
9
|
+
export declare class RetryManager {
|
|
10
|
+
private retryAttempts;
|
|
11
|
+
private config;
|
|
12
|
+
private logger;
|
|
13
|
+
private retryStats;
|
|
14
|
+
constructor(config: Partial<RetryPolicy> | undefined, logger: Logger);
|
|
15
|
+
/**
|
|
16
|
+
* Validate retry policy configuration
|
|
17
|
+
*/
|
|
18
|
+
private validateConfig;
|
|
19
|
+
/**
|
|
20
|
+
* Generate a unique key for session and message combination
|
|
21
|
+
*/
|
|
22
|
+
private getKey;
|
|
23
|
+
/**
|
|
24
|
+
* Check if retry should be attempted
|
|
25
|
+
*/
|
|
26
|
+
canRetry(sessionID: string, messageID: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Get delay for next retry attempt based on strategy
|
|
29
|
+
*/
|
|
30
|
+
getRetryDelay(sessionID: string, messageID: string): number;
|
|
31
|
+
/**
|
|
32
|
+
* Calculate exponential backoff delay
|
|
33
|
+
*/
|
|
34
|
+
private calculateExponentialDelay;
|
|
35
|
+
/**
|
|
36
|
+
* Calculate linear backoff delay
|
|
37
|
+
*/
|
|
38
|
+
private calculateLinearDelay;
|
|
39
|
+
/**
|
|
40
|
+
* Apply jitter to delay
|
|
41
|
+
*/
|
|
42
|
+
private applyJitter;
|
|
43
|
+
/**
|
|
44
|
+
* Record a retry attempt
|
|
45
|
+
*/
|
|
46
|
+
recordRetry(sessionID: string, messageID: string, modelID: string, delay: number): void;
|
|
47
|
+
/**
|
|
48
|
+
* Update retry statistics
|
|
49
|
+
*/
|
|
50
|
+
private updateStats;
|
|
51
|
+
/**
|
|
52
|
+
* Record a successful retry
|
|
53
|
+
*/
|
|
54
|
+
recordSuccess(sessionID: string, modelID: string): void;
|
|
55
|
+
/**
|
|
56
|
+
* Record a failed retry
|
|
57
|
+
*/
|
|
58
|
+
recordFailure(sessionID: string): void;
|
|
59
|
+
/**
|
|
60
|
+
* Get retry statistics for a session
|
|
61
|
+
*/
|
|
62
|
+
getRetryStats(sessionID: string): RetryStats | null;
|
|
63
|
+
/**
|
|
64
|
+
* Get retry attempt information
|
|
65
|
+
*/
|
|
66
|
+
getRetryAttempt(sessionID: string, messageID: string): RetryAttempt | null;
|
|
67
|
+
/**
|
|
68
|
+
* Reset retry state for a specific session/message
|
|
69
|
+
*/
|
|
70
|
+
reset(sessionID: string, messageID?: string): void;
|
|
71
|
+
/**
|
|
72
|
+
* Clean up stale retry entries
|
|
73
|
+
*/
|
|
74
|
+
cleanupStaleEntries(maxAge?: number): void;
|
|
75
|
+
/**
|
|
76
|
+
* Get current retry configuration
|
|
77
|
+
*/
|
|
78
|
+
getConfig(): RetryPolicy;
|
|
79
|
+
/**
|
|
80
|
+
* Update retry configuration
|
|
81
|
+
*/
|
|
82
|
+
updateConfig(config: Partial<RetryPolicy>): void;
|
|
83
|
+
/**
|
|
84
|
+
* Clean up all resources
|
|
85
|
+
*/
|
|
86
|
+
destroy(): void;
|
|
87
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry Manager - Manages retry attempts with exponential backoff
|
|
3
|
+
*/
|
|
4
|
+
import { DEFAULT_RETRY_POLICY, VALID_RETRY_STRATEGIES } from '../types/index.js';
|
|
5
|
+
/**
|
|
6
|
+
* Retry Manager class for managing retry attempts with configurable backoff strategies
|
|
7
|
+
*/
|
|
8
|
+
export class RetryManager {
|
|
9
|
+
retryAttempts;
|
|
10
|
+
config;
|
|
11
|
+
logger;
|
|
12
|
+
retryStats;
|
|
13
|
+
constructor(config = {}, logger) {
|
|
14
|
+
this.config = { ...DEFAULT_RETRY_POLICY, ...config };
|
|
15
|
+
this.logger = logger;
|
|
16
|
+
this.retryAttempts = new Map();
|
|
17
|
+
this.retryStats = new Map();
|
|
18
|
+
// Validate config
|
|
19
|
+
this.validateConfig();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Validate retry policy configuration
|
|
23
|
+
*/
|
|
24
|
+
validateConfig() {
|
|
25
|
+
if (!VALID_RETRY_STRATEGIES.includes(this.config.strategy)) {
|
|
26
|
+
this.logger.warn('Invalid strategy, using default', { strategy: this.config.strategy });
|
|
27
|
+
this.config.strategy = DEFAULT_RETRY_POLICY.strategy;
|
|
28
|
+
}
|
|
29
|
+
if (this.config.maxRetries < 0) {
|
|
30
|
+
this.logger.warn('Invalid maxRetries, using default', { maxRetries: this.config.maxRetries });
|
|
31
|
+
this.config.maxRetries = DEFAULT_RETRY_POLICY.maxRetries;
|
|
32
|
+
}
|
|
33
|
+
if (this.config.baseDelayMs < 0) {
|
|
34
|
+
this.logger.warn('Invalid baseDelayMs, using default', { baseDelayMs: this.config.baseDelayMs });
|
|
35
|
+
this.config.baseDelayMs = DEFAULT_RETRY_POLICY.baseDelayMs;
|
|
36
|
+
}
|
|
37
|
+
if (this.config.maxDelayMs < 0) {
|
|
38
|
+
this.logger.warn('Invalid maxDelayMs, using default', { maxDelayMs: this.config.maxDelayMs });
|
|
39
|
+
this.config.maxDelayMs = DEFAULT_RETRY_POLICY.maxDelayMs;
|
|
40
|
+
}
|
|
41
|
+
if (this.config.baseDelayMs > this.config.maxDelayMs) {
|
|
42
|
+
this.logger.warn('baseDelayMs > maxDelayMs, swapping values');
|
|
43
|
+
[this.config.baseDelayMs, this.config.maxDelayMs] = [this.config.maxDelayMs, this.config.baseDelayMs];
|
|
44
|
+
}
|
|
45
|
+
if (this.config.jitterFactor < 0 || this.config.jitterFactor > 1) {
|
|
46
|
+
this.logger.warn('Invalid jitterFactor, using default', { jitterFactor: this.config.jitterFactor });
|
|
47
|
+
this.config.jitterFactor = DEFAULT_RETRY_POLICY.jitterFactor;
|
|
48
|
+
}
|
|
49
|
+
if (this.config.timeoutMs !== undefined && this.config.timeoutMs < 0) {
|
|
50
|
+
this.logger.warn('Invalid timeoutMs, ignoring', { timeoutMs: this.config.timeoutMs });
|
|
51
|
+
this.config.timeoutMs = undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Generate a unique key for session and message combination
|
|
56
|
+
*/
|
|
57
|
+
getKey(sessionID, messageID) {
|
|
58
|
+
return `${sessionID}:${messageID}`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Check if retry should be attempted
|
|
62
|
+
*/
|
|
63
|
+
canRetry(sessionID, messageID) {
|
|
64
|
+
const key = this.getKey(sessionID, messageID);
|
|
65
|
+
const attempt = this.retryAttempts.get(key);
|
|
66
|
+
if (!attempt) {
|
|
67
|
+
return this.config.maxRetries > 0;
|
|
68
|
+
}
|
|
69
|
+
// Check timeout
|
|
70
|
+
if (this.config.timeoutMs) {
|
|
71
|
+
const elapsed = Date.now() - attempt.startTime;
|
|
72
|
+
if (elapsed > this.config.timeoutMs) {
|
|
73
|
+
this.logger.debug('Retry timeout exceeded', { key, elapsed, timeout: this.config.timeoutMs });
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return attempt.attemptCount < this.config.maxRetries;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Get delay for next retry attempt based on strategy
|
|
81
|
+
*/
|
|
82
|
+
getRetryDelay(sessionID, messageID) {
|
|
83
|
+
const key = this.getKey(sessionID, messageID);
|
|
84
|
+
const attempt = this.retryAttempts.get(key) || {
|
|
85
|
+
attemptCount: 0,
|
|
86
|
+
startTime: Date.now(),
|
|
87
|
+
delays: [],
|
|
88
|
+
lastAttemptTime: 0,
|
|
89
|
+
modelIDs: [],
|
|
90
|
+
};
|
|
91
|
+
let delay;
|
|
92
|
+
switch (this.config.strategy) {
|
|
93
|
+
case "exponential":
|
|
94
|
+
delay = this.calculateExponentialDelay(attempt.attemptCount);
|
|
95
|
+
break;
|
|
96
|
+
case "linear":
|
|
97
|
+
delay = this.calculateLinearDelay(attempt.attemptCount);
|
|
98
|
+
break;
|
|
99
|
+
case "immediate":
|
|
100
|
+
default:
|
|
101
|
+
delay = 0;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
// Apply jitter if enabled
|
|
105
|
+
if (this.config.jitterEnabled && delay > 0) {
|
|
106
|
+
delay = this.applyJitter(delay);
|
|
107
|
+
}
|
|
108
|
+
return delay;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Calculate exponential backoff delay
|
|
112
|
+
*/
|
|
113
|
+
calculateExponentialDelay(attemptCount) {
|
|
114
|
+
const exponentialDelay = this.config.baseDelayMs * Math.pow(2, attemptCount);
|
|
115
|
+
return Math.min(exponentialDelay, this.config.maxDelayMs);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Calculate linear backoff delay
|
|
119
|
+
*/
|
|
120
|
+
calculateLinearDelay(attemptCount) {
|
|
121
|
+
const linearDelay = this.config.baseDelayMs * (attemptCount + 1);
|
|
122
|
+
return Math.min(linearDelay, this.config.maxDelayMs);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Apply jitter to delay
|
|
126
|
+
*/
|
|
127
|
+
applyJitter(delay) {
|
|
128
|
+
const jitterAmount = delay * this.config.jitterFactor;
|
|
129
|
+
const randomJitter = (Math.random() * 2 - 1) * jitterAmount; // -jitter to +jitter
|
|
130
|
+
return Math.max(0, delay + randomJitter);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Record a retry attempt
|
|
134
|
+
*/
|
|
135
|
+
recordRetry(sessionID, messageID, modelID, delay) {
|
|
136
|
+
const key = this.getKey(sessionID, messageID);
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
let attempt = this.retryAttempts.get(key);
|
|
139
|
+
if (!attempt) {
|
|
140
|
+
attempt = {
|
|
141
|
+
attemptCount: 0,
|
|
142
|
+
startTime: now,
|
|
143
|
+
delays: [],
|
|
144
|
+
lastAttemptTime: 0,
|
|
145
|
+
modelIDs: [],
|
|
146
|
+
};
|
|
147
|
+
this.retryAttempts.set(key, attempt);
|
|
148
|
+
}
|
|
149
|
+
attempt.attemptCount++;
|
|
150
|
+
attempt.delays.push(delay);
|
|
151
|
+
attempt.lastAttemptTime = now;
|
|
152
|
+
attempt.modelIDs.push(modelID);
|
|
153
|
+
// Update stats
|
|
154
|
+
this.updateStats(sessionID, modelID, delay, now);
|
|
155
|
+
this.logger.debug('Retry attempt recorded', {
|
|
156
|
+
key,
|
|
157
|
+
attemptCount: attempt.attemptCount,
|
|
158
|
+
delay,
|
|
159
|
+
modelID,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Update retry statistics
|
|
164
|
+
*/
|
|
165
|
+
updateStats(sessionID, modelID, delay, now) {
|
|
166
|
+
let stats = this.retryStats.get(sessionID);
|
|
167
|
+
if (!stats) {
|
|
168
|
+
stats = {
|
|
169
|
+
totalRetries: 0,
|
|
170
|
+
successful: 0,
|
|
171
|
+
failed: 0,
|
|
172
|
+
averageDelay: 0,
|
|
173
|
+
byModel: new Map(),
|
|
174
|
+
startTime: now,
|
|
175
|
+
lastAttemptTime: now,
|
|
176
|
+
};
|
|
177
|
+
this.retryStats.set(sessionID, stats);
|
|
178
|
+
}
|
|
179
|
+
stats.totalRetries++;
|
|
180
|
+
stats.lastAttemptTime = now;
|
|
181
|
+
// Update average delay
|
|
182
|
+
const totalDelay = stats.averageDelay * (stats.totalRetries - 1);
|
|
183
|
+
stats.averageDelay = (totalDelay + delay) / stats.totalRetries;
|
|
184
|
+
// Update model-specific stats
|
|
185
|
+
let modelStats = stats.byModel.get(modelID);
|
|
186
|
+
if (!modelStats) {
|
|
187
|
+
modelStats = { attempts: 0, successes: 0 };
|
|
188
|
+
stats.byModel.set(modelID, modelStats);
|
|
189
|
+
}
|
|
190
|
+
modelStats.attempts++;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Record a successful retry
|
|
194
|
+
*/
|
|
195
|
+
recordSuccess(sessionID, modelID) {
|
|
196
|
+
const stats = this.retryStats.get(sessionID);
|
|
197
|
+
if (stats) {
|
|
198
|
+
stats.successful++;
|
|
199
|
+
const modelStats = stats.byModel.get(modelID);
|
|
200
|
+
if (modelStats) {
|
|
201
|
+
modelStats.successes++;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Record a failed retry
|
|
207
|
+
*/
|
|
208
|
+
recordFailure(sessionID) {
|
|
209
|
+
const stats = this.retryStats.get(sessionID);
|
|
210
|
+
if (stats) {
|
|
211
|
+
stats.failed++;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Get retry statistics for a session
|
|
216
|
+
*/
|
|
217
|
+
getRetryStats(sessionID) {
|
|
218
|
+
return this.retryStats.get(sessionID) || null;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Get retry attempt information
|
|
222
|
+
*/
|
|
223
|
+
getRetryAttempt(sessionID, messageID) {
|
|
224
|
+
const key = this.getKey(sessionID, messageID);
|
|
225
|
+
return this.retryAttempts.get(key) || null;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Reset retry state for a specific session/message
|
|
229
|
+
*/
|
|
230
|
+
reset(sessionID, messageID) {
|
|
231
|
+
if (messageID) {
|
|
232
|
+
const key = this.getKey(sessionID, messageID);
|
|
233
|
+
this.retryAttempts.delete(key);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
// Reset all entries for this session
|
|
237
|
+
for (const [key] of this.retryAttempts.entries()) {
|
|
238
|
+
if (key.startsWith(sessionID + ':')) {
|
|
239
|
+
this.retryAttempts.delete(key);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
this.retryStats.delete(sessionID);
|
|
243
|
+
}
|
|
244
|
+
this.logger.debug('Retry state reset', { sessionID, messageID });
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Clean up stale retry entries
|
|
248
|
+
*/
|
|
249
|
+
cleanupStaleEntries(maxAge = 3600000) {
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
let cleanedCount = 0;
|
|
252
|
+
for (const [key, attempt] of this.retryAttempts.entries()) {
|
|
253
|
+
if (now - attempt.lastAttemptTime > maxAge) {
|
|
254
|
+
this.retryAttempts.delete(key);
|
|
255
|
+
cleanedCount++;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for (const [sessionID, stats] of this.retryStats.entries()) {
|
|
259
|
+
if (now - stats.lastAttemptTime > maxAge) {
|
|
260
|
+
this.retryStats.delete(sessionID);
|
|
261
|
+
cleanedCount++;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (cleanedCount > 0) {
|
|
265
|
+
this.logger.debug('Cleaned up stale retry entries', { count: cleanedCount });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Get current retry configuration
|
|
270
|
+
*/
|
|
271
|
+
getConfig() {
|
|
272
|
+
return { ...this.config };
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Update retry configuration
|
|
276
|
+
*/
|
|
277
|
+
updateConfig(config) {
|
|
278
|
+
this.config = { ...this.config, ...config };
|
|
279
|
+
this.validateConfig();
|
|
280
|
+
this.logger.debug('Retry configuration updated', { config: this.config });
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Clean up all resources
|
|
284
|
+
*/
|
|
285
|
+
destroy() {
|
|
286
|
+
this.retryAttempts.clear();
|
|
287
|
+
this.retryStats.clear();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -17,6 +17,22 @@ export interface FallbackModel {
|
|
|
17
17
|
* - "retry-last": Try the last model once, then reset to first on next prompt
|
|
18
18
|
*/
|
|
19
19
|
export type FallbackMode = "cycle" | "stop" | "retry-last";
|
|
20
|
+
/**
|
|
21
|
+
* Retry strategy type
|
|
22
|
+
*/
|
|
23
|
+
export type RetryStrategy = "immediate" | "exponential" | "linear" | "custom";
|
|
24
|
+
/**
|
|
25
|
+
* Retry policy configuration
|
|
26
|
+
*/
|
|
27
|
+
export interface RetryPolicy {
|
|
28
|
+
maxRetries: number;
|
|
29
|
+
strategy: RetryStrategy;
|
|
30
|
+
baseDelayMs: number;
|
|
31
|
+
maxDelayMs: number;
|
|
32
|
+
jitterEnabled: boolean;
|
|
33
|
+
jitterFactor: number;
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
}
|
|
20
36
|
/**
|
|
21
37
|
* Metrics output configuration
|
|
22
38
|
*/
|
|
@@ -43,6 +59,7 @@ export interface PluginConfig {
|
|
|
43
59
|
fallbackMode: FallbackMode;
|
|
44
60
|
maxSubagentDepth?: number;
|
|
45
61
|
enableSubagentFallback?: boolean;
|
|
62
|
+
retryPolicy?: RetryPolicy;
|
|
46
63
|
log?: LogConfig;
|
|
47
64
|
metrics?: MetricsConfig;
|
|
48
65
|
}
|
|
@@ -50,6 +67,31 @@ export interface PluginConfig {
|
|
|
50
67
|
* Fallback state for tracking progress
|
|
51
68
|
*/
|
|
52
69
|
export type FallbackState = "none" | "in_progress" | "completed";
|
|
70
|
+
/**
|
|
71
|
+
* Retry attempt information
|
|
72
|
+
*/
|
|
73
|
+
export interface RetryAttempt {
|
|
74
|
+
attemptCount: number;
|
|
75
|
+
startTime: number;
|
|
76
|
+
delays: number[];
|
|
77
|
+
lastAttemptTime: number;
|
|
78
|
+
modelIDs: string[];
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Retry statistics for tracking retry behavior
|
|
82
|
+
*/
|
|
83
|
+
export interface RetryStats {
|
|
84
|
+
totalRetries: number;
|
|
85
|
+
successful: number;
|
|
86
|
+
failed: number;
|
|
87
|
+
averageDelay: number;
|
|
88
|
+
byModel: Map<string, {
|
|
89
|
+
attempts: number;
|
|
90
|
+
successes: number;
|
|
91
|
+
}>;
|
|
92
|
+
startTime: number;
|
|
93
|
+
lastAttemptTime: number;
|
|
94
|
+
}
|
|
53
95
|
/**
|
|
54
96
|
* Subagent session information
|
|
55
97
|
*/
|
|
@@ -141,6 +183,19 @@ export interface ModelPerformanceMetrics {
|
|
|
141
183
|
failures: number;
|
|
142
184
|
averageResponseTime?: number;
|
|
143
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Retry metrics
|
|
188
|
+
*/
|
|
189
|
+
export interface RetryMetrics {
|
|
190
|
+
total: number;
|
|
191
|
+
successful: number;
|
|
192
|
+
failed: number;
|
|
193
|
+
averageDelay: number;
|
|
194
|
+
byModel: Map<string, {
|
|
195
|
+
attempts: number;
|
|
196
|
+
successes: number;
|
|
197
|
+
}>;
|
|
198
|
+
}
|
|
144
199
|
/**
|
|
145
200
|
* Complete metrics data
|
|
146
201
|
*/
|
|
@@ -153,6 +208,7 @@ export interface MetricsData {
|
|
|
153
208
|
averageDuration: number;
|
|
154
209
|
byTargetModel: Map<string, FallbackTargetMetrics>;
|
|
155
210
|
};
|
|
211
|
+
retries: RetryMetrics;
|
|
156
212
|
modelPerformance: Map<string, ModelPerformanceMetrics>;
|
|
157
213
|
startedAt: number;
|
|
158
214
|
generatedAt: number;
|
|
@@ -180,6 +236,29 @@ export type MessagePart = TextPart | FilePart;
|
|
|
180
236
|
* SDK-compatible message part input
|
|
181
237
|
*/
|
|
182
238
|
export type SDKMessagePartInput = TextPartInput | FilePartInput;
|
|
239
|
+
/**
|
|
240
|
+
* Toast variant type
|
|
241
|
+
*/
|
|
242
|
+
export type ToastVariant = "info" | "success" | "warning" | "error";
|
|
243
|
+
/**
|
|
244
|
+
* Toast body content
|
|
245
|
+
*/
|
|
246
|
+
export interface ToastBody {
|
|
247
|
+
title: string;
|
|
248
|
+
message: string;
|
|
249
|
+
variant: ToastVariant;
|
|
250
|
+
duration?: number;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Toast message structure
|
|
254
|
+
*/
|
|
255
|
+
export interface ToastMessage {
|
|
256
|
+
body?: ToastBody;
|
|
257
|
+
title?: string;
|
|
258
|
+
message?: string;
|
|
259
|
+
variant?: ToastVariant;
|
|
260
|
+
duration?: number;
|
|
261
|
+
}
|
|
183
262
|
/**
|
|
184
263
|
* OpenCode client interface
|
|
185
264
|
*/
|
|
@@ -217,7 +296,7 @@ export type OpenCodeClient = {
|
|
|
217
296
|
}) => Promise<unknown>;
|
|
218
297
|
};
|
|
219
298
|
tui?: {
|
|
220
|
-
showToast: (toast:
|
|
299
|
+
showToast: (toast: ToastMessage) => Promise<unknown>;
|
|
221
300
|
};
|
|
222
301
|
};
|
|
223
302
|
/**
|
|
@@ -231,10 +310,18 @@ export type PluginContext = {
|
|
|
231
310
|
* Default fallback models
|
|
232
311
|
*/
|
|
233
312
|
export declare const DEFAULT_FALLBACK_MODELS: FallbackModel[];
|
|
313
|
+
/**
|
|
314
|
+
* Default retry policy
|
|
315
|
+
*/
|
|
316
|
+
export declare const DEFAULT_RETRY_POLICY: RetryPolicy;
|
|
234
317
|
/**
|
|
235
318
|
* Valid fallback modes
|
|
236
319
|
*/
|
|
237
320
|
export declare const VALID_FALLBACK_MODES: FallbackMode[];
|
|
321
|
+
/**
|
|
322
|
+
* Valid retry strategies
|
|
323
|
+
*/
|
|
324
|
+
export declare const VALID_RETRY_STRATEGIES: RetryStrategy[];
|
|
238
325
|
/**
|
|
239
326
|
* Valid reset intervals
|
|
240
327
|
*/
|
package/dist/src/types/index.js
CHANGED
|
@@ -12,10 +12,25 @@ export const DEFAULT_FALLBACK_MODELS = [
|
|
|
12
12
|
{ providerID: "google", modelID: "gemini-2.5-pro" },
|
|
13
13
|
{ providerID: "google", modelID: "gemini-2.5-flash" },
|
|
14
14
|
];
|
|
15
|
+
/**
|
|
16
|
+
* Default retry policy
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_RETRY_POLICY = {
|
|
19
|
+
maxRetries: 3,
|
|
20
|
+
strategy: "immediate",
|
|
21
|
+
baseDelayMs: 1000,
|
|
22
|
+
maxDelayMs: 30000,
|
|
23
|
+
jitterEnabled: false,
|
|
24
|
+
jitterFactor: 0.1,
|
|
25
|
+
};
|
|
15
26
|
/**
|
|
16
27
|
* Valid fallback modes
|
|
17
28
|
*/
|
|
18
29
|
export const VALID_FALLBACK_MODES = ["cycle", "stop", "retry-last"];
|
|
30
|
+
/**
|
|
31
|
+
* Valid retry strategies
|
|
32
|
+
*/
|
|
33
|
+
export const VALID_RETRY_STRATEGIES = ["immediate", "exponential", "linear", "custom"];
|
|
19
34
|
/**
|
|
20
35
|
* Valid reset intervals
|
|
21
36
|
*/
|
|
@@ -9,7 +9,7 @@ export declare const DEFAULT_CONFIG: PluginConfig;
|
|
|
9
9
|
/**
|
|
10
10
|
* Validate configuration values
|
|
11
11
|
*/
|
|
12
|
-
export declare function validateConfig(config:
|
|
12
|
+
export declare function validateConfig(config: Partial<PluginConfig>): PluginConfig;
|
|
13
13
|
/**
|
|
14
14
|
* Load and validate config from file paths
|
|
15
15
|
*/
|
package/dist/src/utils/config.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { existsSync, readFileSync } from "fs";
|
|
5
5
|
import { join } from "path";
|
|
6
|
-
import { DEFAULT_FALLBACK_MODELS, VALID_FALLBACK_MODES, VALID_RESET_INTERVALS, } from '../types/index.js';
|
|
6
|
+
import { DEFAULT_FALLBACK_MODELS, VALID_FALLBACK_MODES, VALID_RESET_INTERVALS, DEFAULT_RETRY_POLICY, VALID_RETRY_STRATEGIES, } from '../types/index.js';
|
|
7
7
|
/**
|
|
8
8
|
* Default plugin configuration
|
|
9
9
|
*/
|
|
@@ -12,6 +12,7 @@ export const DEFAULT_CONFIG = {
|
|
|
12
12
|
cooldownMs: 60 * 1000,
|
|
13
13
|
enabled: true,
|
|
14
14
|
fallbackMode: "cycle",
|
|
15
|
+
retryPolicy: DEFAULT_RETRY_POLICY,
|
|
15
16
|
log: {
|
|
16
17
|
level: "warn",
|
|
17
18
|
format: "simple",
|
|
@@ -32,11 +33,17 @@ export const DEFAULT_CONFIG = {
|
|
|
32
33
|
export function validateConfig(config) {
|
|
33
34
|
const mode = config.fallbackMode;
|
|
34
35
|
const resetInterval = config.metrics?.resetInterval;
|
|
36
|
+
const strategy = config.retryPolicy?.strategy;
|
|
35
37
|
return {
|
|
36
38
|
...DEFAULT_CONFIG,
|
|
37
39
|
...config,
|
|
38
40
|
fallbackModels: config.fallbackModels || DEFAULT_CONFIG.fallbackModels,
|
|
39
|
-
fallbackMode: VALID_FALLBACK_MODES.includes(mode) ? mode : DEFAULT_CONFIG.fallbackMode,
|
|
41
|
+
fallbackMode: mode && VALID_FALLBACK_MODES.includes(mode) ? mode : DEFAULT_CONFIG.fallbackMode,
|
|
42
|
+
retryPolicy: config.retryPolicy ? {
|
|
43
|
+
...DEFAULT_CONFIG.retryPolicy,
|
|
44
|
+
...config.retryPolicy,
|
|
45
|
+
strategy: strategy && VALID_RETRY_STRATEGIES.includes(strategy) ? strategy : DEFAULT_CONFIG.retryPolicy.strategy,
|
|
46
|
+
} : DEFAULT_CONFIG.retryPolicy,
|
|
40
47
|
log: config.log ? { ...DEFAULT_CONFIG.log, ...config.log } : DEFAULT_CONFIG.log,
|
|
41
48
|
metrics: config.metrics ? {
|
|
42
49
|
...DEFAULT_CONFIG.metrics,
|
|
@@ -45,7 +52,7 @@ export function validateConfig(config) {
|
|
|
45
52
|
...DEFAULT_CONFIG.metrics.output,
|
|
46
53
|
...config.metrics.output,
|
|
47
54
|
} : DEFAULT_CONFIG.metrics.output,
|
|
48
|
-
resetInterval: VALID_RESET_INTERVALS.includes(resetInterval) ? resetInterval : DEFAULT_CONFIG.metrics.resetInterval,
|
|
55
|
+
resetInterval: resetInterval && VALID_RESET_INTERVALS.includes(resetInterval) ? resetInterval : DEFAULT_CONFIG.metrics.resetInterval,
|
|
49
56
|
} : DEFAULT_CONFIG.metrics,
|
|
50
57
|
};
|
|
51
58
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* General utility functions
|
|
3
3
|
*/
|
|
4
|
-
import type { MessagePart, SDKMessagePartInput } from '../types/index.js';
|
|
4
|
+
import type { MessagePart, SDKMessagePartInput, ToastMessage, OpenCodeClient } from '../types/index.js';
|
|
5
5
|
export declare const DEDUP_WINDOW_MS = 5000;
|
|
6
6
|
export declare const STATE_TIMEOUT_MS = 30000;
|
|
7
7
|
/**
|
|
@@ -23,7 +23,7 @@ export declare function convertPartsToSDKFormat(parts: MessagePart[]): SDKMessag
|
|
|
23
23
|
/**
|
|
24
24
|
* Extract toast message properties with fallback values
|
|
25
25
|
*/
|
|
26
|
-
export declare function getToastMessage(toast:
|
|
26
|
+
export declare function getToastMessage(toast: ToastMessage): {
|
|
27
27
|
title: string;
|
|
28
28
|
message: string;
|
|
29
29
|
variant: string;
|
|
@@ -31,4 +31,4 @@ export declare function getToastMessage(toast: any): {
|
|
|
31
31
|
/**
|
|
32
32
|
* Safely show toast, falling back to console logging if TUI is missing or fails
|
|
33
33
|
*/
|
|
34
|
-
export declare const safeShowToast: (client:
|
|
34
|
+
export declare const safeShowToast: (client: OpenCodeClient, toast: ToastMessage) => Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Metrics-specific types for the MetricsManager
|
|
3
|
-
*/
|
|
4
|
-
/**
|
|
5
|
-
* Reset interval options for metrics
|
|
6
|
-
*/
|
|
7
|
-
export type ResetInterval = "hourly" | "daily" | "weekly";
|
|
8
|
-
/**
|
|
9
|
-
* Reset interval values in milliseconds
|
|
10
|
-
*/
|
|
11
|
-
export declare const RESET_INTERVAL_MS: Record<ResetInterval, number>;
|