ldrouter 1.18.2 → 1.19.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/CHANGELOG.md +28 -0
- package/README.md +15 -1
- package/dist/server/backup/container.js +99 -0
- package/dist/server/backup/export.js +99 -0
- package/dist/server/backup/restore-upload.js +333 -0
- package/dist/server/backup/stats.js +15 -0
- package/dist/server/config/index.js +4 -0
- package/dist/server/db/schema.js +18 -1
- package/dist/server/gateway/runner.js +73 -59
- package/dist/server/protocols/canonical.js +6 -0
- package/dist/server/routes/admin/backup.js +177 -191
- package/dist/server/routes/admin/stats.js +182 -67
- package/dist/web/assets/index-BdbskFHA.js +349 -0
- package/dist/web/index.html +1 -1
- package/migrations/0011_daily_aggregate_stats.sql +60 -0
- package/package.json +1 -1
- package/dist/web/assets/index-DYrnLQwk.js +0 -349
|
@@ -2,20 +2,31 @@
|
|
|
2
2
|
import { and, eq, gte, lte, sql, desc } from 'drizzle-orm';
|
|
3
3
|
import { getDb, schema } from '../../db/index.js';
|
|
4
4
|
import { requireAdminAuth } from '../../auth/middleware.js';
|
|
5
|
+
import { GatewayError } from '../../errors.js';
|
|
5
6
|
import { toSummary, loadSummaryMaps } from './requests.js';
|
|
7
|
+
/**
|
|
8
|
+
* Midnight UTC, `offsetDays` from the day containing `now`.
|
|
9
|
+
*
|
|
10
|
+
* Preset windows are day-aligned on purpose. A rolling window always clips its first day, and a
|
|
11
|
+
* clipped day whose raw rows were pruned cannot be reconstructed from the day-granular aggregate —
|
|
12
|
+
* so a compacted install answered 400 for every preset the UI can send. Aligning the window makes
|
|
13
|
+
* the first day whole and leaves only today partial, which is never served from an aggregate.
|
|
14
|
+
*/
|
|
15
|
+
function utcDayStart(now, offsetDays) {
|
|
16
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + offsetDays));
|
|
17
|
+
}
|
|
6
18
|
const PRESETS = {
|
|
7
19
|
today: () => {
|
|
8
20
|
const now = new Date();
|
|
9
|
-
|
|
10
|
-
return { from, to: now, bucket: 'hour' };
|
|
21
|
+
return { from: utcDayStart(now, 0), to: now, bucket: 'hour' };
|
|
11
22
|
},
|
|
12
23
|
'7d': () => {
|
|
13
24
|
const now = new Date();
|
|
14
|
-
return { from:
|
|
25
|
+
return { from: utcDayStart(now, -6), to: now, bucket: 'day' };
|
|
15
26
|
},
|
|
16
27
|
'30d': () => {
|
|
17
28
|
const now = new Date();
|
|
18
|
-
return { from:
|
|
29
|
+
return { from: utcDayStart(now, -29), to: now, bucket: 'day' };
|
|
19
30
|
},
|
|
20
31
|
};
|
|
21
32
|
/**
|
|
@@ -49,10 +60,43 @@ function cacheHitRateFor(db, conds) {
|
|
|
49
60
|
function rangeFromQuery(q) {
|
|
50
61
|
if (q.preset && PRESETS[q.preset])
|
|
51
62
|
return PRESETS[q.preset]();
|
|
52
|
-
const
|
|
53
|
-
const
|
|
63
|
+
const now = new Date();
|
|
64
|
+
const from = q.from ? new Date(q.from) : utcDayStart(now, -6);
|
|
65
|
+
const to = q.to ? new Date(q.to) : now;
|
|
54
66
|
return { from, to, bucket: q.bucket === 'hour' ? 'hour' : 'day' };
|
|
55
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* UTC days whose raw request detail is gone and that therefore must be answered from the
|
|
70
|
+
* day-granular aggregate.
|
|
71
|
+
*
|
|
72
|
+
* Having a `daily_model_stats` row proves nothing: the aggregate is written for EVERY logged
|
|
73
|
+
* request, so every day with traffic has one, and treating that as "compacted" made every
|
|
74
|
+
* rolling preset (whose window always clips its first day) answer 400. Only the raw log
|
|
75
|
+
* separates the states, and only a day holding FEWER raw rows than it logged needs its
|
|
76
|
+
* aggregate — which is exactly what retention (cutoff mid-day) and a compact export/restore
|
|
77
|
+
* leave behind.
|
|
78
|
+
*
|
|
79
|
+
* Counted over whole UTC days, never the requested window: a clipped first day still has all
|
|
80
|
+
* of its raw rows, and comparing a window-scoped count against a whole-day aggregate would
|
|
81
|
+
* report every such day as pruned. `created_at` is indexed, so this is one grouped range scan.
|
|
82
|
+
*/
|
|
83
|
+
function aggregateRequiredDays(db, aggregateRows) {
|
|
84
|
+
const loggedByDay = new Map();
|
|
85
|
+
for (const r of aggregateRows)
|
|
86
|
+
loggedByDay.set(r.day, (loggedByDay.get(r.day) ?? 0) + r.requests);
|
|
87
|
+
if (loggedByDay.size === 0)
|
|
88
|
+
return [];
|
|
89
|
+
// ISO-8601 UTC dates sort lexicographically, so min/max bound the whole-day scan.
|
|
90
|
+
const days = [...loggedByDay.keys()].sort();
|
|
91
|
+
const rawCounts = db
|
|
92
|
+
.select({ day: sql `substr(created_at,1,10)`, c: sql `COUNT(*)` })
|
|
93
|
+
.from(schema.requests)
|
|
94
|
+
.where(and(gte(schema.requests.createdAt, `${days[0]}T00:00:00.000Z`), lte(schema.requests.createdAt, `${days[days.length - 1]}T23:59:59.999Z`)))
|
|
95
|
+
.groupBy(sql `substr(created_at,1,10)`)
|
|
96
|
+
.all();
|
|
97
|
+
const rawByDay = new Map(rawCounts.map((r) => [r.day, Number(r.c)]));
|
|
98
|
+
return [...loggedByDay].filter(([day, logged]) => (rawByDay.get(day) ?? 0) < logged).map(([day]) => day);
|
|
99
|
+
}
|
|
56
100
|
export async function registerStatsRoutes(app) {
|
|
57
101
|
app.addHook('preHandler', requireAdminAuth);
|
|
58
102
|
app.get('/api/admin/stats', async (req) => {
|
|
@@ -62,6 +106,22 @@ export async function registerStatsRoutes(app) {
|
|
|
62
106
|
const fromIso = from.toISOString();
|
|
63
107
|
const toIso = to.toISOString();
|
|
64
108
|
const conds = [gte(schema.requests.createdAt, fromIso), lte(schema.requests.createdAt, toIso)];
|
|
109
|
+
const utcToday = new Date().toISOString().slice(0, 10);
|
|
110
|
+
const aggregateRows = db.select().from(schema.dailyModelStats).where(and(gte(schema.dailyModelStats.day, fromIso.slice(0, 10)), lte(schema.dailyModelStats.day, toIso.slice(0, 10)))).all();
|
|
111
|
+
// Only days whose raw detail is actually gone are answered from the day-granular aggregate.
|
|
112
|
+
// A day that still has its raw rows is served from raw, exactly like a fresh install.
|
|
113
|
+
const aggregateDays = aggregateRequiredDays(db, aggregateRows)
|
|
114
|
+
.filter((day) => day < utcToday && `${day}T00:00:00.000Z` <= toIso && `${day}T23:59:59.999Z` >= fromIso);
|
|
115
|
+
const clippedDays = aggregateDays.filter((day) => `${day}T00:00:00.000Z` < fromIso || `${day}T23:59:59.999Z` > toIso);
|
|
116
|
+
if (clippedDays.length > 0) {
|
|
117
|
+
throw new GatewayError('invalid_request_error', 'Statistics ranges overlapping compacted history must include complete UTC days.', { status: 400 });
|
|
118
|
+
}
|
|
119
|
+
const percentilesAvailable = aggregateDays.length === 0;
|
|
120
|
+
const aggregateOnly = aggregateDays.length > 0;
|
|
121
|
+
const rawConds = aggregateDays.length
|
|
122
|
+
? and(...conds, sql `substr(created_at,1,10) NOT IN (${sql.join(aggregateDays.map((day) => sql `${day}`), sql `, `)})`)
|
|
123
|
+
: and(...conds);
|
|
124
|
+
const aggregateTotals = aggregateRows.filter((r) => aggregateDays.includes(r.day)).reduce((a, r) => ({ total: a.total + r.requests, success: a.success + r.successfulRequests, failed: a.failed + r.failedRequests, input: a.input + r.inputTokens, output: a.output + r.outputTokens, cacheRead: a.cacheRead + r.cacheReadTokens, cacheWrite: a.cacheWrite + r.cacheWriteTokens, reasoning: a.reasoning + r.reasoningTokens, gateway: a.gateway + r.gatewayCacheHits, fallback: a.fallback + r.fallbackRequests, latencySum: a.latencySum + r.latencySuccessSumMs, latencyCount: a.latencyCount + r.latencySuccessCount, ttftSum: a.ttftSum + r.ttftSumMs, ttftCount: a.ttftCount + r.ttftCount }), { total: 0, success: 0, failed: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, gateway: 0, fallback: 0, latencySum: 0, latencyCount: 0, ttftSum: 0, ttftCount: 0 });
|
|
65
125
|
const summary = db
|
|
66
126
|
.select({
|
|
67
127
|
total: sql `COUNT(*)`,
|
|
@@ -76,13 +136,14 @@ export async function registerStatsRoutes(app) {
|
|
|
76
136
|
fallbacks: sql `SUM(CASE WHEN attempts_count > 1 THEN 1 ELSE 0 END)`,
|
|
77
137
|
})
|
|
78
138
|
.from(schema.requests)
|
|
79
|
-
.where(
|
|
139
|
+
.where(rawConds)
|
|
80
140
|
.get();
|
|
81
|
-
|
|
141
|
+
// Aggregate rows retain cache counts but not provider-specific prompt denominator semantics.
|
|
142
|
+
const cacheHitRate = aggregateDays.length ? null : cacheHitRateFor(db, conds);
|
|
82
143
|
const latRows = db
|
|
83
144
|
.select({ v: schema.requests.totalLatencyMs })
|
|
84
145
|
.from(schema.requests)
|
|
85
|
-
.where(and(
|
|
146
|
+
.where(and(rawConds, eq(schema.requests.success, true)))
|
|
86
147
|
.all();
|
|
87
148
|
const latencies = latRows.map((r) => r.v).filter((v) => v > 0).sort((a, b) => a - b);
|
|
88
149
|
const avg = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
|
|
@@ -90,32 +151,32 @@ export async function registerStatsRoutes(app) {
|
|
|
90
151
|
const ttftRows = db
|
|
91
152
|
.select({ v: schema.requests.ttftMs })
|
|
92
153
|
.from(schema.requests)
|
|
93
|
-
.where(and(
|
|
154
|
+
.where(and(rawConds, sql `ttft_ms IS NOT NULL`))
|
|
94
155
|
.all();
|
|
95
156
|
const ttfts = ttftRows.map((r) => r.v).filter((v) => v !== null).sort((a, b) => a - b);
|
|
96
157
|
const avgTtft = ttfts.length ? ttfts.reduce((a, b) => a + b, 0) / ttfts.length : null;
|
|
97
158
|
const p95Ttft = ttfts.length ? percentile(ttfts, 95) : null;
|
|
98
|
-
const total = Number(summary?.total ?? 0);
|
|
99
|
-
const success = Number(summary?.success ?? 0);
|
|
100
|
-
const failed = Number(summary?.failed ?? 0);
|
|
159
|
+
const total = Number(summary?.total ?? 0) + aggregateTotals.total;
|
|
160
|
+
const success = Number(summary?.success ?? 0) + aggregateTotals.success;
|
|
161
|
+
const failed = Number(summary?.failed ?? 0) + aggregateTotals.failed;
|
|
101
162
|
const statsSummary = {
|
|
102
163
|
totalRequests: total,
|
|
103
164
|
successfulRequests: success,
|
|
104
165
|
failedRequests: failed,
|
|
105
166
|
successRate: total ? success / total : 0,
|
|
106
|
-
inputTokens: Number(summary?.inputTokens ?? 0),
|
|
107
|
-
outputTokens: Number(summary?.outputTokens ?? 0),
|
|
108
|
-
totalTokens: Number(summary?.inputTokens ?? 0) + Number(summary?.outputTokens ?? 0),
|
|
109
|
-
cacheReadTokens: Number(summary?.cacheRead ?? 0),
|
|
110
|
-
cacheWriteTokens: Number(summary?.cacheWrite ?? 0),
|
|
111
|
-
reasoningTokens: Number(summary?.reasoning ?? 0),
|
|
112
|
-
averageLatencyMs: avg,
|
|
113
|
-
p95LatencyMs: p95,
|
|
114
|
-
averageTtftMs: avgTtft,
|
|
115
|
-
p95TtftMs: p95Ttft,
|
|
167
|
+
inputTokens: Number(summary?.inputTokens ?? 0) + aggregateTotals.input,
|
|
168
|
+
outputTokens: Number(summary?.outputTokens ?? 0) + aggregateTotals.output,
|
|
169
|
+
totalTokens: Number(summary?.inputTokens ?? 0) + aggregateTotals.input + Number(summary?.outputTokens ?? 0) + aggregateTotals.output,
|
|
170
|
+
cacheReadTokens: Number(summary?.cacheRead ?? 0) + aggregateTotals.cacheRead,
|
|
171
|
+
cacheWriteTokens: Number(summary?.cacheWrite ?? 0) + aggregateTotals.cacheWrite,
|
|
172
|
+
reasoningTokens: Number(summary?.reasoning ?? 0) + aggregateTotals.reasoning,
|
|
173
|
+
averageLatencyMs: aggregateOnly ? (aggregateTotals.latencySum + latencies.reduce((a, b) => a + b, 0)) / (aggregateTotals.latencyCount + latencies.length || 1) : avg,
|
|
174
|
+
p95LatencyMs: percentilesAvailable ? p95 : null,
|
|
175
|
+
averageTtftMs: aggregateOnly ? (aggregateTotals.ttftSum + ttfts.reduce((a, b) => a + b, 0)) / (aggregateTotals.ttftCount + ttfts.length || 1) : avgTtft,
|
|
176
|
+
p95TtftMs: percentilesAvailable ? p95Ttft : null,
|
|
116
177
|
cacheHitRate,
|
|
117
|
-
gatewayCacheHitRate: total ? Number(summary?.gatewayCacheHits ?? 0) / total : 0,
|
|
118
|
-
fallbackRate: total ? Number(summary?.fallbacks ?? 0) / total : 0,
|
|
178
|
+
gatewayCacheHitRate: total ? (Number(summary?.gatewayCacheHits ?? 0) + aggregateTotals.gateway) / total : 0,
|
|
179
|
+
fallbackRate: total ? (Number(summary?.fallbacks ?? 0) + aggregateTotals.fallback) / total : 0,
|
|
119
180
|
};
|
|
120
181
|
// Time-bucketed series
|
|
121
182
|
const bucketExpr = bucket === 'hour' ? sql `strftime('%Y-%m-%dT%H:00:00Z', created_at)` : sql `strftime('%Y-%m-%dT00:00:00Z', created_at)`;
|
|
@@ -130,10 +191,18 @@ export async function registerStatsRoutes(app) {
|
|
|
130
191
|
cacheRead: sql `COALESCE(SUM(cache_read_tokens),0)`,
|
|
131
192
|
})
|
|
132
193
|
.from(schema.requests)
|
|
133
|
-
.where(
|
|
194
|
+
.where(rawConds)
|
|
134
195
|
.groupBy(bucketExpr)
|
|
135
196
|
.orderBy(bucketExpr)
|
|
136
197
|
.all();
|
|
198
|
+
const seriesMap = new Map(seriesRows.map((r) => [r.t, { t: r.t, requests: Number(r.requests), errors: Number(r.errors), inputTokens: Number(r.inputTokens), outputTokens: Number(r.outputTokens), avgLatency: Number(r.avgLatency ?? 0), cacheRead: Number(r.cacheRead ?? 0) }]));
|
|
199
|
+
for (const r of aggregateRows.filter((x) => aggregateDays.includes(x.day))) {
|
|
200
|
+
const t = `${r.day}T00:00:00Z`;
|
|
201
|
+
const old = seriesMap.get(t) ?? { t, requests: 0, errors: 0, inputTokens: 0, outputTokens: 0, avgLatency: 0, cacheRead: 0 };
|
|
202
|
+
const oldSuccesses = old.requests - old.errors;
|
|
203
|
+
const count = oldSuccesses + r.latencySuccessCount;
|
|
204
|
+
seriesMap.set(t, { t, requests: old.requests + r.requests, errors: old.errors + r.failedRequests, inputTokens: old.inputTokens + r.inputTokens, outputTokens: old.outputTokens + r.outputTokens, avgLatency: count ? (old.avgLatency * oldSuccesses + r.latencySuccessSumMs) / count : 0, cacheRead: old.cacheRead + r.cacheReadTokens });
|
|
205
|
+
}
|
|
137
206
|
// Top models
|
|
138
207
|
const topModels = db
|
|
139
208
|
.select({
|
|
@@ -143,7 +212,7 @@ export async function registerStatsRoutes(app) {
|
|
|
143
212
|
tokens: sql `COALESCE(SUM(total_tokens),0)`,
|
|
144
213
|
})
|
|
145
214
|
.from(schema.requests)
|
|
146
|
-
.where(
|
|
215
|
+
.where(rawConds)
|
|
147
216
|
.groupBy(schema.requests.finalModelId)
|
|
148
217
|
// Order by the aggregate expression itself: SQLite rejects ORDER BY on a
|
|
149
218
|
// quoted select alias ("no such column: c").
|
|
@@ -152,6 +221,23 @@ export async function registerStatsRoutes(app) {
|
|
|
152
221
|
.all();
|
|
153
222
|
const models = db.select().from(schema.models).all();
|
|
154
223
|
const modelMap = new Map(models.map((m) => [m.id, m]));
|
|
224
|
+
const mergedModels = new Map();
|
|
225
|
+
for (const r of topModels) {
|
|
226
|
+
const id = r.modelId ? modelMap.get(r.modelId)?.publicModelId ?? r.modelId : 'unknown';
|
|
227
|
+
const v = mergedModels.get(id) ?? { requests: 0, errors: 0, tokens: 0 };
|
|
228
|
+
v.requests += Number(r.c);
|
|
229
|
+
v.errors += Number(r.err);
|
|
230
|
+
v.tokens += Number(r.tokens);
|
|
231
|
+
mergedModels.set(id, v);
|
|
232
|
+
}
|
|
233
|
+
for (const r of aggregateRows.filter((x) => aggregateDays.includes(x.day))) {
|
|
234
|
+
const id = r.modelId ? modelMap.get(r.modelId)?.publicModelId ?? r.modelId : 'unknown';
|
|
235
|
+
const v = mergedModels.get(id) ?? { requests: 0, errors: 0, tokens: 0 };
|
|
236
|
+
v.requests += r.requests;
|
|
237
|
+
v.errors += r.failedRequests;
|
|
238
|
+
v.tokens += r.inputTokens + r.outputTokens;
|
|
239
|
+
mergedModels.set(id, v);
|
|
240
|
+
}
|
|
155
241
|
const topKeys = db
|
|
156
242
|
.select({
|
|
157
243
|
apiKeyId: schema.requests.apiKeyId,
|
|
@@ -159,13 +245,29 @@ export async function registerStatsRoutes(app) {
|
|
|
159
245
|
tokens: sql `COALESCE(SUM(total_tokens),0)`,
|
|
160
246
|
})
|
|
161
247
|
.from(schema.requests)
|
|
162
|
-
.where(
|
|
248
|
+
.where(rawConds)
|
|
163
249
|
.groupBy(schema.requests.apiKeyId)
|
|
164
250
|
.orderBy(desc(sql `COUNT(*)`))
|
|
165
251
|
.limit(10)
|
|
166
252
|
.all();
|
|
167
253
|
const keys = db.select().from(schema.apiKeys).all();
|
|
168
254
|
const keyMap = new Map(keys.map((k) => [k.id, k]));
|
|
255
|
+
const mergedKeys = new Map();
|
|
256
|
+
for (const r of topKeys) {
|
|
257
|
+
const id = r.apiKeyId ? keyMap.get(r.apiKeyId)?.name ?? 'deleted' : 'unknown';
|
|
258
|
+
const v = mergedKeys.get(id) ?? { requests: 0, tokens: 0 };
|
|
259
|
+
v.requests += Number(r.c);
|
|
260
|
+
v.tokens += Number(r.tokens);
|
|
261
|
+
mergedKeys.set(id, v);
|
|
262
|
+
}
|
|
263
|
+
const keyAggregateRows = db.select().from(schema.dailyApiKeyStats).where(and(gte(schema.dailyApiKeyStats.day, fromIso.slice(0, 10)), lte(schema.dailyApiKeyStats.day, toIso.slice(0, 10)))).all().filter((r) => aggregateDays.includes(r.day));
|
|
264
|
+
for (const r of keyAggregateRows) {
|
|
265
|
+
const id = r.apiKeyId ? keyMap.get(r.apiKeyId)?.name ?? 'deleted' : 'unknown';
|
|
266
|
+
const v = mergedKeys.get(id) ?? { requests: 0, tokens: 0 };
|
|
267
|
+
v.requests += r.requests;
|
|
268
|
+
v.tokens += r.totalTokens;
|
|
269
|
+
mergedKeys.set(id, v);
|
|
270
|
+
}
|
|
169
271
|
// ─── Previous period (for delta badges in summary cards) ───────────────
|
|
170
272
|
const durationMs = to.getTime() - from.getTime();
|
|
171
273
|
const prevFrom = new Date(from.getTime() - durationMs).toISOString();
|
|
@@ -200,43 +302,44 @@ export async function registerStatsRoutes(app) {
|
|
|
200
302
|
.groupBy(schema.models.providerId)
|
|
201
303
|
.all();
|
|
202
304
|
const countMap = new Map(modelCounts.map((r) => [r.providerId, Number(r.c)]));
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
305
|
+
const providerTotals = new Map(providerAggs.map((r) => [r.providerId, { total: Number(r.total), errors: Number(r.err), latencySum: Number(r.avgLat) * (Number(r.total) - Number(r.err)), latencyCount: Number(r.total) - Number(r.err) }]));
|
|
306
|
+
const providerAggregateRows = db.select().from(schema.dailyProviderStats).where(and(gte(schema.dailyProviderStats.day, fromIso.slice(0, 10)), lte(schema.dailyProviderStats.day, toIso.slice(0, 10)))).all().filter((r) => aggregateDays.includes(r.day));
|
|
307
|
+
for (const r of providerAggregateRows) {
|
|
308
|
+
const a = providerTotals.get(r.providerId) ?? { total: 0, errors: 0, latencySum: 0, latencyCount: 0 };
|
|
309
|
+
a.total += r.attempts;
|
|
310
|
+
a.errors += r.failedAttempts;
|
|
311
|
+
a.latencySum += r.latencySuccessSumMs;
|
|
312
|
+
a.latencyCount += r.latencySuccessCount;
|
|
313
|
+
providerTotals.set(r.providerId, a);
|
|
314
|
+
}
|
|
315
|
+
const providers = [...providerTotals].map(([providerId, r]) => {
|
|
316
|
+
const p = providerLookup.get(providerId);
|
|
206
317
|
if (!p)
|
|
207
318
|
return null;
|
|
208
|
-
const total =
|
|
319
|
+
const total = r.total;
|
|
209
320
|
return {
|
|
210
|
-
id:
|
|
321
|
+
id: providerId,
|
|
211
322
|
name: p.name,
|
|
212
323
|
slug: p.slug,
|
|
213
324
|
health: p.healthState,
|
|
214
325
|
enabled: p.enabled,
|
|
215
|
-
modelCount: countMap.get(
|
|
326
|
+
modelCount: countMap.get(providerId) ?? 0,
|
|
216
327
|
requests: total,
|
|
217
|
-
errorRate: total > 0 ?
|
|
218
|
-
avgLatencyMs:
|
|
328
|
+
errorRate: total > 0 ? r.errors / total : 0,
|
|
329
|
+
avgLatencyMs: r.latencyCount ? r.latencySum / r.latencyCount : 0,
|
|
219
330
|
};
|
|
220
331
|
})
|
|
221
332
|
.filter((x) => x !== null)
|
|
222
333
|
.sort((a, b) => b.requests - a.requests);
|
|
223
334
|
const range = { from: fromIso, to: toIso, bucket };
|
|
224
335
|
return {
|
|
336
|
+
percentilesAvailable,
|
|
225
337
|
range,
|
|
226
338
|
summary: statsSummary,
|
|
227
339
|
previous: previousSummary,
|
|
228
|
-
series:
|
|
229
|
-
topModels:
|
|
230
|
-
|
|
231
|
-
requests: Number(r.c),
|
|
232
|
-
errorRate: Number(r.c) > 0 ? Number(r.err) / Number(r.c) : 0,
|
|
233
|
-
totalTokens: Number(r.tokens),
|
|
234
|
-
})),
|
|
235
|
-
topApiKeys: topKeys.map((r) => ({
|
|
236
|
-
name: r.apiKeyId ? keyMap.get(r.apiKeyId)?.name ?? 'deleted' : 'unknown',
|
|
237
|
-
requests: Number(r.c),
|
|
238
|
-
totalTokens: Number(r.tokens),
|
|
239
|
-
})),
|
|
340
|
+
series: [...seriesMap.values()].sort((a, b) => a.t.localeCompare(b.t)),
|
|
341
|
+
topModels: [...mergedModels].map(([publicId, v]) => ({ publicId, requests: v.requests, errorRate: v.requests ? v.errors / v.requests : 0, totalTokens: v.tokens })).sort((a, b) => b.requests - a.requests).slice(0, 10),
|
|
342
|
+
topApiKeys: [...mergedKeys].map(([name, v]) => ({ name, requests: v.requests, totalTokens: v.tokens })).sort((a, b) => b.requests - a.requests).slice(0, 10),
|
|
240
343
|
recent,
|
|
241
344
|
providers,
|
|
242
345
|
};
|
|
@@ -247,6 +350,13 @@ export async function registerStatsRoutes(app) {
|
|
|
247
350
|
* incremental aggregation). Returns counts sufficient to derive ratios.
|
|
248
351
|
*/
|
|
249
352
|
function buildSummary(db, fromIso, toIso) {
|
|
353
|
+
// For previous period, use the same aggregate/raw partition as the primary range.
|
|
354
|
+
const days = db.select().from(schema.dailyModelStats).where(and(gte(schema.dailyModelStats.day, fromIso.slice(0, 10)), lte(schema.dailyModelStats.day, toIso.slice(0, 10)))).all();
|
|
355
|
+
// Same rule as the primary range: a day only needs its aggregate when its raw detail is gone.
|
|
356
|
+
const daySet = aggregateRequiredDays(db, days).filter((day) => day < new Date().toISOString().slice(0, 10) && `${day}T00:00:00.000Z` >= fromIso && `${day}T23:59:59.999Z` <= toIso);
|
|
357
|
+
const aggregates = days.filter((x) => daySet.includes(x.day));
|
|
358
|
+
const rawOnly = daySet.length ? and(gte(schema.requests.createdAt, fromIso), lte(schema.requests.createdAt, toIso), sql `substr(created_at,1,10) NOT IN (${sql.join(daySet.map((day) => sql `${day}`), sql `, `)})`) : and(gte(schema.requests.createdAt, fromIso), lte(schema.requests.createdAt, toIso));
|
|
359
|
+
const agg = aggregates.reduce((a, x) => ({ total: a.total + x.requests, success: a.success + x.successfulRequests, failed: a.failed + x.failedRequests, input: a.input + x.inputTokens, output: a.output + x.outputTokens, cacheRead: a.cacheRead + x.cacheReadTokens, cacheWrite: a.cacheWrite + x.cacheWriteTokens, reasoning: a.reasoning + x.reasoningTokens, gateway: a.gateway + x.gatewayCacheHits, fallback: a.fallback + x.fallbackRequests, latency: a.latency + x.latencySuccessSumMs, latencyCount: a.latencyCount + x.latencySuccessCount, ttft: a.ttft + x.ttftSumMs, ttftCount: a.ttftCount + x.ttftCount }), { total: 0, success: 0, failed: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, gateway: 0, fallback: 0, latency: 0, latencyCount: 0, ttft: 0, ttftCount: 0 });
|
|
250
360
|
const conds = [gte(schema.requests.createdAt, fromIso), lte(schema.requests.createdAt, toIso)];
|
|
251
361
|
const r = db
|
|
252
362
|
.select({
|
|
@@ -258,37 +368,42 @@ function buildSummary(db, fromIso, toIso) {
|
|
|
258
368
|
cacheRead: sql `COALESCE(SUM(cache_read_tokens),0)`,
|
|
259
369
|
cacheWrite: sql `COALESCE(SUM(cache_write_tokens),0)`,
|
|
260
370
|
reasoning: sql `COALESCE(SUM(reasoning_tokens),0)`,
|
|
261
|
-
|
|
262
|
-
|
|
371
|
+
latencySum: sql `COALESCE(SUM(CASE WHEN success=1 THEN total_latency_ms ELSE 0 END),0)`,
|
|
372
|
+
latencyCount: sql `SUM(CASE WHEN success=1 AND total_latency_ms > 0 THEN 1 ELSE 0 END)`,
|
|
373
|
+
ttftSum: sql `COALESCE(SUM(CASE WHEN success=1 THEN COALESCE(ttft_ms,0) ELSE 0 END),0)`,
|
|
374
|
+
ttftCount: sql `SUM(CASE WHEN success=1 AND ttft_ms IS NOT NULL THEN 1 ELSE 0 END)`,
|
|
263
375
|
gatewayCacheHits: sql `SUM(CASE WHEN gateway_cache_hit=1 THEN 1 ELSE 0 END)`,
|
|
264
376
|
fallbacks: sql `SUM(CASE WHEN attempts_count > 1 THEN 1 ELSE 0 END)`,
|
|
265
377
|
})
|
|
266
378
|
.from(schema.requests)
|
|
267
|
-
.where(
|
|
379
|
+
.where(rawOnly)
|
|
268
380
|
.get();
|
|
269
|
-
const total = Number(r?.total ?? 0);
|
|
270
|
-
const success = Number(r?.success ?? 0);
|
|
271
|
-
const
|
|
272
|
-
const inputTokens = Number(r?.inputTokens ?? 0);
|
|
273
|
-
const
|
|
381
|
+
const total = Number(r?.total ?? 0) + agg.total;
|
|
382
|
+
const success = Number(r?.success ?? 0) + agg.success;
|
|
383
|
+
const failed = Number(r?.failed ?? 0) + agg.failed;
|
|
384
|
+
const inputTokens = Number(r?.inputTokens ?? 0) + agg.input;
|
|
385
|
+
const outputTokens = Number(r?.outputTokens ?? 0) + agg.output;
|
|
386
|
+
const latencyCount = Number(r?.latencyCount ?? 0) + agg.latencyCount;
|
|
387
|
+
const ttftCount = Number(r?.ttftCount ?? 0) + agg.ttftCount;
|
|
388
|
+
const cacheHitRate = daySet.length ? null : cacheHitRateFor(db, conds);
|
|
274
389
|
return {
|
|
275
390
|
totalRequests: total,
|
|
276
391
|
successfulRequests: success,
|
|
277
|
-
failedRequests:
|
|
392
|
+
failedRequests: failed,
|
|
278
393
|
successRate: total ? success / total : 0,
|
|
279
394
|
inputTokens,
|
|
280
|
-
outputTokens
|
|
281
|
-
totalTokens: inputTokens +
|
|
282
|
-
cacheReadTokens: cacheRead,
|
|
283
|
-
cacheWriteTokens: Number(r?.cacheWrite ?? 0),
|
|
284
|
-
reasoningTokens: Number(r?.reasoning ?? 0),
|
|
285
|
-
averageLatencyMs: Number(r?.
|
|
286
|
-
p95LatencyMs:
|
|
287
|
-
averageTtftMs: (r?.
|
|
395
|
+
outputTokens,
|
|
396
|
+
totalTokens: inputTokens + outputTokens,
|
|
397
|
+
cacheReadTokens: Number(r?.cacheRead ?? 0) + agg.cacheRead,
|
|
398
|
+
cacheWriteTokens: Number(r?.cacheWrite ?? 0) + agg.cacheWrite,
|
|
399
|
+
reasoningTokens: Number(r?.reasoning ?? 0) + agg.reasoning,
|
|
400
|
+
averageLatencyMs: latencyCount ? (Number(r?.latencySum ?? 0) + agg.latency) / latencyCount : 0,
|
|
401
|
+
p95LatencyMs: null,
|
|
402
|
+
averageTtftMs: ttftCount ? (Number(r?.ttftSum ?? 0) + agg.ttft) / ttftCount : null,
|
|
288
403
|
p95TtftMs: null,
|
|
289
404
|
cacheHitRate,
|
|
290
|
-
gatewayCacheHitRate: total ? Number(r?.gatewayCacheHits ?? 0) / total : 0,
|
|
291
|
-
fallbackRate: total ? Number(r?.fallbacks ?? 0) / total : 0,
|
|
405
|
+
gatewayCacheHitRate: total ? (Number(r?.gatewayCacheHits ?? 0) + agg.gateway) / total : 0,
|
|
406
|
+
fallbackRate: total ? (Number(r?.fallbacks ?? 0) + agg.fallback) / total : 0,
|
|
292
407
|
};
|
|
293
408
|
}
|
|
294
409
|
function percentile(sorted, p) {
|