@azumag/opencode-rate-limit-fallback 1.21.0 → 1.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/fallback/FallbackHandler.d.ts +73 -0
- package/dist/src/fallback/FallbackHandler.js +341 -0
- package/dist/src/fallback/ModelSelector.d.ts +37 -0
- package/dist/src/fallback/ModelSelector.js +134 -0
- package/dist/src/metrics/MetricsManager.d.ts +81 -0
- package/dist/src/metrics/MetricsManager.js +377 -0
- package/dist/src/metrics/types.d.ts +11 -0
- package/dist/src/metrics/types.js +11 -0
- package/dist/src/session/SubagentTracker.d.ts +36 -0
- package/dist/src/session/SubagentTracker.js +114 -0
- package/dist/src/types/index.d.ts +262 -0
- package/dist/src/types/index.js +46 -0
- package/dist/src/utils/config.d.ts +16 -0
- package/dist/src/utils/config.js +78 -0
- package/dist/src/utils/errorDetection.d.ts +7 -0
- package/dist/src/utils/errorDetection.js +34 -0
- package/dist/src/utils/helpers.d.ts +34 -0
- package/dist/src/utils/helpers.js +95 -0
- package/package.json +3 -5
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback orchestration logic
|
|
3
|
+
*/
|
|
4
|
+
import type { Logger } from '../../logger.js';
|
|
5
|
+
import type { FallbackModel, PluginConfig, OpenCodeClient, MessagePart, SessionHierarchy } from '../types/index.js';
|
|
6
|
+
import { MetricsManager } from '../metrics/MetricsManager.js';
|
|
7
|
+
/**
|
|
8
|
+
* Hierarchy resolver functions
|
|
9
|
+
*/
|
|
10
|
+
export type HierarchyResolver = {
|
|
11
|
+
getRootSession: (sessionID: string) => string | null;
|
|
12
|
+
getHierarchy: (sessionID: string) => SessionHierarchy | null;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Fallback Handler class for orchestrating the fallback retry flow
|
|
16
|
+
*/
|
|
17
|
+
export declare class FallbackHandler {
|
|
18
|
+
private config;
|
|
19
|
+
private client;
|
|
20
|
+
private logger;
|
|
21
|
+
private modelSelector;
|
|
22
|
+
private currentSessionModel;
|
|
23
|
+
private modelRequestStartTimes;
|
|
24
|
+
private retryState;
|
|
25
|
+
private fallbackInProgress;
|
|
26
|
+
private fallbackMessages;
|
|
27
|
+
private metricsManager;
|
|
28
|
+
private hierarchyResolver;
|
|
29
|
+
constructor(config: PluginConfig, client: OpenCodeClient, logger: Logger, metricsManager: MetricsManager, hierarchyResolver: HierarchyResolver);
|
|
30
|
+
/**
|
|
31
|
+
* Check and mark fallback in progress for deduplication
|
|
32
|
+
*/
|
|
33
|
+
private checkAndMarkFallbackInProgress;
|
|
34
|
+
/**
|
|
35
|
+
* Get or create retry state for a specific message
|
|
36
|
+
*/
|
|
37
|
+
private getOrCreateRetryState;
|
|
38
|
+
/**
|
|
39
|
+
* Get current model for a session
|
|
40
|
+
*/
|
|
41
|
+
getSessionModel(sessionID: string): {
|
|
42
|
+
providerID: string;
|
|
43
|
+
modelID: string;
|
|
44
|
+
} | null;
|
|
45
|
+
/**
|
|
46
|
+
* Abort current session with error handling
|
|
47
|
+
*/
|
|
48
|
+
private abortSession;
|
|
49
|
+
/**
|
|
50
|
+
* Retry the prompt with a different model
|
|
51
|
+
*/
|
|
52
|
+
retryWithModel(targetSessionID: string, model: FallbackModel, parts: MessagePart[], hierarchy: SessionHierarchy | null): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Handle the rate limit fallback process
|
|
55
|
+
*/
|
|
56
|
+
handleRateLimitFallback(sessionID: string, currentProviderID: string, currentModelID: string): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Handle message updated events for metrics recording
|
|
59
|
+
*/
|
|
60
|
+
handleMessageUpdated(sessionID: string, messageID: string, hasError: boolean, isError: boolean): void;
|
|
61
|
+
/**
|
|
62
|
+
* Set model for a session
|
|
63
|
+
*/
|
|
64
|
+
setSessionModel(sessionID: string, providerID: string, modelID: string): void;
|
|
65
|
+
/**
|
|
66
|
+
* Clean up stale entries
|
|
67
|
+
*/
|
|
68
|
+
cleanupStaleEntries(): void;
|
|
69
|
+
/**
|
|
70
|
+
* Clean up all resources
|
|
71
|
+
*/
|
|
72
|
+
destroy(): void;
|
|
73
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback orchestration logic
|
|
3
|
+
*/
|
|
4
|
+
import { ModelSelector } from './ModelSelector.js';
|
|
5
|
+
import { extractMessageParts, convertPartsToSDKFormat, safeShowToast, getStateKey, getModelKey, DEDUP_WINDOW_MS, STATE_TIMEOUT_MS } from '../utils/helpers.js';
|
|
6
|
+
/**
|
|
7
|
+
* Fallback Handler class for orchestrating the fallback retry flow
|
|
8
|
+
*/
|
|
9
|
+
export class FallbackHandler {
|
|
10
|
+
config;
|
|
11
|
+
client;
|
|
12
|
+
logger;
|
|
13
|
+
modelSelector;
|
|
14
|
+
currentSessionModel;
|
|
15
|
+
modelRequestStartTimes;
|
|
16
|
+
retryState;
|
|
17
|
+
fallbackInProgress;
|
|
18
|
+
fallbackMessages;
|
|
19
|
+
// Metrics manager reference
|
|
20
|
+
metricsManager;
|
|
21
|
+
// Hierarchy resolver
|
|
22
|
+
hierarchyResolver;
|
|
23
|
+
constructor(config, client, logger, metricsManager, hierarchyResolver) {
|
|
24
|
+
this.config = config;
|
|
25
|
+
this.client = client;
|
|
26
|
+
this.logger = logger;
|
|
27
|
+
this.modelSelector = new ModelSelector(config, client);
|
|
28
|
+
this.metricsManager = metricsManager;
|
|
29
|
+
this.hierarchyResolver = hierarchyResolver;
|
|
30
|
+
this.currentSessionModel = new Map();
|
|
31
|
+
this.modelRequestStartTimes = new Map();
|
|
32
|
+
this.retryState = new Map();
|
|
33
|
+
this.fallbackInProgress = new Map();
|
|
34
|
+
this.fallbackMessages = new Map();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Check and mark fallback in progress for deduplication
|
|
38
|
+
*/
|
|
39
|
+
checkAndMarkFallbackInProgress(sessionID, messageID) {
|
|
40
|
+
const key = getStateKey(sessionID, messageID);
|
|
41
|
+
const lastFallback = this.fallbackInProgress.get(key);
|
|
42
|
+
if (lastFallback && Date.now() - lastFallback < DEDUP_WINDOW_MS) {
|
|
43
|
+
return false; // Skip - already processing
|
|
44
|
+
}
|
|
45
|
+
this.fallbackInProgress.set(key, Date.now());
|
|
46
|
+
return true; // Continue processing
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Get or create retry state for a specific message
|
|
50
|
+
*/
|
|
51
|
+
getOrCreateRetryState(sessionID, messageID) {
|
|
52
|
+
const stateKey = getStateKey(sessionID, messageID);
|
|
53
|
+
let state = this.retryState.get(stateKey);
|
|
54
|
+
if (!state || Date.now() - state.lastAttemptTime > STATE_TIMEOUT_MS) {
|
|
55
|
+
state = { attemptedModels: new Set(), lastAttemptTime: Date.now() };
|
|
56
|
+
this.retryState.set(stateKey, state);
|
|
57
|
+
}
|
|
58
|
+
return state;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get current model for a session
|
|
62
|
+
*/
|
|
63
|
+
getSessionModel(sessionID) {
|
|
64
|
+
const tracked = this.currentSessionModel.get(sessionID);
|
|
65
|
+
return tracked ? { providerID: tracked.providerID, modelID: tracked.modelID } : null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Abort current session with error handling
|
|
69
|
+
*/
|
|
70
|
+
async abortSession(sessionID) {
|
|
71
|
+
try {
|
|
72
|
+
await this.client.session.abort({ path: { id: sessionID } });
|
|
73
|
+
}
|
|
74
|
+
catch (abortError) {
|
|
75
|
+
// Silently ignore abort errors and continue with fallback
|
|
76
|
+
this.logger.debug(`Failed to abort session ${sessionID}`, { error: abortError });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Retry the prompt with a different model
|
|
81
|
+
*/
|
|
82
|
+
async retryWithModel(targetSessionID, model, parts, hierarchy) {
|
|
83
|
+
// Track the new model for this session
|
|
84
|
+
this.currentSessionModel.set(targetSessionID, {
|
|
85
|
+
providerID: model.providerID,
|
|
86
|
+
modelID: model.modelID,
|
|
87
|
+
lastUpdated: Date.now(),
|
|
88
|
+
});
|
|
89
|
+
// If this is a root session with subagents, propagate the model to all subagents
|
|
90
|
+
if (hierarchy) {
|
|
91
|
+
if (hierarchy.rootSessionID === targetSessionID) {
|
|
92
|
+
hierarchy.sharedFallbackState = "completed";
|
|
93
|
+
hierarchy.lastActivity = Date.now();
|
|
94
|
+
// Update model tracking for all subagents
|
|
95
|
+
for (const [subagentID, subagent] of hierarchy.subagents.entries()) {
|
|
96
|
+
this.currentSessionModel.set(subagentID, {
|
|
97
|
+
providerID: model.providerID,
|
|
98
|
+
modelID: model.modelID,
|
|
99
|
+
lastUpdated: Date.now(),
|
|
100
|
+
});
|
|
101
|
+
subagent.fallbackState = "completed";
|
|
102
|
+
subagent.lastActivity = Date.now();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Record model request for metrics
|
|
107
|
+
if (this.metricsManager) {
|
|
108
|
+
this.metricsManager.recordModelRequest(model.providerID, model.modelID);
|
|
109
|
+
const modelKey = getModelKey(model.providerID, model.modelID);
|
|
110
|
+
this.modelRequestStartTimes.set(modelKey, Date.now());
|
|
111
|
+
}
|
|
112
|
+
// Convert internal MessagePart to SDK-compatible format
|
|
113
|
+
const sdkParts = convertPartsToSDKFormat(parts);
|
|
114
|
+
await this.client.session.prompt({
|
|
115
|
+
path: { id: targetSessionID },
|
|
116
|
+
body: {
|
|
117
|
+
parts: sdkParts,
|
|
118
|
+
model: { providerID: model.providerID, modelID: model.modelID },
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
await safeShowToast(this.client, {
|
|
122
|
+
body: {
|
|
123
|
+
title: "Fallback Successful",
|
|
124
|
+
message: `Now using ${model.modelID}`,
|
|
125
|
+
variant: "success",
|
|
126
|
+
duration: 3000,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Handle the rate limit fallback process
|
|
132
|
+
*/
|
|
133
|
+
async handleRateLimitFallback(sessionID, currentProviderID, currentModelID) {
|
|
134
|
+
try {
|
|
135
|
+
// Get root session and hierarchy using resolver
|
|
136
|
+
const rootSessionID = this.hierarchyResolver.getRootSession(sessionID);
|
|
137
|
+
const targetSessionID = rootSessionID || sessionID;
|
|
138
|
+
const hierarchy = this.hierarchyResolver.getHierarchy(sessionID);
|
|
139
|
+
// If no model info provided, try to get from tracked session model
|
|
140
|
+
if (!currentProviderID || !currentModelID) {
|
|
141
|
+
const tracked = this.currentSessionModel.get(targetSessionID);
|
|
142
|
+
if (tracked) {
|
|
143
|
+
currentProviderID = tracked.providerID;
|
|
144
|
+
currentModelID = tracked.modelID;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Record rate limit metric
|
|
148
|
+
if (currentProviderID && currentModelID && this.metricsManager) {
|
|
149
|
+
this.metricsManager.recordRateLimit(currentProviderID, currentModelID);
|
|
150
|
+
}
|
|
151
|
+
// Abort current session with error handling
|
|
152
|
+
await this.abortSession(targetSessionID);
|
|
153
|
+
await safeShowToast(this.client, {
|
|
154
|
+
body: {
|
|
155
|
+
title: "Rate Limit Detected",
|
|
156
|
+
message: `Switching from ${currentModelID || 'current model'}...`,
|
|
157
|
+
variant: "warning",
|
|
158
|
+
duration: 3000,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
// Get messages from the session
|
|
162
|
+
const messagesResult = await this.client.session.messages({ path: { id: targetSessionID } });
|
|
163
|
+
if (!messagesResult.data) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const messages = messagesResult.data;
|
|
167
|
+
const lastUserMessage = [...messages].reverse().find(m => m.info.role === "user");
|
|
168
|
+
if (!lastUserMessage) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// Check deduplication with message scope
|
|
172
|
+
const dedupSessionID = rootSessionID || sessionID;
|
|
173
|
+
if (!this.checkAndMarkFallbackInProgress(dedupSessionID, lastUserMessage.info.id)) {
|
|
174
|
+
return; // Skip - already processing
|
|
175
|
+
}
|
|
176
|
+
// Update hierarchy state if exists
|
|
177
|
+
if (hierarchy && rootSessionID) {
|
|
178
|
+
hierarchy.sharedFallbackState = "in_progress";
|
|
179
|
+
hierarchy.lastActivity = Date.now();
|
|
180
|
+
const subagent = hierarchy.subagents.get(sessionID);
|
|
181
|
+
if (subagent) {
|
|
182
|
+
subagent.fallbackState = "in_progress";
|
|
183
|
+
subagent.lastActivity = Date.now();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Get or create retry state for this message
|
|
187
|
+
const state = this.getOrCreateRetryState(sessionID, lastUserMessage.info.id);
|
|
188
|
+
const stateKey = getStateKey(sessionID, lastUserMessage.info.id);
|
|
189
|
+
const fallbackKey = getStateKey(dedupSessionID, lastUserMessage.info.id);
|
|
190
|
+
// Select the next fallback model
|
|
191
|
+
const nextModel = await this.modelSelector.selectFallbackModel(currentProviderID, currentModelID, state.attemptedModels);
|
|
192
|
+
// Show error if no model is available
|
|
193
|
+
if (!nextModel) {
|
|
194
|
+
await safeShowToast(this.client, {
|
|
195
|
+
body: {
|
|
196
|
+
title: "No Fallback Available",
|
|
197
|
+
message: this.config.fallbackMode === "stop"
|
|
198
|
+
? "All fallback models exhausted"
|
|
199
|
+
: "All models are rate limited",
|
|
200
|
+
variant: "error",
|
|
201
|
+
duration: 5000,
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
this.retryState.delete(stateKey);
|
|
205
|
+
this.fallbackInProgress.delete(fallbackKey);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
state.attemptedModels.add(getModelKey(nextModel.providerID, nextModel.modelID));
|
|
209
|
+
state.lastAttemptTime = Date.now();
|
|
210
|
+
// Extract message parts
|
|
211
|
+
const parts = extractMessageParts(lastUserMessage);
|
|
212
|
+
if (parts.length === 0) {
|
|
213
|
+
this.fallbackInProgress.delete(fallbackKey);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
await safeShowToast(this.client, {
|
|
217
|
+
body: {
|
|
218
|
+
title: "Retrying",
|
|
219
|
+
message: `Using ${nextModel.providerID}/${nextModel.modelID}`,
|
|
220
|
+
variant: "info",
|
|
221
|
+
duration: 3000,
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
// Record fallback start time
|
|
225
|
+
if (this.metricsManager) {
|
|
226
|
+
this.metricsManager.recordFallbackStart();
|
|
227
|
+
}
|
|
228
|
+
// Track this message as a fallback message for completion detection
|
|
229
|
+
this.fallbackMessages.set(fallbackKey, {
|
|
230
|
+
sessionID: dedupSessionID,
|
|
231
|
+
messageID: lastUserMessage.info.id,
|
|
232
|
+
timestamp: Date.now(),
|
|
233
|
+
});
|
|
234
|
+
// Retry with the selected model
|
|
235
|
+
await this.retryWithModel(dedupSessionID, nextModel, parts, hierarchy);
|
|
236
|
+
// Clean up state
|
|
237
|
+
this.retryState.delete(stateKey);
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
// Log fallback errors at warn level for visibility
|
|
241
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
242
|
+
const errorName = err instanceof Error ? err.name : undefined;
|
|
243
|
+
this.logger.warn(`Fallback error for session ${sessionID}`, {
|
|
244
|
+
error: errorMessage,
|
|
245
|
+
name: errorName,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Handle message updated events for metrics recording
|
|
251
|
+
*/
|
|
252
|
+
handleMessageUpdated(sessionID, messageID, hasError, isError) {
|
|
253
|
+
if (hasError && !isError) {
|
|
254
|
+
// Non-rate-limit error - record model failure metric
|
|
255
|
+
const tracked = this.currentSessionModel.get(sessionID);
|
|
256
|
+
if (tracked) {
|
|
257
|
+
if (this.metricsManager) {
|
|
258
|
+
this.metricsManager.recordModelFailure(tracked.providerID, tracked.modelID);
|
|
259
|
+
// Check if this was a fallback attempt and record failure
|
|
260
|
+
const fallbackKey = getStateKey(sessionID, messageID);
|
|
261
|
+
const fallbackInfo = this.fallbackMessages.get(fallbackKey);
|
|
262
|
+
if (fallbackInfo) {
|
|
263
|
+
this.metricsManager.recordFallbackFailure();
|
|
264
|
+
this.fallbackInProgress.delete(fallbackKey);
|
|
265
|
+
this.fallbackMessages.delete(fallbackKey);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else if (!hasError) {
|
|
271
|
+
// Check if this message is a fallback message and clear its in-progress state
|
|
272
|
+
const fallbackKey = getStateKey(sessionID, messageID);
|
|
273
|
+
const fallbackInfo = this.fallbackMessages.get(fallbackKey);
|
|
274
|
+
if (fallbackInfo) {
|
|
275
|
+
// Clear fallback in progress for this message
|
|
276
|
+
this.fallbackInProgress.delete(fallbackKey);
|
|
277
|
+
this.fallbackMessages.delete(fallbackKey);
|
|
278
|
+
this.logger.debug(`Fallback completed for message ${messageID}`, { sessionID });
|
|
279
|
+
// Record fallback success metric
|
|
280
|
+
const tracked = this.currentSessionModel.get(sessionID);
|
|
281
|
+
if (tracked) {
|
|
282
|
+
if (this.metricsManager) {
|
|
283
|
+
this.metricsManager.recordFallbackSuccess(tracked.providerID, tracked.modelID, fallbackInfo.timestamp);
|
|
284
|
+
// Record model performance metric
|
|
285
|
+
const modelKey = getModelKey(tracked.providerID, tracked.modelID);
|
|
286
|
+
const startTime = this.modelRequestStartTimes.get(modelKey);
|
|
287
|
+
if (startTime) {
|
|
288
|
+
const responseTime = Date.now() - startTime;
|
|
289
|
+
this.metricsManager.recordModelSuccess(tracked.providerID, tracked.modelID, responseTime);
|
|
290
|
+
this.modelRequestStartTimes.delete(modelKey);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Set model for a session
|
|
299
|
+
*/
|
|
300
|
+
setSessionModel(sessionID, providerID, modelID) {
|
|
301
|
+
this.currentSessionModel.set(sessionID, {
|
|
302
|
+
providerID,
|
|
303
|
+
modelID,
|
|
304
|
+
lastUpdated: Date.now(),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Clean up stale entries
|
|
309
|
+
*/
|
|
310
|
+
cleanupStaleEntries() {
|
|
311
|
+
const { STATE_TIMEOUT_MS, SESSION_ENTRY_TTL_MS } = require('../types/index.js');
|
|
312
|
+
const now = Date.now();
|
|
313
|
+
for (const [sessionID, entry] of this.currentSessionModel.entries()) {
|
|
314
|
+
if (now - entry.lastUpdated > SESSION_ENTRY_TTL_MS) {
|
|
315
|
+
this.currentSessionModel.delete(sessionID);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
for (const [stateKey, state] of this.retryState.entries()) {
|
|
319
|
+
if (now - state.lastAttemptTime > STATE_TIMEOUT_MS) {
|
|
320
|
+
this.retryState.delete(stateKey);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
for (const [fallbackKey, fallbackInfo] of this.fallbackMessages.entries()) {
|
|
324
|
+
if (now - fallbackInfo.timestamp > SESSION_ENTRY_TTL_MS) {
|
|
325
|
+
this.fallbackInProgress.delete(fallbackKey);
|
|
326
|
+
this.fallbackMessages.delete(fallbackKey);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
this.modelSelector.cleanupStaleEntries();
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Clean up all resources
|
|
333
|
+
*/
|
|
334
|
+
destroy() {
|
|
335
|
+
this.currentSessionModel.clear();
|
|
336
|
+
this.modelRequestStartTimes.clear();
|
|
337
|
+
this.retryState.clear();
|
|
338
|
+
this.fallbackInProgress.clear();
|
|
339
|
+
this.fallbackMessages.clear();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model selection logic based on fallback mode
|
|
3
|
+
*/
|
|
4
|
+
import type { FallbackModel, PluginConfig, OpenCodeClient } from '../types/index.js';
|
|
5
|
+
/**
|
|
6
|
+
* Model Selector class for handling model selection strategies
|
|
7
|
+
*/
|
|
8
|
+
export declare class ModelSelector {
|
|
9
|
+
private rateLimitedModels;
|
|
10
|
+
private config;
|
|
11
|
+
private client;
|
|
12
|
+
constructor(config: PluginConfig, client: OpenCodeClient);
|
|
13
|
+
/**
|
|
14
|
+
* Check if a model is currently rate limited
|
|
15
|
+
*/
|
|
16
|
+
private isModelRateLimited;
|
|
17
|
+
/**
|
|
18
|
+
* Mark a model as rate limited
|
|
19
|
+
*/
|
|
20
|
+
markModelRateLimited(providerID: string, modelID: string): void;
|
|
21
|
+
/**
|
|
22
|
+
* Find the next available model that is not rate limited
|
|
23
|
+
*/
|
|
24
|
+
private findNextAvailableModel;
|
|
25
|
+
/**
|
|
26
|
+
* Apply the fallback mode logic
|
|
27
|
+
*/
|
|
28
|
+
private applyFallbackMode;
|
|
29
|
+
/**
|
|
30
|
+
* Select the next fallback model based on current state and fallback mode
|
|
31
|
+
*/
|
|
32
|
+
selectFallbackModel(currentProviderID: string, currentModelID: string, attemptedModels: Set<string>): Promise<FallbackModel | null>;
|
|
33
|
+
/**
|
|
34
|
+
* Clean up stale rate-limited models
|
|
35
|
+
*/
|
|
36
|
+
cleanupStaleEntries(): void;
|
|
37
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model selection logic based on fallback mode
|
|
3
|
+
*/
|
|
4
|
+
import { getModelKey } from '../utils/helpers.js';
|
|
5
|
+
import { safeShowToast } from '../utils/helpers.js';
|
|
6
|
+
/**
|
|
7
|
+
* Model Selector class for handling model selection strategies
|
|
8
|
+
*/
|
|
9
|
+
export class ModelSelector {
|
|
10
|
+
rateLimitedModels;
|
|
11
|
+
config;
|
|
12
|
+
client;
|
|
13
|
+
constructor(config, client) {
|
|
14
|
+
this.config = config;
|
|
15
|
+
this.client = client;
|
|
16
|
+
this.rateLimitedModels = new Map();
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Check if a model is currently rate limited
|
|
20
|
+
*/
|
|
21
|
+
isModelRateLimited(providerID, modelID) {
|
|
22
|
+
const key = getModelKey(providerID, modelID);
|
|
23
|
+
const limitedAt = this.rateLimitedModels.get(key);
|
|
24
|
+
if (!limitedAt)
|
|
25
|
+
return false;
|
|
26
|
+
if (Date.now() - limitedAt > this.config.cooldownMs) {
|
|
27
|
+
this.rateLimitedModels.delete(key);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Mark a model as rate limited
|
|
34
|
+
*/
|
|
35
|
+
markModelRateLimited(providerID, modelID) {
|
|
36
|
+
const key = getModelKey(providerID, modelID);
|
|
37
|
+
this.rateLimitedModels.set(key, Date.now());
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Find the next available model that is not rate limited
|
|
41
|
+
*/
|
|
42
|
+
findNextAvailableModel(currentProviderID, currentModelID, attemptedModels) {
|
|
43
|
+
const currentKey = getModelKey(currentProviderID, currentModelID);
|
|
44
|
+
const startIndex = this.config.fallbackModels.findIndex(m => getModelKey(m.providerID, m.modelID) === currentKey);
|
|
45
|
+
// If current model is not in the fallback list (startIndex is -1), start from 0
|
|
46
|
+
const searchStartIndex = Math.max(0, startIndex);
|
|
47
|
+
// Search forward from current position
|
|
48
|
+
for (let i = searchStartIndex + 1; i < this.config.fallbackModels.length; i++) {
|
|
49
|
+
const model = this.config.fallbackModels[i];
|
|
50
|
+
const key = getModelKey(model.providerID, model.modelID);
|
|
51
|
+
if (!attemptedModels.has(key) && !this.isModelRateLimited(model.providerID, model.modelID)) {
|
|
52
|
+
return model;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Search from the beginning
|
|
56
|
+
for (let i = 0; i <= searchStartIndex && i < this.config.fallbackModels.length; i++) {
|
|
57
|
+
const model = this.config.fallbackModels[i];
|
|
58
|
+
const key = getModelKey(model.providerID, model.modelID);
|
|
59
|
+
if (!attemptedModels.has(key) && !this.isModelRateLimited(model.providerID, model.modelID)) {
|
|
60
|
+
return model;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Apply the fallback mode logic
|
|
67
|
+
*/
|
|
68
|
+
applyFallbackMode(currentProviderID, currentModelID, attemptedModels) {
|
|
69
|
+
if (this.config.fallbackMode === "cycle") {
|
|
70
|
+
// Reset and retry from the first model
|
|
71
|
+
attemptedModels.clear();
|
|
72
|
+
if (currentProviderID && currentModelID) {
|
|
73
|
+
attemptedModels.add(getModelKey(currentProviderID, currentModelID));
|
|
74
|
+
}
|
|
75
|
+
return this.findNextAvailableModel("", "", attemptedModels);
|
|
76
|
+
}
|
|
77
|
+
else if (this.config.fallbackMode === "retry-last") {
|
|
78
|
+
// Try the last model in the list once, then reset on next prompt
|
|
79
|
+
const lastModel = this.config.fallbackModels[this.config.fallbackModels.length - 1];
|
|
80
|
+
if (lastModel) {
|
|
81
|
+
const isLastModelCurrent = currentProviderID === lastModel.providerID && currentModelID === lastModel.modelID;
|
|
82
|
+
if (!isLastModelCurrent && !this.isModelRateLimited(lastModel.providerID, lastModel.modelID)) {
|
|
83
|
+
// Use the last model for one more try
|
|
84
|
+
safeShowToast(this.client, {
|
|
85
|
+
body: {
|
|
86
|
+
title: "Last Resort",
|
|
87
|
+
message: `Trying ${lastModel.modelID} one more time...`,
|
|
88
|
+
variant: "warning",
|
|
89
|
+
duration: 3000,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
return lastModel;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
// Last model also failed, reset for next prompt
|
|
96
|
+
attemptedModels.clear();
|
|
97
|
+
if (currentProviderID && currentModelID) {
|
|
98
|
+
attemptedModels.add(getModelKey(currentProviderID, currentModelID));
|
|
99
|
+
}
|
|
100
|
+
return this.findNextAvailableModel("", "", attemptedModels);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// "stop" mode: return null
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Select the next fallback model based on current state and fallback mode
|
|
109
|
+
*/
|
|
110
|
+
async selectFallbackModel(currentProviderID, currentModelID, attemptedModels) {
|
|
111
|
+
// Mark current model as rate limited and add to attempted
|
|
112
|
+
if (currentProviderID && currentModelID) {
|
|
113
|
+
this.markModelRateLimited(currentProviderID, currentModelID);
|
|
114
|
+
attemptedModels.add(getModelKey(currentProviderID, currentModelID));
|
|
115
|
+
}
|
|
116
|
+
let nextModel = this.findNextAvailableModel(currentProviderID || "", currentModelID || "", attemptedModels);
|
|
117
|
+
// Handle when no model is found based on fallbackMode
|
|
118
|
+
if (!nextModel && attemptedModels.size > 0) {
|
|
119
|
+
nextModel = this.applyFallbackMode(currentProviderID, currentModelID, attemptedModels);
|
|
120
|
+
}
|
|
121
|
+
return nextModel;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Clean up stale rate-limited models
|
|
125
|
+
*/
|
|
126
|
+
cleanupStaleEntries() {
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
for (const [key, limitedAt] of this.rateLimitedModels.entries()) {
|
|
129
|
+
if (now - limitedAt > this.config.cooldownMs) {
|
|
130
|
+
this.rateLimitedModels.delete(key);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metrics Manager - Handles metrics collection, aggregation, and reporting
|
|
3
|
+
*/
|
|
4
|
+
import type { Logger } from '../../logger.js';
|
|
5
|
+
import type { MetricsConfig, MetricsData, RateLimitMetrics, FallbackTargetMetrics, ModelPerformanceMetrics } from '../types/index.js';
|
|
6
|
+
/**
|
|
7
|
+
* Metrics Manager class for collecting and reporting metrics
|
|
8
|
+
*/
|
|
9
|
+
export declare class MetricsManager {
|
|
10
|
+
private metrics;
|
|
11
|
+
private config;
|
|
12
|
+
private logger;
|
|
13
|
+
private resetTimer;
|
|
14
|
+
constructor(config: MetricsConfig, logger: Logger);
|
|
15
|
+
/**
|
|
16
|
+
* Start the automatic reset timer
|
|
17
|
+
*/
|
|
18
|
+
private startResetTimer;
|
|
19
|
+
/**
|
|
20
|
+
* Reset all metrics data
|
|
21
|
+
*/
|
|
22
|
+
reset(): void;
|
|
23
|
+
/**
|
|
24
|
+
* Record a rate limit event
|
|
25
|
+
*/
|
|
26
|
+
recordRateLimit(providerID: string, modelID: string): void;
|
|
27
|
+
/**
|
|
28
|
+
* Record the start of a fallback operation
|
|
29
|
+
* @returns timestamp for tracking duration
|
|
30
|
+
*/
|
|
31
|
+
recordFallbackStart(): number;
|
|
32
|
+
/**
|
|
33
|
+
* Record a successful fallback operation
|
|
34
|
+
*/
|
|
35
|
+
recordFallbackSuccess(targetProviderID: string, targetModelID: string, startTime: number): void;
|
|
36
|
+
/**
|
|
37
|
+
* Record a failed fallback operation
|
|
38
|
+
*/
|
|
39
|
+
recordFallbackFailure(): void;
|
|
40
|
+
/**
|
|
41
|
+
* Record a model request
|
|
42
|
+
*/
|
|
43
|
+
recordModelRequest(providerID: string, modelID: string): void;
|
|
44
|
+
/**
|
|
45
|
+
* Record a successful model request
|
|
46
|
+
*/
|
|
47
|
+
recordModelSuccess(providerID: string, modelID: string, responseTime: number): void;
|
|
48
|
+
/**
|
|
49
|
+
* Record a failed model request
|
|
50
|
+
*/
|
|
51
|
+
recordModelFailure(providerID: string, modelID: string): void;
|
|
52
|
+
/**
|
|
53
|
+
* Get a copy of the current metrics
|
|
54
|
+
*/
|
|
55
|
+
getMetrics(): MetricsData;
|
|
56
|
+
/**
|
|
57
|
+
* Export metrics in the specified format
|
|
58
|
+
*/
|
|
59
|
+
export(format?: "pretty" | "json" | "csv"): string;
|
|
60
|
+
/**
|
|
61
|
+
* Convert metrics to a plain object (converts Maps to Objects)
|
|
62
|
+
*/
|
|
63
|
+
private toPlainObject;
|
|
64
|
+
/**
|
|
65
|
+
* Export metrics in pretty-printed text format
|
|
66
|
+
*/
|
|
67
|
+
private exportPretty;
|
|
68
|
+
/**
|
|
69
|
+
* Export metrics in CSV format
|
|
70
|
+
*/
|
|
71
|
+
private exportCSV;
|
|
72
|
+
/**
|
|
73
|
+
* Report metrics to configured outputs
|
|
74
|
+
*/
|
|
75
|
+
report(): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Clean up resources
|
|
78
|
+
*/
|
|
79
|
+
destroy(): void;
|
|
80
|
+
}
|
|
81
|
+
export type { MetricsConfig, MetricsData, RateLimitMetrics, FallbackTargetMetrics, ModelPerformanceMetrics };
|