@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.
- package/README.md +12 -5
- package/docs/API.md +0 -15
- package/images/dashboard-screenshot.png +0 -0
- package/images/readme-cover.png +0 -0
- package/images/settings-screenshot.png +0 -0
- package/package.json +11 -3
- package/public/css/style.css +1097 -40
- package/public/index.html +439 -184
- package/public/js/app.js +384 -66
- package/src/account-rotation/index.js +64 -27
- package/src/format-converter.js +5 -1
- package/src/index.js +1 -1
- package/src/model-mapper.js +145 -22
- package/src/routes/api-routes.js +19 -3
- package/src/routes/chat-route.js +77 -4
- package/src/routes/messages-route.js +189 -21
- package/src/routes/metrics-route.js +43 -0
- package/src/routes/settings-route.js +127 -21
- package/src/security.js +2 -1
- package/src/server-settings.js +40 -5
- package/src/server.js +27 -2
- package/src/usage-metrics.js +472 -0
- package/src/utils/logger.js +14 -1
- package/images/demo-screenshot.png +0 -0
- package/images/f757093f-507b-4453-994e-f8275f8b07a9.png +0 -0
- package/src/account-rotation/strategies/base-strategy.js +0 -48
- package/src/account-rotation/strategies/index.js +0 -31
- package/src/account-rotation/strategies/round-robin-strategy.js +0 -42
- package/src/account-rotation/strategies/sticky-strategy.js +0 -97
|
@@ -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
|
+
};
|
package/src/utils/logger.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
-
}
|