@pikoloo/codex-proxy 1.0.6 → 1.1.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,472 @@
1
+ import { existsSync, mkdirSync, renameSync, rmSync, statSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+
4
+ import { DuckDBInstance } from '@duckdb/node-api';
5
+
6
+ import { CONFIG_DIR } from './account-manager.js';
7
+ import { logger } from './utils/logger.js';
8
+
9
+ const DEFAULT_METRICS_DB = join(CONFIG_DIR, 'metrics.duckdb');
10
+ const DEFAULT_METRICS_MAX_BYTES = 50 * 1024 * 1024;
11
+ const RANGE_MS = {
12
+ '24h': 24 * 60 * 60 * 1000,
13
+ '7d': 7 * 24 * 60 * 60 * 1000,
14
+ '30d': 30 * 24 * 60 * 60 * 1000
15
+ };
16
+
17
+ let defaultStore = null;
18
+
19
+ function envMaxBytes() {
20
+ const raw = Number(process.env.CODEX_CLAUDE_PROXY_METRICS_MAX_BYTES);
21
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_METRICS_MAX_BYTES;
22
+ }
23
+
24
+ function toInt(value) {
25
+ const number = Number(value);
26
+ return Number.isFinite(number) ? Math.trunc(number) : 0;
27
+ }
28
+
29
+ function nowIso() {
30
+ return new Date().toISOString();
31
+ }
32
+
33
+ function sqlString(value) {
34
+ return String(value ?? '').replaceAll("'", "''");
35
+ }
36
+
37
+ function sqlLiteral(value) {
38
+ return value == null ? 'NULL' : `'${sqlString(value)}'`;
39
+ }
40
+
41
+ function normalizeUsage(usage = {}) {
42
+ return {
43
+ input_tokens: toInt(usage.input_tokens ?? usage.prompt_tokens),
44
+ output_tokens: toInt(usage.output_tokens ?? usage.completion_tokens),
45
+ cache_read_input_tokens: toInt(usage.cache_read_input_tokens)
46
+ };
47
+ }
48
+
49
+ function normalizeStatus(status) {
50
+ const numeric = Number(status);
51
+ return Number.isInteger(numeric) && numeric > 0 ? numeric : 0;
52
+ }
53
+
54
+ function normalizeEvent(event = {}) {
55
+ const usage = normalizeUsage(event.usage || event);
56
+ const inputTokens = toInt(event.inputTokens ?? usage.input_tokens);
57
+ const outputTokens = toInt(event.outputTokens ?? usage.output_tokens);
58
+ const cacheReadInputTokens = toInt(event.cacheReadInputTokens ?? usage.cache_read_input_tokens);
59
+ const startedAt = event.startedAt || event.started_at || nowIso();
60
+ const completedAt = event.completedAt || event.completed_at || nowIso();
61
+
62
+ return {
63
+ startedAt,
64
+ completedAt,
65
+ endpoint: event.endpoint || 'unknown',
66
+ requestedModel: event.requestedModel || event.requested_model || null,
67
+ upstreamModel: event.upstreamModel || event.upstream_model || event.requestedModel || null,
68
+ accountLabel: event.accountLabel || event.account_label || null,
69
+ provider: event.provider || 'openai',
70
+ stream: event.stream === true,
71
+ messageCount: toInt(event.messageCount ?? event.message_count),
72
+ toolCount: toInt(event.toolCount ?? event.tool_count),
73
+ inputTokens,
74
+ outputTokens,
75
+ cacheReadInputTokens,
76
+ totalTokens: toInt(event.totalTokens ?? event.total_tokens ?? inputTokens + outputTokens),
77
+ status: normalizeStatus(event.status),
78
+ errorType: event.errorType || event.error_type || null,
79
+ durationMs: toInt(event.durationMs ?? event.duration_ms)
80
+ };
81
+ }
82
+
83
+ function statusWhere(status) {
84
+ if (status === 'success') return 'status BETWEEN 200 AND 399';
85
+ if (status === 'error') return 'status >= 400';
86
+ const numeric = Number(status);
87
+ if (Number.isInteger(numeric) && numeric > 0) return `status = ${numeric}`;
88
+ return null;
89
+ }
90
+
91
+ function buildWhere(filters = {}) {
92
+ const clauses = [];
93
+ const range = filters.range || '24h';
94
+ if (RANGE_MS[range]) {
95
+ clauses.push(`started_at >= TIMESTAMP '${new Date(Date.now() - RANGE_MS[range]).toISOString()}'`);
96
+ }
97
+ if (filters.model) {
98
+ clauses.push(`upstream_model = '${sqlString(filters.model)}'`);
99
+ }
100
+ if (filters.account) {
101
+ clauses.push(`account_label = '${sqlString(filters.account)}'`);
102
+ }
103
+ const statusClause = statusWhere(filters.status);
104
+ if (statusClause) clauses.push(statusClause);
105
+ return clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
106
+ }
107
+
108
+ function rowObjects(reader) {
109
+ return reader.getRowObjectsJson().map((row) => {
110
+ const normalized = {};
111
+ for (const [key, value] of Object.entries(row)) {
112
+ const maybeNumber = Number(value);
113
+ normalized[key] = typeof value === 'string' && value.trim() !== '' && Number.isFinite(maybeNumber)
114
+ ? maybeNumber
115
+ : value;
116
+ }
117
+ return normalized;
118
+ });
119
+ }
120
+
121
+ function fileSize(path) {
122
+ if (!existsSync(path)) return 0;
123
+ return statSync(path).size;
124
+ }
125
+
126
+ async function copyDatabase(sourcePath, targetPath) {
127
+ const instance = await DuckDBInstance.create(':memory:');
128
+ const connection = await instance.connect();
129
+ try {
130
+ await connection.run(`ATTACH '${sqlString(sourcePath)}' AS source_db (READ_ONLY)`);
131
+ await connection.run(`ATTACH '${sqlString(targetPath)}' AS target_db`);
132
+ await connection.run('COPY FROM DATABASE source_db TO target_db');
133
+ } finally {
134
+ connection.closeSync();
135
+ instance.closeSync();
136
+ }
137
+ }
138
+
139
+ class UsageMetricsStore {
140
+ constructor(options = {}) {
141
+ this.dbPath = options.dbPath || process.env.CODEX_CLAUDE_PROXY_METRICS_DB || DEFAULT_METRICS_DB;
142
+ this.maxBytes = Number(options.maxBytes || envMaxBytes());
143
+ this.instance = null;
144
+ this.connection = null;
145
+ this.ready = null;
146
+ this.queue = Promise.resolve();
147
+ }
148
+
149
+ async close() {
150
+ await this.ready?.catch(() => {});
151
+ this.connection?.closeSync();
152
+ this.instance?.closeSync();
153
+ this.connection = null;
154
+ this.instance = null;
155
+ this.ready = null;
156
+ }
157
+
158
+ async recordUsageEvent(event) {
159
+ return this.enqueue(async () => {
160
+ const normalized = normalizeEvent(event);
161
+ await this.ensureReady();
162
+ await this.connection.run(`
163
+ INSERT INTO usage_events (
164
+ started_at, completed_at, endpoint, requested_model, upstream_model,
165
+ account_label, provider, stream, message_count, tool_count,
166
+ input_tokens, output_tokens, cache_read_input_tokens, total_tokens,
167
+ status, error_type, duration_ms
168
+ ) VALUES (
169
+ TIMESTAMP ${sqlLiteral(normalized.startedAt)},
170
+ TIMESTAMP ${sqlLiteral(normalized.completedAt)},
171
+ ${sqlLiteral(normalized.endpoint)},
172
+ ${sqlLiteral(normalized.requestedModel)},
173
+ ${sqlLiteral(normalized.upstreamModel)},
174
+ ${sqlLiteral(normalized.accountLabel)},
175
+ ${sqlLiteral(normalized.provider)},
176
+ ${normalized.stream ? 'true' : 'false'},
177
+ ${normalized.messageCount},
178
+ ${normalized.toolCount},
179
+ ${normalized.inputTokens},
180
+ ${normalized.outputTokens},
181
+ ${normalized.cacheReadInputTokens},
182
+ ${normalized.totalTokens},
183
+ ${normalized.status},
184
+ ${sqlLiteral(normalized.errorType)},
185
+ ${normalized.durationMs}
186
+ )
187
+ `);
188
+ await this.compactIfNeeded();
189
+ });
190
+ }
191
+
192
+ async getSummary(filters = {}) {
193
+ await this.ensureReady();
194
+ const where = buildWhere(filters);
195
+ const totals = rowObjects(await this.connection.runAndReadAll(`
196
+ SELECT
197
+ count(*)::INTEGER AS requestCount,
198
+ sum(CASE WHEN status BETWEEN 200 AND 399 THEN 1 ELSE 0 END)::INTEGER AS successCount,
199
+ sum(CASE WHEN status >= 400 THEN 1 ELSE 0 END)::INTEGER AS errorCount,
200
+ coalesce(sum(input_tokens), 0)::INTEGER AS inputTokens,
201
+ coalesce(sum(output_tokens), 0)::INTEGER AS outputTokens,
202
+ coalesce(sum(cache_read_input_tokens), 0)::INTEGER AS cacheReadInputTokens,
203
+ coalesce(sum(total_tokens), 0)::INTEGER AS totalTokens,
204
+ coalesce(round(avg(duration_ms)), 0)::INTEGER AS averageDurationMs
205
+ FROM usage_events
206
+ ${where}
207
+ `))[0] || this.emptyTotals();
208
+
209
+ const byModel = rowObjects(await this.connection.runAndReadAll(`
210
+ SELECT
211
+ coalesce(upstream_model, 'unknown') AS model,
212
+ count(*)::INTEGER AS requestCount,
213
+ coalesce(sum(total_tokens), 0)::INTEGER AS totalTokens,
214
+ coalesce(sum(input_tokens), 0)::INTEGER AS inputTokens,
215
+ coalesce(sum(output_tokens), 0)::INTEGER AS outputTokens
216
+ FROM usage_events
217
+ ${where}
218
+ GROUP BY coalesce(upstream_model, 'unknown')
219
+ ORDER BY totalTokens DESC, requestCount DESC
220
+ LIMIT 12
221
+ `));
222
+
223
+ const byAccount = rowObjects(await this.connection.runAndReadAll(`
224
+ SELECT
225
+ coalesce(account_label, 'unknown') AS accountLabel,
226
+ count(*)::INTEGER AS requestCount,
227
+ coalesce(sum(total_tokens), 0)::INTEGER AS totalTokens
228
+ FROM usage_events
229
+ ${where}
230
+ GROUP BY coalesce(account_label, 'unknown')
231
+ ORDER BY totalTokens DESC, requestCount DESC
232
+ LIMIT 12
233
+ `));
234
+
235
+ const timeline = rowObjects(await this.connection.runAndReadAll(`
236
+ SELECT
237
+ strftime(started_at, '%Y-%m-%d %H:00') AS bucket,
238
+ count(*)::INTEGER AS requestCount,
239
+ coalesce(sum(total_tokens), 0)::INTEGER AS totalTokens
240
+ FROM usage_events
241
+ ${where}
242
+ GROUP BY bucket
243
+ ORDER BY bucket ASC
244
+ LIMIT 72
245
+ `));
246
+
247
+ return {
248
+ range: filters.range || '24h',
249
+ totals: { ...this.emptyTotals(), ...totals },
250
+ byModel,
251
+ byAccount,
252
+ timeline
253
+ };
254
+ }
255
+
256
+ async getRecentEvents(filters = {}) {
257
+ await this.ensureReady();
258
+ const where = buildWhere(filters);
259
+ const limit = Math.max(1, Math.min(100, toInt(filters.limit) || 50));
260
+ const events = rowObjects(await this.connection.runAndReadAll(`
261
+ SELECT
262
+ rowid::INTEGER AS id,
263
+ strftime(started_at, '%Y-%m-%dT%H:%M:%S.000Z') AS startedAt,
264
+ strftime(completed_at, '%Y-%m-%dT%H:%M:%S.000Z') AS completedAt,
265
+ endpoint,
266
+ requested_model AS requestedModel,
267
+ upstream_model AS upstreamModel,
268
+ account_label AS accountLabel,
269
+ provider,
270
+ stream,
271
+ message_count AS messageCount,
272
+ tool_count AS toolCount,
273
+ input_tokens AS inputTokens,
274
+ output_tokens AS outputTokens,
275
+ cache_read_input_tokens AS cacheReadInputTokens,
276
+ total_tokens AS totalTokens,
277
+ status,
278
+ error_type AS errorType,
279
+ duration_ms AS durationMs
280
+ FROM usage_events
281
+ ${where}
282
+ ORDER BY started_at DESC, rowid DESC
283
+ LIMIT ${limit}
284
+ `));
285
+ return { range: filters.range || '24h', events };
286
+ }
287
+
288
+ async getStorageInfo() {
289
+ await this.ensureReady();
290
+ const metadata = await this.getMetadata();
291
+ const sizeBytes = fileSize(this.dbPath);
292
+ return {
293
+ dbPath: this.dbPath,
294
+ sizeBytes,
295
+ maxBytes: this.maxBytes,
296
+ overLimit: sizeBytes > this.maxBytes,
297
+ lastCompactionAttemptAt: metadata.last_compaction_attempt_at || null,
298
+ lastCompactionBeforeBytes: toInt(metadata.last_compaction_before_bytes),
299
+ lastCompactionAfterBytes: toInt(metadata.last_compaction_after_bytes),
300
+ lastCompactionError: metadata.last_compaction_error || null
301
+ };
302
+ }
303
+
304
+ async compactIfNeeded() {
305
+ await this.ensureReady();
306
+ const beforeBytes = fileSize(this.dbPath);
307
+ if (beforeBytes <= this.maxBytes) return;
308
+
309
+ const attemptedAt = nowIso();
310
+ let afterBytes = beforeBytes;
311
+ let error = null;
312
+
313
+ try {
314
+ await this.connection.run('CHECKPOINT');
315
+ afterBytes = fileSize(this.dbPath);
316
+
317
+ if (afterBytes > this.maxBytes) {
318
+ await this.copyCompact();
319
+ afterBytes = fileSize(this.dbPath);
320
+ }
321
+ } catch (err) {
322
+ error = err.message;
323
+ logger.warn(`[Metrics] Compaction failed: ${err.message}`);
324
+ }
325
+
326
+ await this.setMetadata({
327
+ last_compaction_attempt_at: attemptedAt,
328
+ last_compaction_before_bytes: String(beforeBytes),
329
+ last_compaction_after_bytes: String(afterBytes),
330
+ last_compaction_error: error || ''
331
+ });
332
+ }
333
+
334
+ async copyCompact() {
335
+ const compactPath = `${this.dbPath}.compact-${process.pid}-${Date.now()}`;
336
+ const backupPath = `${this.dbPath}.bak-${process.pid}-${Date.now()}`;
337
+
338
+ this.connection?.closeSync();
339
+ this.instance?.closeSync();
340
+ this.connection = null;
341
+ this.instance = null;
342
+ this.ready = null;
343
+
344
+ try {
345
+ rmSync(compactPath, { force: true });
346
+ await copyDatabase(this.dbPath, compactPath);
347
+ renameSync(this.dbPath, backupPath);
348
+ renameSync(compactPath, this.dbPath);
349
+ rmSync(backupPath, { force: true });
350
+ } finally {
351
+ rmSync(compactPath, { force: true });
352
+ await this.ensureReady();
353
+ }
354
+ }
355
+
356
+ async getMetadata() {
357
+ const rows = rowObjects(await this.connection.runAndReadAll('SELECT key, value FROM usage_metadata'));
358
+ return Object.fromEntries(rows.map((row) => [row.key, row.value]));
359
+ }
360
+
361
+ async setMetadata(values) {
362
+ for (const [key, value] of Object.entries(values)) {
363
+ await this.connection.run(`
364
+ INSERT OR REPLACE INTO usage_metadata (key, value)
365
+ VALUES (${sqlLiteral(key)}, ${sqlLiteral(value)})
366
+ `);
367
+ }
368
+ }
369
+
370
+ emptyTotals() {
371
+ return {
372
+ requestCount: 0,
373
+ successCount: 0,
374
+ errorCount: 0,
375
+ inputTokens: 0,
376
+ outputTokens: 0,
377
+ cacheReadInputTokens: 0,
378
+ totalTokens: 0,
379
+ averageDurationMs: 0
380
+ };
381
+ }
382
+
383
+ async ensureReady() {
384
+ if (!this.ready) {
385
+ this.ready = this.init();
386
+ }
387
+ await this.ready;
388
+ }
389
+
390
+ async init() {
391
+ mkdirSync(dirname(this.dbPath), { recursive: true, mode: 0o700 });
392
+ this.instance = await DuckDBInstance.fromCache(this.dbPath);
393
+ this.connection = await this.instance.connect();
394
+ await this.connection.run(`
395
+ CREATE TABLE IF NOT EXISTS usage_events (
396
+ started_at TIMESTAMP NOT NULL,
397
+ completed_at TIMESTAMP NOT NULL,
398
+ endpoint VARCHAR NOT NULL,
399
+ requested_model VARCHAR,
400
+ upstream_model VARCHAR,
401
+ account_label VARCHAR,
402
+ provider VARCHAR NOT NULL,
403
+ stream BOOLEAN NOT NULL,
404
+ message_count INTEGER NOT NULL,
405
+ tool_count INTEGER NOT NULL,
406
+ input_tokens INTEGER NOT NULL,
407
+ output_tokens INTEGER NOT NULL,
408
+ cache_read_input_tokens INTEGER NOT NULL,
409
+ total_tokens INTEGER NOT NULL,
410
+ status INTEGER NOT NULL,
411
+ error_type VARCHAR,
412
+ duration_ms INTEGER NOT NULL
413
+ )
414
+ `);
415
+ await this.connection.run(`
416
+ CREATE TABLE IF NOT EXISTS usage_metadata (
417
+ key VARCHAR PRIMARY KEY,
418
+ value VARCHAR NOT NULL
419
+ )
420
+ `);
421
+ await this.setMetadata({ schema_version: '1' });
422
+ }
423
+
424
+ enqueue(task) {
425
+ const run = this.queue.then(task, task);
426
+ this.queue = run.catch(() => {});
427
+ return run;
428
+ }
429
+ }
430
+
431
+ export function createUsageMetricsStore(options = {}) {
432
+ return new UsageMetricsStore(options);
433
+ }
434
+
435
+ export function getUsageMetricsStore() {
436
+ if (!defaultStore) {
437
+ defaultStore = createUsageMetricsStore();
438
+ }
439
+ return defaultStore;
440
+ }
441
+
442
+ export async function recordUsageEventSafe(event, store = getUsageMetricsStore()) {
443
+ try {
444
+ await store.recordUsageEvent(event);
445
+ } catch (error) {
446
+ logger.warn(`[Metrics] Failed to record usage: ${error.message}`);
447
+ }
448
+ }
449
+
450
+ export async function* tapUsageEventStream(eventStream, onUsage) {
451
+ let finalUsage = null;
452
+ for await (const event of eventStream) {
453
+ if (event?.data?.type === 'message_delta' && event.data.usage) {
454
+ finalUsage = normalizeUsage(event.data.usage);
455
+ }
456
+ yield event;
457
+ }
458
+ await onUsage?.(finalUsage || normalizeUsage());
459
+ }
460
+
461
+ export {
462
+ DEFAULT_METRICS_DB,
463
+ DEFAULT_METRICS_MAX_BYTES,
464
+ normalizeUsage
465
+ };
466
+
467
+ export default {
468
+ createUsageMetricsStore,
469
+ getUsageMetricsStore,
470
+ recordUsageEventSafe,
471
+ tapUsageEventStream
472
+ };
@@ -140,8 +140,21 @@ class Logger extends EventEmitter {
140
140
 
141
141
  response(status, details = {}) {
142
142
  const parts = [`status=${status}`];
143
+ const usage = details.usage || {};
144
+ const inputTokens = usage.input_tokens ?? usage.prompt_tokens;
145
+ const outputTokens = usage.output_tokens ?? usage.completion_tokens;
146
+ const cacheTokens = usage.cache_read_input_tokens ?? usage.prompt_tokens_details?.cached_tokens;
147
+ const totalTokens = details.tokens ?? usage.total_tokens ?? (
148
+ Number.isFinite(inputTokens) && Number.isFinite(outputTokens)
149
+ ? inputTokens + outputTokens
150
+ : undefined
151
+ );
152
+
143
153
  if (details.model) parts.push(`model=${details.model}`);
144
- if (details.tokens) parts.push(`tokens=${details.tokens}`);
154
+ if (Number.isFinite(totalTokens)) parts.push(`tokens=${totalTokens}`);
155
+ if (Number.isFinite(inputTokens)) parts.push(`input=${inputTokens}`);
156
+ if (Number.isFinite(outputTokens)) parts.push(`output=${outputTokens}`);
157
+ if (Number.isFinite(cacheTokens)) parts.push(`cache=${cacheTokens}`);
145
158
  if (details.duration) parts.push(`${details.duration}ms`);
146
159
  if (details.error) parts.push(`error=${details.error}`);
147
160
 
Binary file
@@ -1,48 +0,0 @@
1
- import { isAccountCoolingDown } from '../rate-limits.js';
2
-
3
- export class BaseStrategy {
4
- constructor(config, name = 'base') {
5
- if (this.constructor === BaseStrategy) {
6
- throw new Error('BaseStrategy is abstract and cannot be instantiated directly');
7
- }
8
- this.config = config;
9
- this.name = name;
10
- }
11
-
12
- selectAccount(accounts, modelId, options) {
13
- throw new Error('selectAccount must be implemented by subclass');
14
- }
15
-
16
- onSuccess(account, modelId) {}
17
-
18
- onRateLimit(account, modelId) {}
19
-
20
- onFailure(account, modelId) {}
21
-
22
- isAccountUsable(account, modelId) {
23
- if (!account) return false;
24
- if (account.isInvalid) return false;
25
- if (account.enabled === false) return false;
26
- if (isAccountCoolingDown(account)) return false;
27
-
28
- // Check model-specific rate limit
29
- if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) {
30
- const limit = account.modelRateLimits[modelId];
31
- if (limit.isRateLimited && limit.resetTime > Date.now()) {
32
- return false;
33
- }
34
- }
35
-
36
- return true;
37
- }
38
-
39
- getUsableAccounts(accounts, modelId) {
40
- const usable = [];
41
- for (let i = 0; i < accounts.length; i++) {
42
- if (this.isAccountUsable(accounts[i], modelId)) {
43
- usable.push({ account: accounts[i], index: i });
44
- }
45
- }
46
- return usable;
47
- }
48
- }
@@ -1,31 +0,0 @@
1
- import { BaseStrategy } from './base-strategy.js';
2
- import { StickyStrategy } from './sticky-strategy.js';
3
- import { RoundRobinStrategy } from './round-robin-strategy.js';
4
-
5
- export { BaseStrategy, StickyStrategy, RoundRobinStrategy };
6
-
7
- export const DEFAULT_STRATEGY = 'sticky';
8
-
9
- export const STRATEGIES = {
10
- STICKY: 'sticky',
11
- ROUND_ROBIN: 'round-robin',
12
- };
13
-
14
- const strategyMap = {
15
- sticky: StickyStrategy,
16
- 'round-robin': RoundRobinStrategy,
17
- };
18
-
19
- const strategyLabels = {
20
- sticky: 'Sticky (Cache-Optimized)',
21
- 'round-robin': 'Round-Robin (Load-Balanced)',
22
- };
23
-
24
- export function createStrategy(name, config) {
25
- const StrategyClass = strategyMap[name] || StickyStrategy;
26
- return new StrategyClass(config);
27
- }
28
-
29
- export function getStrategyLabel(name) {
30
- return strategyLabels[name] || strategyLabels[DEFAULT_STRATEGY];
31
- }
@@ -1,42 +0,0 @@
1
- import { BaseStrategy } from './base-strategy.js';
2
- import { logger } from '../../utils/logger.js';
3
-
4
- export class RoundRobinStrategy extends BaseStrategy {
5
- constructor(config) {
6
- super(config, 'round-robin');
7
- this.cursor = 0;
8
- }
9
-
10
- selectAccount(accounts, modelId, options = {}) {
11
- if (!accounts || accounts.length === 0) {
12
- return { account: null, index: 0, waitMs: 0 };
13
- }
14
-
15
- // Clamp cursor
16
- if (this.cursor >= accounts.length) {
17
- this.cursor = 0;
18
- }
19
-
20
- // Start from next position after cursor
21
- const startIndex = (this.cursor + 1) % accounts.length;
22
-
23
- for (let i = 0; i < accounts.length; i++) {
24
- const checkIndex = (startIndex + i) % accounts.length;
25
- const account = accounts[checkIndex];
26
-
27
- if (this.isAccountUsable(account, modelId)) {
28
- account.lastUsed = Date.now();
29
- this.cursor = checkIndex;
30
- logger.debug(`RoundRobinStrategy: Using account at index ${checkIndex}`);
31
- return { account, index: checkIndex, waitMs: 0 };
32
- }
33
- }
34
-
35
- // No usable accounts
36
- return { account: null, index: this.cursor, waitMs: 0 };
37
- }
38
-
39
- resetCursor() {
40
- this.cursor = 0;
41
- }
42
- }