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.
@@ -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
- const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
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: new Date(now.getTime() - 7 * 24 * 3600 * 1000), to: now, bucket: 'day' };
25
+ return { from: utcDayStart(now, -6), to: now, bucket: 'day' };
15
26
  },
16
27
  '30d': () => {
17
28
  const now = new Date();
18
- return { from: new Date(now.getTime() - 30 * 24 * 3600 * 1000), to: now, bucket: 'day' };
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 from = q.from ? new Date(q.from) : new Date(Date.now() - 7 * 24 * 3600 * 1000);
53
- const to = q.to ? new Date(q.to) : new Date();
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(and(...conds))
139
+ .where(rawConds)
80
140
  .get();
81
- const cacheHitRate = cacheHitRateFor(db, conds);
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(...conds, eq(schema.requests.success, true)))
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(...conds, sql `ttft_ms IS NOT NULL`))
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(and(...conds))
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(and(...conds))
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(and(...conds))
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 providers = providerAggs
204
- .map((r) => {
205
- const p = providerLookup.get(r.providerId);
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 = Number(r.total);
319
+ const total = r.total;
209
320
  return {
210
- id: r.providerId,
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(r.providerId) ?? 0,
326
+ modelCount: countMap.get(providerId) ?? 0,
216
327
  requests: total,
217
- errorRate: total > 0 ? Number(r.err) / total : 0,
218
- avgLatencyMs: Number(r.avgLat),
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: seriesRows.map((r) => ({ 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) })),
229
- topModels: topModels.map((r) => ({
230
- publicId: r.modelId ? modelMap.get(r.modelId)?.publicModelId ?? r.modelId : 'unknown',
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
- avgLatency: sql `COALESCE(AVG(CASE WHEN success=1 THEN total_latency_ms END),0)`,
262
- avgTtft: sql `AVG(CASE WHEN success=1 AND ttft_ms IS NOT NULL THEN ttft_ms END)`,
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(and(...conds))
379
+ .where(rawOnly)
268
380
  .get();
269
- const total = Number(r?.total ?? 0);
270
- const success = Number(r?.success ?? 0);
271
- const cacheRead = Number(r?.cacheRead ?? 0);
272
- const inputTokens = Number(r?.inputTokens ?? 0);
273
- const cacheHitRate = cacheHitRateFor(db, conds);
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: Number(r?.failed ?? 0),
392
+ failedRequests: failed,
278
393
  successRate: total ? success / total : 0,
279
394
  inputTokens,
280
- outputTokens: Number(r?.outputTokens ?? 0),
281
- totalTokens: inputTokens + Number(r?.outputTokens ?? 0),
282
- cacheReadTokens: cacheRead,
283
- cacheWriteTokens: Number(r?.cacheWrite ?? 0),
284
- reasoningTokens: Number(r?.reasoning ?? 0),
285
- averageLatencyMs: Number(r?.avgLatency ?? 0),
286
- p95LatencyMs: 0, // caller fills via percentile query
287
- averageTtftMs: (r?.avgTtft ?? null),
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) {