fest-gateway 0.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.
Files changed (161) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -0
  3. package/dist/cli/claude.js +326 -0
  4. package/dist/cli/claude.js.map +1 -0
  5. package/dist/cli/config.js +59 -0
  6. package/dist/cli/config.js.map +1 -0
  7. package/dist/cli/login.js +130 -0
  8. package/dist/cli/login.js.map +1 -0
  9. package/dist/cli/logout.js +20 -0
  10. package/dist/cli/logout.js.map +1 -0
  11. package/dist/cli/whoami.js +34 -0
  12. package/dist/cli/whoami.js.map +1 -0
  13. package/dist/server/adapters/anthropic.js +74 -0
  14. package/dist/server/adapters/anthropic.js.map +1 -0
  15. package/dist/server/adapters/fireworks.js +54 -0
  16. package/dist/server/adapters/fireworks.js.map +1 -0
  17. package/dist/server/adapters/index.js +16 -0
  18. package/dist/server/adapters/index.js.map +1 -0
  19. package/dist/server/adapters/registry.js +38 -0
  20. package/dist/server/adapters/registry.js.map +1 -0
  21. package/dist/server/adapters/rewrite.js +152 -0
  22. package/dist/server/adapters/rewrite.js.map +1 -0
  23. package/dist/server/adapters/url.js +24 -0
  24. package/dist/server/adapters/url.js.map +1 -0
  25. package/dist/server/api/auth.js +213 -0
  26. package/dist/server/api/auth.js.map +1 -0
  27. package/dist/server/api/live.js +108 -0
  28. package/dist/server/api/live.js.map +1 -0
  29. package/dist/server/api/models.js +129 -0
  30. package/dist/server/api/models.js.map +1 -0
  31. package/dist/server/api/oauth.js +300 -0
  32. package/dist/server/api/oauth.js.map +1 -0
  33. package/dist/server/api/routes.js +164 -0
  34. package/dist/server/api/routes.js.map +1 -0
  35. package/dist/server/api/routing-view.js +47 -0
  36. package/dist/server/api/routing-view.js.map +1 -0
  37. package/dist/server/auth/accounts.js +111 -0
  38. package/dist/server/auth/accounts.js.map +1 -0
  39. package/dist/server/auth/gateway-401.js +85 -0
  40. package/dist/server/auth/gateway-401.js.map +1 -0
  41. package/dist/server/auth/guard.js +74 -0
  42. package/dist/server/auth/guard.js.map +1 -0
  43. package/dist/server/auth/oauth.js +43 -0
  44. package/dist/server/auth/oauth.js.map +1 -0
  45. package/dist/server/auth/password.js +104 -0
  46. package/dist/server/auth/password.js.map +1 -0
  47. package/dist/server/auth/posture.js +145 -0
  48. package/dist/server/auth/posture.js.map +1 -0
  49. package/dist/server/auth/session.js +161 -0
  50. package/dist/server/auth/session.js.map +1 -0
  51. package/dist/server/bin/fest.js +589 -0
  52. package/dist/server/bin/fest.js.map +1 -0
  53. package/dist/server/config.js +109 -0
  54. package/dist/server/config.js.map +1 -0
  55. package/dist/server/credentials/provider.js +70 -0
  56. package/dist/server/credentials/provider.js.map +1 -0
  57. package/dist/server/credentials/resolve.js +89 -0
  58. package/dist/server/credentials/resolve.js.map +1 -0
  59. package/dist/server/http/body.js +66 -0
  60. package/dist/server/http/body.js.map +1 -0
  61. package/dist/server/http/errors.js +144 -0
  62. package/dist/server/http/errors.js.map +1 -0
  63. package/dist/server/http/headers.js +189 -0
  64. package/dist/server/http/headers.js.map +1 -0
  65. package/dist/server/http/pipe.js +97 -0
  66. package/dist/server/http/pipe.js.map +1 -0
  67. package/dist/server/http/server.js +226 -0
  68. package/dist/server/http/server.js.map +1 -0
  69. package/dist/server/http/sse.js +167 -0
  70. package/dist/server/http/sse.js.map +1 -0
  71. package/dist/server/http/static.js +138 -0
  72. package/dist/server/http/static.js.map +1 -0
  73. package/dist/server/ingest/live-bus.js +67 -0
  74. package/dist/server/ingest/live-bus.js.map +1 -0
  75. package/dist/server/ingest/sink.js +141 -0
  76. package/dist/server/ingest/sink.js.map +1 -0
  77. package/dist/server/log.js +31 -0
  78. package/dist/server/log.js.map +1 -0
  79. package/dist/server/pipeline/count-tokens.js +48 -0
  80. package/dist/server/pipeline/count-tokens.js.map +1 -0
  81. package/dist/server/pipeline/dispatch.js +138 -0
  82. package/dist/server/pipeline/dispatch.js.map +1 -0
  83. package/dist/server/pipeline/passthrough.js +392 -0
  84. package/dist/server/pipeline/passthrough.js.map +1 -0
  85. package/dist/server/pipeline/preflight.js +66 -0
  86. package/dist/server/pipeline/preflight.js.map +1 -0
  87. package/dist/server/pipeline/record.js +49 -0
  88. package/dist/server/pipeline/record.js.map +1 -0
  89. package/dist/server/pipeline/substitute.js +287 -0
  90. package/dist/server/pipeline/substitute.js.map +1 -0
  91. package/dist/server/routes/resolve.js +176 -0
  92. package/dist/server/routes/resolve.js.map +1 -0
  93. package/dist/server/routes/table.js +247 -0
  94. package/dist/server/routes/table.js.map +1 -0
  95. package/dist/server/routes/watch.js +101 -0
  96. package/dist/server/routes/watch.js.map +1 -0
  97. package/dist/server/secret/fingerprint.js +107 -0
  98. package/dist/server/secret/fingerprint.js.map +1 -0
  99. package/dist/server/secret/non-persistable.js +69 -0
  100. package/dist/server/secret/non-persistable.js.map +1 -0
  101. package/dist/server/store/audit.js +53 -0
  102. package/dist/server/store/audit.js.map +1 -0
  103. package/dist/server/store/bootstrap.js +53 -0
  104. package/dist/server/store/bootstrap.js.map +1 -0
  105. package/dist/server/store/db.js +157 -0
  106. package/dist/server/store/db.js.map +1 -0
  107. package/dist/server/store/ids.js +14 -0
  108. package/dist/server/store/ids.js.map +1 -0
  109. package/dist/server/store/migrations/001-init.sql +213 -0
  110. package/dist/server/store/migrations/002-feed-and-error-indexes.sql +26 -0
  111. package/dist/server/store/migrations/003-routing.sql +42 -0
  112. package/dist/server/store/migrations/004-admin-auth.sql +72 -0
  113. package/dist/server/store/migrations/005-notional-cost.sql +28 -0
  114. package/dist/server/store/queries.js +904 -0
  115. package/dist/server/store/queries.js.map +1 -0
  116. package/dist/server/store/retention.js +79 -0
  117. package/dist/server/store/retention.js.map +1 -0
  118. package/dist/server/store/seed.js +203 -0
  119. package/dist/server/store/seed.js.map +1 -0
  120. package/dist/server/store/tokens.js +133 -0
  121. package/dist/server/store/tokens.js.map +1 -0
  122. package/dist/server/store/write.js +330 -0
  123. package/dist/server/store/write.js.map +1 -0
  124. package/dist/server/usage/accumulator.js +209 -0
  125. package/dist/server/usage/accumulator.js.map +1 -0
  126. package/dist/server/usage/cost.js +57 -0
  127. package/dist/server/usage/cost.js.map +1 -0
  128. package/dist/server/usage/prices/catalog.js +136 -0
  129. package/dist/server/usage/prices/catalog.js.map +1 -0
  130. package/dist/server/usage/prices/refresh.js +125 -0
  131. package/dist/server/usage/prices/refresh.js.map +1 -0
  132. package/dist/server/usage/prices/snapshot.json.gz +0 -0
  133. package/dist/server/usage/prices/table.js +102 -0
  134. package/dist/server/usage/prices/table.js.map +1 -0
  135. package/dist/server/usage/pricing.js +103 -0
  136. package/dist/server/usage/pricing.js.map +1 -0
  137. package/dist/shared/api.js +19 -0
  138. package/dist/shared/api.js.map +1 -0
  139. package/dist/shared/base-models.js +28 -0
  140. package/dist/shared/base-models.js.map +1 -0
  141. package/dist/shared/demotion-vars.js +17 -0
  142. package/dist/shared/demotion-vars.js.map +1 -0
  143. package/dist/shared/series.js +50 -0
  144. package/dist/shared/series.js.map +1 -0
  145. package/dist/shared/types.js +40 -0
  146. package/dist/shared/types.js.map +1 -0
  147. package/dist/web/dist/assets/index-8HlKsJqt.css +2 -0
  148. package/dist/web/dist/assets/index-DQ7fyPVm.js +56 -0
  149. package/dist/web/dist/assets/index-DQ7fyPVm.js.map +1 -0
  150. package/dist/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  151. package/dist/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  152. package/dist/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  153. package/dist/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  154. package/dist/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  155. package/dist/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  156. package/dist/web/dist/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
  157. package/dist/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  158. package/dist/web/dist/index.html +44 -0
  159. package/dist/web/dist/nearform-icon.svg +4 -0
  160. package/package.json +82 -0
  161. package/routes.example.json +22 -0
@@ -0,0 +1,904 @@
1
+ /**
2
+ * The read layer for the dashboard.
3
+ *
4
+ * ── Visibility is enforced HERE, and nowhere else ────────────────────────────
5
+ *
6
+ * Every exported function takes `Scope` as its first parameter after the store,
7
+ * and every SQL statement in this file filters on `org_id`. There is no view,
8
+ * no route middleware and no ORM layer that "also" checks tenancy — because the
9
+ * moment there are two places a check can live, a future screen will be written
10
+ * against the one that does not check, and it will leak another org's traffic.
11
+ *
12
+ * The same argument applies one level down: a `member` scope is constrained to
13
+ * its own `user_id` by `scopeClause()` / `rollupSlice()`, once, rather than by
14
+ * each query remembering to.
15
+ *
16
+ * ── The rollup cube, which WILL corrupt your numbers if you skim it ─────────
17
+ *
18
+ * `usage_hourly` is a CUBE, not a flat fact table. For every request the writer
19
+ * (server/store/write.ts) upserts up to four keys:
20
+ *
21
+ * (user, model) the specific cell
22
+ * (user, '') one developer, all models
23
+ * ('', model) all developers, one model
24
+ * ('', '') org totals
25
+ *
26
+ * de-duplicated, so an unattributed request or one with no resolved model
27
+ * contributes exactly one increment rather than two or four.
28
+ *
29
+ * Two consequences, both easy to get wrong:
30
+ *
31
+ * 1. A "bucket" is NOT one row. The primary key also carries
32
+ * `credential_origin` and `cost_basis`, which are ALWAYS concrete — never
33
+ * `''`. So `('', '')` for one hour is one row per (origin, basis) pair
34
+ * actually observed. Every read must `SUM(...)` across them. A
35
+ * `SELECT ... WHERE user_id = '' AND served_model = '' LIMIT 1` would
36
+ * report a single origin's slice and look entirely plausible.
37
+ * `rollupSlice()` exists so no query invents its own pinning, and no query
38
+ * in this file ever pins `credential_origin` or `cost_basis`.
39
+ *
40
+ * 2. Summing across grains double-counts. An org total must pin
41
+ * `user_id = '' AND served_model = ''`; a per-user breakdown must pin
42
+ * `served_model = ''` and take `user_id <> ''`. Dropping either pin adds
43
+ * the same traffic in two or three times.
44
+ *
45
+ * 3. `''` is overloaded: on `user_id` it means both "all users" and
46
+ * "unattributed", and on `served_model` both "all models" and "never
47
+ * resolved". From the rollup alone those are indistinguishable. So the
48
+ * unattributed slice — which an admin genuinely needs to see, because it
49
+ * means someone is using Fest without an identity token — is recovered as a
50
+ * RESIDUAL: org total minus the sum of the attributed rows. That identity
51
+ * holds exactly because of the writer's de-duplication. On the raw path it
52
+ * comes straight from `user_id IS NULL`. Note the two tables use different
53
+ * representations deliberately (`NULL` in `requests`, `''` in the rollup,
54
+ * whose columns are NOT NULL and part of a primary key); both are handled
55
+ * and both surface as `userId: ''`.
56
+ */
57
+ import { NON_ERROR_STATUSES } from "../../shared/types.js";
58
+ import { cacheHitRatio } from "../usage/pricing.js";
59
+ /**
60
+ * The member's own `user_id`, or null for an org-wide scope.
61
+ *
62
+ * Fails closed: a `member` scope with no `userId` is a bug in the caller's
63
+ * session handling, and answering it as though it were an admin would be the
64
+ * exact leak this module exists to prevent.
65
+ */
66
+ function memberUserId(scope) {
67
+ if (scope.role !== "member")
68
+ return null;
69
+ if (!scope.userId) {
70
+ throw new Error("member scope requires a userId; refusing to widen to org-wide");
71
+ }
72
+ return scope.userId;
73
+ }
74
+ /**
75
+ * The tenancy gate for raw `requests`.
76
+ *
77
+ * `alias` is the table alias so the fragment can be spliced into a join. For a
78
+ * member we emit `user_id = ?`, which in SQL also excludes NULL rows — correct
79
+ * and deliberate: an unattributed request is not provably the member's, and we
80
+ * never guess who it was. An admin sees those rows; the member does not.
81
+ */
82
+ function scopeClause(scope, alias) {
83
+ const params = [scope.orgId];
84
+ let sql = `${alias}.org_id = ?`;
85
+ const uid = memberUserId(scope);
86
+ if (uid !== null) {
87
+ sql += ` AND ${alias}.user_id = ?`;
88
+ params.push(uid);
89
+ }
90
+ return { sql, params };
91
+ }
92
+ /**
93
+ * THE single place that pins the rollup cube's dimensions.
94
+ *
95
+ * Deliberately never mentions `credential_origin` or `cost_basis`: those are
96
+ * always concrete in the cube, so a query must aggregate across them rather
97
+ * than pin them. That is why this returns only a WHERE fragment and the callers
98
+ * all use `SUM()`.
99
+ */
100
+ function rollupSlice(scope, grain) {
101
+ const params = [scope.orgId];
102
+ let sql = "h.org_id = ?";
103
+ const uid = memberUserId(scope);
104
+ if (uid !== null) {
105
+ // A member's own cell exists at both (uid, '') and (uid, model), so pinning
106
+ // their id works at either grain.
107
+ sql += " AND h.user_id = ?";
108
+ params.push(uid);
109
+ }
110
+ else if (grain.byUser) {
111
+ sql += " AND h.user_id <> ''";
112
+ }
113
+ else {
114
+ sql += " AND h.user_id = ''";
115
+ }
116
+ if (grain.byModel)
117
+ sql += " AND h.served_model <> ''";
118
+ else
119
+ sql += " AND h.served_model = ''";
120
+ return { sql, params };
121
+ }
122
+ /**
123
+ * How recent a range has to be before we bypass the rollups and read raw rows.
124
+ *
125
+ * Two hours, because the hourly rollup lags BY DEFINITION: the current hour's
126
+ * row is still accumulating, and the metering writer flushes in batches, so the
127
+ * most recent bucket is always incomplete. A "last 30 minutes" panel served
128
+ * from `usage_hourly` would under-report and look like an outage. Two hours
129
+ * gives one complete hour plus the in-flight one, which is enough that any
130
+ * range long enough to be a TREND reads the rollups instead — and those survive
131
+ * raw-row retention deletion, which is the whole point of having them.
132
+ */
133
+ export const RAW_WINDOW_MS = 2 * 60 * 60 * 1000;
134
+ /**
135
+ * ONE place decides raw-vs-rollup. Every screen calls this rather than picking
136
+ * its own threshold, so two panels on the same page can never disagree about
137
+ * where "today" came from.
138
+ */
139
+ export function chooseSource(range) {
140
+ return range.toMs - range.fromMs <= RAW_WINDOW_MS ? "requests" : "usage_hourly";
141
+ }
142
+ const HOUR_MS = 60 * 60 * 1000;
143
+ function hourFloor(ms) {
144
+ return Math.floor(ms / HOUR_MS) * HOUR_MS;
145
+ }
146
+ /**
147
+ * Rollup ranges snap the lower edge outward to an hour boundary: an hourly
148
+ * bucket is indivisible, so a range starting at 10:30 can only be answered with
149
+ * the whole 10:00 bucket. Callers get slightly more than they asked for rather
150
+ * than silently less — an over-count at the edge is visible, an under-count is
151
+ * mistaken for a quiet period.
152
+ */
153
+ function rollupRange(range) {
154
+ return {
155
+ sql: "h.hour_start >= ? AND h.hour_start < ?",
156
+ params: [hourFloor(range.fromMs), range.toMs],
157
+ };
158
+ }
159
+ function rawRange(range) {
160
+ return { sql: "r.started_at >= ? AND r.started_at < ?", params: [range.fromMs, range.toMs] };
161
+ }
162
+ function num(v) {
163
+ return typeof v === "bigint" ? Number(v) : typeof v === "number" ? v : Number(v ?? 0);
164
+ }
165
+ function numOrNull(v) {
166
+ if (v === null || v === undefined)
167
+ return null;
168
+ return num(v);
169
+ }
170
+ function strOrNull(v) {
171
+ return typeof v === "string" ? v : v === null || v === undefined ? null : String(v);
172
+ }
173
+ function str(v, fallback = "") {
174
+ return typeof v === "string" ? v : v === null || v === undefined ? fallback : String(v);
175
+ }
176
+ /**
177
+ * "Is an error" — ONE definition, and it is the writer's.
178
+ *
179
+ * `server/store/write.ts` increments `usage_hourly.errors` from
180
+ * `isErrorStatus`, so the raw path must use the same predicate or the same
181
+ * range would report different error counts depending on which side of the
182
+ * 2-hour threshold it fell. Both are now generated from `NON_ERROR_STATUSES`
183
+ * rather than written out twice, because the previous pair of hand-written
184
+ * conditions was one edit away from disagreeing.
185
+ *
186
+ * This includes `identity_denied` (a rejected request, recorded on purpose so
187
+ * an admin can see auth failures) and `client_abort`, neither of which
188
+ * necessarily carries an `error_type`. It excludes `preflight_refused`, which
189
+ * is an expected refusal of Claude Code's warmup ping — see
190
+ * `server/pipeline/preflight.ts`.
191
+ *
192
+ * NOTE: this means the `requests_errors` partial index — predicated on
193
+ * `error_type IS NOT NULL` — does not cover this predicate. Matching the
194
+ * numbers matters more than matching the index; see the report accompanying
195
+ * this module. The `status <> 'ok'` partial index from migration 002 is still
196
+ * usable, since this narrower predicate implies it.
197
+ *
198
+ * Interpolation is safe: `NON_ERROR_STATUSES` is a closed literal union, never
199
+ * user input.
200
+ */
201
+ const RAW_IS_ERROR = `r.status NOT IN (${NON_ERROR_STATUSES.map((s) => `'${s}'`).join(", ")})`;
202
+ const DEFAULT_LIMIT = 50;
203
+ /** A page bigger than this is a mistake or an attempt to dump the table. */
204
+ export const MAX_LIMIT = 500;
205
+ /**
206
+ * `limit` arrives from a query string, so treat it as hostile: coerce to an
207
+ * integer and clamp. Not doing this is how `LIMIT ?` becomes a table export.
208
+ */
209
+ function clampLimit(limit) {
210
+ const n = Math.trunc(Number(limit));
211
+ if (!Number.isFinite(n) || n <= 0)
212
+ return DEFAULT_LIMIT;
213
+ return Math.min(n, MAX_LIMIT);
214
+ }
215
+ /**
216
+ * Sentinel for "no cursor yet". Binding a cursor on EVERY call keeps the feed a
217
+ * single SQL shape, which matters for the plan: with `seq < ?` present SQLite
218
+ * drives the query off a bounded descending rowid search, whereas the
219
+ * cursor-less variant falls back to a temp b-tree sort of the whole org.
220
+ */
221
+ const SEQ_SENTINEL = Number.MAX_SAFE_INTEGER;
222
+ function clampCursor(beforeSeq) {
223
+ const n = Math.trunc(Number(beforeSeq));
224
+ if (!Number.isFinite(n) || n <= 0)
225
+ return SEQ_SENTINEL;
226
+ return Math.min(n, SEQ_SENTINEL);
227
+ }
228
+ const FEED_COLUMNS = `
229
+ r.seq, r.id, r.started_at, r.user_id, r.session_id,
230
+ r.requested_model, r.served_model, r.posture, r.credential_origin,
231
+ r.credential_fingerprint, r.status, r.http_status, r.error_type,
232
+ r.stream, r.partial,
233
+ r.input_tokens, r.cache_read_tokens, r.cache_write_5m_tokens,
234
+ r.cache_write_1h_tokens, r.output_tokens, r.web_searches, r.service_tier,
235
+ r.cost_usd, r.cost_basis, r.notional_cost_usd, r.ttfb_ms, r.duration_ms,
236
+ r.rl_5h_utilization, r.rl_claim, r.client_version,
237
+ r.pipeline, r.route_id, r.credentials_considered`;
238
+ /**
239
+ * Build the feed SQL and params. Exported so the query-plan test can assert on
240
+ * the exact statement the app runs, rather than on a hand-written approximation
241
+ * of it that would quietly drift.
242
+ */
243
+ export function feedSql(scope, filter) {
244
+ const scoped = scopeClause(scope, "r");
245
+ const params = [...scoped.params];
246
+ let where = scoped.sql;
247
+ // Filters are appended as `AND col = ?` with a parallel params array. No
248
+ // value is ever interpolated into the string — not even one that "looks like"
249
+ // an enum, because `credentialOrigin` and `servedModel` both come straight
250
+ // off a query string.
251
+ if (filter.userId !== undefined) {
252
+ where += " AND r.user_id = ?";
253
+ params.push(filter.userId);
254
+ }
255
+ if (filter.servedModel !== undefined) {
256
+ where += " AND r.served_model = ?";
257
+ params.push(filter.servedModel);
258
+ }
259
+ if (filter.credentialOrigin !== undefined) {
260
+ where += " AND r.credential_origin = ?";
261
+ params.push(filter.credentialOrigin);
262
+ }
263
+ if (filter.sessionId !== undefined) {
264
+ where += " AND r.session_id = ?";
265
+ params.push(filter.sessionId);
266
+ }
267
+ if (filter.errorsOnly)
268
+ where += ` AND ${RAW_IS_ERROR}`;
269
+ params.push(clampCursor(filter.beforeSeq));
270
+ params.push(clampLimit(filter.limit));
271
+ const sql = `SELECT ${FEED_COLUMNS}
272
+ FROM requests r
273
+ WHERE ${where} AND r.seq < ?
274
+ ORDER BY r.seq DESC
275
+ LIMIT ?`;
276
+ return { sql, params };
277
+ }
278
+ /**
279
+ * Never let a malformed audit note break the feed.
280
+ *
281
+ * The column is JSON written by our own writer, so this should not fail — but
282
+ * a feed that 500s because one historical row has odd content is a worse
283
+ * outcome than a row that shows no credential detail.
284
+ */
285
+ function parseConsidered(value) {
286
+ if (typeof value !== "string" || value === "")
287
+ return [];
288
+ try {
289
+ const parsed = JSON.parse(value);
290
+ return Array.isArray(parsed) ? parsed : [];
291
+ }
292
+ catch {
293
+ return [];
294
+ }
295
+ }
296
+ function toRequestRow(row) {
297
+ return {
298
+ seq: num(row["seq"]),
299
+ id: str(row["id"]),
300
+ startedAt: num(row["started_at"]),
301
+ userId: strOrNull(row["user_id"]),
302
+ sessionId: strOrNull(row["session_id"]),
303
+ requestedModel: strOrNull(row["requested_model"]),
304
+ servedModel: strOrNull(row["served_model"]),
305
+ posture: str(row["posture"]),
306
+ credentialOrigin: str(row["credential_origin"]),
307
+ credentialFingerprint: strOrNull(row["credential_fingerprint"]),
308
+ status: str(row["status"]),
309
+ httpStatus: numOrNull(row["http_status"]),
310
+ errorType: strOrNull(row["error_type"]),
311
+ stream: num(row["stream"]) !== 0,
312
+ partial: num(row["partial"]) !== 0,
313
+ usage: {
314
+ inputTokens: num(row["input_tokens"]),
315
+ cacheReadTokens: num(row["cache_read_tokens"]),
316
+ cacheWrite5mTokens: num(row["cache_write_5m_tokens"]),
317
+ cacheWrite1hTokens: num(row["cache_write_1h_tokens"]),
318
+ outputTokens: num(row["output_tokens"]),
319
+ webSearches: num(row["web_searches"]),
320
+ serviceTier: strOrNull(row["service_tier"]) ?? undefined,
321
+ },
322
+ costUsd: numOrNull(row["cost_usd"]),
323
+ costBasis: str(row["cost_basis"], "none"),
324
+ notionalCostUsd: numOrNull(row["notional_cost_usd"]),
325
+ ttfbMs: numOrNull(row["ttfb_ms"]),
326
+ durationMs: num(row["duration_ms"]),
327
+ rl5hUtilization: numOrNull(row["rl_5h_utilization"]),
328
+ rlClaim: strOrNull(row["rl_claim"]),
329
+ clientVersion: strOrNull(row["client_version"]),
330
+ pipeline: str(row["pipeline"], "passthrough"),
331
+ routeId: strOrNull(row["route_id"]),
332
+ // Stored as JSON because it is an audit note read whole. A row written
333
+ // before migration 003 has '[]', which reads correctly as "nothing
334
+ // recorded" rather than as a wrong answer.
335
+ credentialsConsidered: parseConsidered(row["credentials_considered"]),
336
+ };
337
+ }
338
+ /**
339
+ * Newest-first page of requests, keyset paginated on `seq`.
340
+ *
341
+ * Keyset and not OFFSET: the feed is append-heavy, so between page 1 and page 2
342
+ * new rows arrive at the top and every OFFSET page shifts underneath the
343
+ * reader — they see one row twice and miss another entirely. `seq < cursor` is
344
+ * stable under concurrent inserts, and costs the same at page 1 and page 900.
345
+ */
346
+ export function listRequests(store, scope, filter) {
347
+ const limit = clampLimit(filter.limit);
348
+ const { sql, params } = feedSql(scope, filter);
349
+ const rows = store.db.prepare(sql).all(...params).map(toRequestRow);
350
+ // A short page means we reached the end; only a full page can have more
351
+ // behind it. Handing back a cursor on a short page would make the UI issue a
352
+ // guaranteed-empty request at the bottom of every feed.
353
+ const last = rows[rows.length - 1];
354
+ const nextCursor = rows.length === limit && last !== undefined ? last.seq : null;
355
+ return { rows, nextCursor };
356
+ }
357
+ /**
358
+ * Aggregate expressions, raw-rows flavour.
359
+ *
360
+ * Cost honesty, which is the whole reason this is three columns and not one:
361
+ *
362
+ * - SQL `SUM()` SKIPS NULLs. A bare `SUM(cost_usd)` therefore returns a total
363
+ * that quietly omits every row we could not price, and looks completely
364
+ * plausible while being short. `unpriced_requests` is the receipt.
365
+ * - A `subscription` row is REAL USAGE WITH NO ORG SPEND. It is not $0 — $0
366
+ * would say "this was free", and any average or forecast over it would be
367
+ * wrong. It must never be added into a dollar total, so it is excluded from
368
+ * the sum (`CASE ... THEN NULL`, defensive in case a notional price is ever
369
+ * written into the column) and counted separately instead.
370
+ *
371
+ * With all three a caller can render "$12.3456 (+3 n/a)" — see `formatCostSum`
372
+ * in server/usage/cost.ts, which is the intended consumer.
373
+ */
374
+ const RAW_AGG = `
375
+ COUNT(*) AS requests,
376
+ COALESCE(SUM(CASE WHEN ${RAW_IS_ERROR} THEN 1 ELSE 0 END), 0) AS errors,
377
+ COALESCE(SUM(r.input_tokens), 0) AS input_tokens,
378
+ COALESCE(SUM(r.cache_read_tokens), 0) AS cache_read_tokens,
379
+ COALESCE(SUM(r.cache_write_5m_tokens), 0) AS cache_write_5m_tokens,
380
+ COALESCE(SUM(r.cache_write_1h_tokens), 0) AS cache_write_1h_tokens,
381
+ COALESCE(SUM(r.output_tokens), 0) AS output_tokens,
382
+ COALESCE(SUM(r.web_searches), 0) AS web_searches,
383
+ COALESCE(SUM(CASE WHEN r.cost_basis = 'subscription' THEN NULL ELSE r.cost_usd END), 0)
384
+ AS priced_cost_usd,
385
+ COALESCE(SUM(CASE WHEN r.cost_usd IS NULL AND r.cost_basis <> 'subscription'
386
+ THEN 1 ELSE 0 END), 0) AS unpriced_requests,
387
+ COALESCE(SUM(CASE WHEN r.cost_basis = 'subscription' THEN 1 ELSE 0 END), 0)
388
+ AS subscription_requests,
389
+ -- Note the asymmetry with priced_cost_usd above, which is the entire point of
390
+ -- the column: spend EXCLUDES subscription rows, value INCLUDES them. These
391
+ -- two sums are never added to one another anywhere in this codebase.
392
+ COALESCE(SUM(r.notional_cost_usd), 0) AS notional_cost_usd,
393
+ COALESCE(SUM(CASE WHEN r.notional_cost_usd IS NULL THEN 1 ELSE 0 END), 0)
394
+ AS notional_unpriced_requests`;
395
+ /**
396
+ * The same three cost figures, rollup flavour. `usage_hourly.cost_usd` is
397
+ * already a priced-only sum and carries its own two counts, precisely so the
398
+ * honesty survives the raw rows being deleted by retention.
399
+ */
400
+ const ROLLUP_AGG = `
401
+ COALESCE(SUM(h.requests), 0) AS requests,
402
+ COALESCE(SUM(h.errors), 0) AS errors,
403
+ COALESCE(SUM(h.input_tokens), 0) AS input_tokens,
404
+ COALESCE(SUM(h.cache_read_tokens), 0) AS cache_read_tokens,
405
+ COALESCE(SUM(h.cache_write_5m_tokens), 0) AS cache_write_5m_tokens,
406
+ COALESCE(SUM(h.cache_write_1h_tokens), 0) AS cache_write_1h_tokens,
407
+ COALESCE(SUM(h.output_tokens), 0) AS output_tokens,
408
+ COALESCE(SUM(h.web_searches), 0) AS web_searches,
409
+ COALESCE(SUM(h.cost_usd), 0) AS priced_cost_usd,
410
+ COALESCE(SUM(h.unpriced_requests), 0) AS unpriced_requests,
411
+ COALESCE(SUM(h.subscription_requests), 0) AS subscription_requests,
412
+ COALESCE(SUM(h.notional_cost_usd), 0) AS notional_cost_usd,
413
+ COALESCE(SUM(h.notional_unpriced_requests), 0) AS notional_unpriced_requests`;
414
+ function makeTotals(parts) {
415
+ return {
416
+ ...parts,
417
+ // Single definition of cache hit ratio, in server/usage/pricing.ts. Do not
418
+ // re-derive it here: the denominator is the four disjoint context buckets,
419
+ // and getting that wrong is the classic order-of-magnitude double-count.
420
+ cacheHitRatio: cacheHitRatio(parts.usage),
421
+ };
422
+ }
423
+ function toTotals(row) {
424
+ return makeTotals({
425
+ requests: num(row["requests"]),
426
+ errors: num(row["errors"]),
427
+ usage: {
428
+ inputTokens: num(row["input_tokens"]),
429
+ cacheReadTokens: num(row["cache_read_tokens"]),
430
+ cacheWrite5mTokens: num(row["cache_write_5m_tokens"]),
431
+ cacheWrite1hTokens: num(row["cache_write_1h_tokens"]),
432
+ outputTokens: num(row["output_tokens"]),
433
+ webSearches: num(row["web_searches"]),
434
+ },
435
+ pricedCostUsd: num(row["priced_cost_usd"]),
436
+ unpricedRequests: num(row["unpriced_requests"]),
437
+ subscriptionRequests: num(row["subscription_requests"]),
438
+ notionalCostUsd: num(row["notional_cost_usd"]),
439
+ notionalUnpricedRequests: num(row["notional_unpriced_requests"]),
440
+ });
441
+ }
442
+ const EMPTY_ROW = {};
443
+ /**
444
+ * Org total minus the attributed rows: the unattributed residual.
445
+ *
446
+ * Only meaningful on the rollup path, and only because the writer de-duplicates
447
+ * its key set, so the `('', '')` cell is exactly the whole org. See the cube
448
+ * note at the top of the file.
449
+ */
450
+ function subtractTotals(total, parts) {
451
+ const acc = {
452
+ requests: total.requests,
453
+ errors: total.errors,
454
+ inputTokens: total.usage.inputTokens,
455
+ cacheReadTokens: total.usage.cacheReadTokens,
456
+ cacheWrite5mTokens: total.usage.cacheWrite5mTokens,
457
+ cacheWrite1hTokens: total.usage.cacheWrite1hTokens,
458
+ outputTokens: total.usage.outputTokens,
459
+ webSearches: total.usage.webSearches,
460
+ pricedCostUsd: total.pricedCostUsd,
461
+ unpricedRequests: total.unpricedRequests,
462
+ subscriptionRequests: total.subscriptionRequests,
463
+ notionalCostUsd: total.notionalCostUsd,
464
+ notionalUnpricedRequests: total.notionalUnpricedRequests,
465
+ };
466
+ for (const p of parts) {
467
+ acc.requests -= p.requests;
468
+ acc.errors -= p.errors;
469
+ acc.inputTokens -= p.usage.inputTokens;
470
+ acc.cacheReadTokens -= p.usage.cacheReadTokens;
471
+ acc.cacheWrite5mTokens -= p.usage.cacheWrite5mTokens;
472
+ acc.cacheWrite1hTokens -= p.usage.cacheWrite1hTokens;
473
+ acc.outputTokens -= p.usage.outputTokens;
474
+ acc.webSearches -= p.usage.webSearches;
475
+ acc.pricedCostUsd -= p.pricedCostUsd;
476
+ acc.unpricedRequests -= p.unpricedRequests;
477
+ acc.subscriptionRequests -= p.subscriptionRequests;
478
+ acc.notionalCostUsd -= p.notionalCostUsd;
479
+ acc.notionalUnpricedRequests -= p.notionalUnpricedRequests;
480
+ }
481
+ return makeTotals({
482
+ requests: acc.requests,
483
+ errors: acc.errors,
484
+ usage: {
485
+ inputTokens: acc.inputTokens,
486
+ cacheReadTokens: acc.cacheReadTokens,
487
+ cacheWrite5mTokens: acc.cacheWrite5mTokens,
488
+ cacheWrite1hTokens: acc.cacheWrite1hTokens,
489
+ outputTokens: acc.outputTokens,
490
+ webSearches: acc.webSearches,
491
+ },
492
+ // Floating-point subtraction of REAL sums can leave a -1e-17 here. Clamp,
493
+ // because a dashboard rendering "-$0.0000" destroys trust in every other
494
+ // number on the page.
495
+ pricedCostUsd: Math.max(acc.pricedCostUsd, 0),
496
+ unpricedRequests: acc.unpricedRequests,
497
+ notionalCostUsd: Math.max(acc.notionalCostUsd, 0),
498
+ notionalUnpricedRequests: acc.notionalUnpricedRequests,
499
+ subscriptionRequests: acc.subscriptionRequests,
500
+ });
501
+ }
502
+ /**
503
+ * Build an aggregate query over whichever source `chooseSource` picked, at the
504
+ * requested cube grain. Shared so a fix to the cost arithmetic lands in every
505
+ * panel at once instead of five out of six.
506
+ */
507
+ function aggregateQuery(scope, range, grain, opts = {}) {
508
+ if (chooseSource(range) === "requests") {
509
+ const scoped = scopeClause(scope, "r");
510
+ const time = rawRange(range);
511
+ const select = opts.rawSelect ? `${opts.rawSelect}, ${RAW_AGG}` : RAW_AGG;
512
+ return {
513
+ sql: `SELECT ${select}
514
+ FROM requests r${opts.rawJoin ?? ""}
515
+ WHERE ${scoped.sql} AND ${time.sql}
516
+ ${opts.rawGroupBy ? `GROUP BY ${opts.rawGroupBy}` : ""}
517
+ ${opts.orderBy ? `ORDER BY ${opts.orderBy}` : ""}`,
518
+ params: [...scoped.params, ...time.params],
519
+ };
520
+ }
521
+ const slice = rollupSlice(scope, grain);
522
+ const time = rollupRange(range);
523
+ const select = opts.rollupSelect ? `${opts.rollupSelect}, ${ROLLUP_AGG}` : ROLLUP_AGG;
524
+ return {
525
+ sql: `SELECT ${select}
526
+ FROM usage_hourly h${opts.rollupJoin ?? ""}
527
+ WHERE ${slice.sql} AND ${time.sql}
528
+ ${opts.rollupGroupBy ? `GROUP BY ${opts.rollupGroupBy}` : ""}
529
+ ${opts.orderBy ? `ORDER BY ${opts.orderBy}` : ""}`,
530
+ params: [...slice.params, ...time.params],
531
+ };
532
+ }
533
+ function runTotals(store, q) {
534
+ const row = store.db.prepare(q.sql).get(...q.params);
535
+ return toTotals(row ?? EMPTY_ROW);
536
+ }
537
+ const ORG_GRAIN = { byUser: false, byModel: false };
538
+ export function usageTotals(store, scope, range) {
539
+ return runTotals(store, aggregateQuery(scope, range, ORG_GRAIN));
540
+ }
541
+ /**
542
+ * Per-user totals.
543
+ *
544
+ * LEFT JOIN, not JOIN, and it has to stay that way: `requests.user_id` is
545
+ * nullable because a call with no identity token is recorded as unattributed
546
+ * rather than dropped. An inner join would hide those rows, and unattributed
547
+ * usage is precisely what an admin needs to SEE — it means a developer is
548
+ * pointing Claude Code at Fest without an identity token, so their spend is
549
+ * landing in nobody's column.
550
+ *
551
+ * Unattributed usage surfaces as `userId: ''` with `email: null`: directly from
552
+ * `user_id IS NULL` on the raw path, and as the org-total-minus-attributed
553
+ * residual on the rollup path, where `''` cannot be told apart from "all".
554
+ */
555
+ export function usageByUser(store, scope, range) {
556
+ const q = usageByUserSql(scope, range);
557
+ const rows = store.db.prepare(q.sql).all(...q.params).map((row) => ({
558
+ userId: str(row["user_id"]),
559
+ email: strOrNull(row["email"]),
560
+ ...toTotals(row),
561
+ }));
562
+ if (chooseSource(range) === "requests")
563
+ return rows;
564
+ // Rollup path: recover the unattributed slice as a residual.
565
+ const total = usageTotals(store, scope, range);
566
+ const residual = subtractTotals(total, rows);
567
+ if (residual.requests > 0)
568
+ rows.push({ userId: "", email: null, ...residual });
569
+ return rows;
570
+ }
571
+ /** Exported for the query-plan test; see `feedSql`. */
572
+ export function usageByUserSql(scope, range) {
573
+ return aggregateQuery(scope, range, { byUser: true, byModel: false }, {
574
+ rawSelect: "COALESCE(r.user_id, '') AS user_id, MAX(u.email) AS email",
575
+ rollupSelect: "h.user_id AS user_id, MAX(u.email) AS email",
576
+ // The join is scoped by org too. User ids are prefixed uuids so a
577
+ // cross-org collision is not realistic, but a join that can only ever
578
+ // match inside the tenant is one less thing to have to reason about.
579
+ rawJoin: " LEFT JOIN users u ON u.id = r.user_id AND u.org_id = r.org_id",
580
+ rollupJoin: " LEFT JOIN users u ON u.id = h.user_id AND u.org_id = h.org_id",
581
+ rawGroupBy: "COALESCE(r.user_id, '')",
582
+ rollupGroupBy: "h.user_id",
583
+ orderBy: "requests DESC",
584
+ });
585
+ }
586
+ /**
587
+ * Per-model totals. A request that errored before a model was resolved has no
588
+ * `served_model`; it surfaces as `servedModel: ''` rather than being dropped,
589
+ * by the same residual trick as `usageByUser`.
590
+ */
591
+ export function usageByModel(store, scope, range) {
592
+ const q = aggregateQuery(scope, range, { byUser: false, byModel: true }, {
593
+ rawSelect: "COALESCE(r.served_model, '') AS served_model",
594
+ rollupSelect: "h.served_model AS served_model",
595
+ rawGroupBy: "COALESCE(r.served_model, '')",
596
+ rollupGroupBy: "h.served_model",
597
+ orderBy: "requests DESC",
598
+ });
599
+ const rows = store.db.prepare(q.sql).all(...q.params).map((row) => ({
600
+ servedModel: str(row["served_model"]),
601
+ ...toTotals(row),
602
+ }));
603
+ if (chooseSource(range) === "requests")
604
+ return rows;
605
+ const residual = subtractTotals(usageTotals(store, scope, range), rows);
606
+ if (residual.requests > 0)
607
+ rows.push({ servedModel: "", ...residual });
608
+ return rows;
609
+ }
610
+ /**
611
+ * Per-credential-origin totals. This is the compliance view: it answers "how
612
+ * much of our traffic ran on a server-held key" at a glance, and
613
+ * `distinctUsers` answers "how many people did that affect".
614
+ *
615
+ * `credential_origin` is never `''` in the cube, so the org grain already
616
+ * carries every origin and no residual is needed. `distinctUsers` does need a
617
+ * second, per-user-grain query though: a count of distinct users cannot be
618
+ * recovered from rows where the user dimension is collapsed to `''`.
619
+ */
620
+ export function usageByCredentialOrigin(store, scope, range) {
621
+ const raw = chooseSource(range) === "requests";
622
+ const q = aggregateQuery(scope, range, ORG_GRAIN, {
623
+ rawSelect: "r.credential_origin AS credential_origin, COUNT(DISTINCT r.user_id) AS distinct_users",
624
+ rollupSelect: "h.credential_origin AS credential_origin",
625
+ rawGroupBy: "r.credential_origin",
626
+ rollupGroupBy: "h.credential_origin",
627
+ orderBy: "requests DESC",
628
+ });
629
+ const rows = store.db.prepare(q.sql).all(...q.params).map((row) => ({
630
+ credentialOrigin: str(row["credential_origin"]),
631
+ // COUNT(DISTINCT user_id) ignores NULLs, so an unattributed request is not
632
+ // counted as a person. It is visible in `usageByUser` instead.
633
+ distinctUsers: num(row["distinct_users"]),
634
+ ...toTotals(row),
635
+ }));
636
+ if (raw)
637
+ return rows;
638
+ const slice = rollupSlice(scope, { byUser: true, byModel: false });
639
+ const time = rollupRange(range);
640
+ const counts = new Map();
641
+ const countRows = store.db
642
+ .prepare(`SELECT h.credential_origin AS credential_origin,
643
+ COUNT(DISTINCT h.user_id) AS distinct_users
644
+ FROM usage_hourly h WHERE ${slice.sql} AND ${time.sql}
645
+ GROUP BY h.credential_origin`)
646
+ .all(...slice.params, ...time.params);
647
+ for (const row of countRows)
648
+ counts.set(str(row["credential_origin"]), num(row["distinct_users"]));
649
+ return rows.map((row) => ({ ...row, distinctUsers: counts.get(row.credentialOrigin) ?? 0 }));
650
+ }
651
+ /**
652
+ * Hourly series for a sparkline. Gaps are gaps: an hour with no traffic is
653
+ * absent rather than zero-filled, because the caller knows the range it asked
654
+ * for, whereas this layer cannot tell "no traffic" from "outside retention".
655
+ */
656
+ export function usageSeries(store, scope, range) {
657
+ const q = aggregateQuery(scope, range, ORG_GRAIN, {
658
+ // Integer division truncates in SQLite, which is exactly the bucketing we
659
+ // want, and it keeps the arithmetic in epoch ms with no timezone anywhere.
660
+ rawSelect: `(r.started_at / ${HOUR_MS}) * ${HOUR_MS} AS hour_start`,
661
+ rollupSelect: "h.hour_start AS hour_start",
662
+ rawGroupBy: `r.started_at / ${HOUR_MS}`,
663
+ rollupGroupBy: "h.hour_start",
664
+ orderBy: "hour_start ASC",
665
+ });
666
+ return store.db.prepare(q.sql).all(...q.params).map((row) => ({
667
+ hourStart: num(row["hour_start"]),
668
+ ...toTotals(row),
669
+ }));
670
+ }
671
+ // ── Errors ────────────────────────────────────────────────────────────────────
672
+ /**
673
+ * Error breakdown.
674
+ *
675
+ * Always reads raw `requests`, regardless of range: the rollup keeps an error
676
+ * COUNT but not `error_type`/`http_status`, so there is nothing to group by
677
+ * there. That means this view goes blank once retention has deleted the raw
678
+ * rows for a range, which is honest — a breakdown we cannot compute must not be
679
+ * approximated.
680
+ *
681
+ * `errorType` is genuinely nullable: a `client_abort` or an `identity_denied`
682
+ * rejection is a non-ok status that may carry no error type at all, and those
683
+ * are exactly the rows an admin wants to see (an identity_denied run means
684
+ * someone's token is wrong).
685
+ */
686
+ export function errorBreakdown(store, scope, range) {
687
+ const scoped = scopeClause(scope, "r");
688
+ const time = rawRange(range);
689
+ const sql = `SELECT r.error_type AS error_type, r.http_status AS http_status, COUNT(*) AS count
690
+ FROM requests r
691
+ WHERE ${scoped.sql} AND ${time.sql} AND ${RAW_IS_ERROR}
692
+ GROUP BY r.error_type, r.http_status
693
+ ORDER BY count DESC, r.error_type ASC`;
694
+ return store.db.prepare(sql).all(...scoped.params, ...time.params).map((row) => ({
695
+ errorType: strOrNull(row["error_type"]),
696
+ httpStatus: numOrNull(row["http_status"]),
697
+ count: num(row["count"]),
698
+ }));
699
+ }
700
+ // ── Latency ───────────────────────────────────────────────────────────────────
701
+ /**
702
+ * Lower and upper edges of the fixed histogram buckets from `usage_hourly`:
703
+ * <1s, <3s, <10s, <30s, <60s, >=60s. Must stay in step with `latencyBucket` in
704
+ * server/store/write.ts — the writer decides which bucket a row lands in, this
705
+ * decides what that bucket means.
706
+ *
707
+ * The last bucket is open-ended on purpose: a gateway relaying agent turns has
708
+ * a genuinely unbounded tail, and inventing an upper edge for it would invent a
709
+ * percentile.
710
+ */
711
+ const LAT_LOWER = [0, 1000, 3000, 10_000, 30_000, 60_000];
712
+ const LAT_UPPER = [1000, 3000, 10_000, 30_000, 60_000, null];
713
+ export const LAT_BUCKET_COUNT = LAT_LOWER.length;
714
+ /**
715
+ * Linear interpolation of a percentile within the fixed buckets.
716
+ *
717
+ * Why buckets exist at all: PERCENTILES DO NOT MERGE ACROSS ROLLUP ROWS. You
718
+ * cannot average two hours' p95s, or take the larger, and get the p95 of the
719
+ * two hours combined — the information needed to do that was thrown away when
720
+ * each hour was summarised. Bucket COUNTS, by contrast, simply add. So the
721
+ * rollup stores counts and we reconstruct a percentile from them.
722
+ *
723
+ * The reconstruction assumes latency is uniformly distributed inside each
724
+ * bucket, which it is not. The result is therefore an ESTIMATE with a
725
+ * resolution no finer than the bucket it lands in: a p95 reported as 24.5s
726
+ * really means "somewhere in 10s–30s". Present it as approximate. If you need
727
+ * an exact p95, shorten the range until `chooseSource` reads raw rows.
728
+ *
729
+ * A percentile landing in the open-ended top bucket returns that bucket's lower
730
+ * edge (60s) as a FLOOR, because there is no upper edge to interpolate toward.
731
+ */
732
+ export function interpolatePercentile(buckets, p) {
733
+ let total = 0;
734
+ for (const b of buckets)
735
+ total += b;
736
+ if (total <= 0)
737
+ return null;
738
+ const target = p * total;
739
+ let cumulative = 0;
740
+ for (let i = 0; i < buckets.length; i += 1) {
741
+ const inBucket = buckets[i] ?? 0;
742
+ if (inBucket === 0)
743
+ continue;
744
+ if (cumulative + inBucket >= target) {
745
+ const lower = LAT_LOWER[i] ?? 0;
746
+ const upper = LAT_UPPER[i];
747
+ if (upper === null || upper === undefined)
748
+ return lower;
749
+ const fraction = (target - cumulative) / inBucket;
750
+ return lower + fraction * (upper - lower);
751
+ }
752
+ cumulative += inBucket;
753
+ }
754
+ // Unreachable while total > 0; returning the top edge still beats returning
755
+ // null, which a caller would read as "no data".
756
+ return LAT_LOWER[LAT_LOWER.length - 1] ?? null;
757
+ }
758
+ /** Nearest-rank percentile over a sorted array. No interpolation, no estimate. */
759
+ function exactPercentile(sorted, p) {
760
+ if (sorted.length === 0)
761
+ return null;
762
+ const rank = Math.ceil(p * sorted.length) - 1;
763
+ const idx = Math.min(Math.max(rank, 0), sorted.length - 1);
764
+ return sorted[idx] ?? null;
765
+ }
766
+ /** Bucket a raw duration. Mirrors `latencyBucket` in write.ts. */
767
+ function bucketOf(durationMs) {
768
+ for (let b = 0; b < LAT_BUCKET_COUNT; b += 1) {
769
+ const upper = LAT_UPPER[b];
770
+ if (upper === null || upper === undefined || durationMs < upper)
771
+ return b;
772
+ }
773
+ return LAT_BUCKET_COUNT - 1;
774
+ }
775
+ export function latencySummary(store, scope, range) {
776
+ if (chooseSource(range) === "requests") {
777
+ const scoped = scopeClause(scope, "r");
778
+ const time = rawRange(range);
779
+ const params = [...scoped.params, ...time.params];
780
+ // Pulling the durations out and sorting in JS is acceptable here *because*
781
+ // this branch only ever runs for a range of <= RAW_WINDOW_MS. Do not reuse
782
+ // it for a long range: that is what the bucketed branch below is for.
783
+ const durations = store.db
784
+ .prepare(`SELECT r.duration_ms AS duration_ms FROM requests r
785
+ WHERE ${scoped.sql} AND ${time.sql} ORDER BY r.duration_ms ASC`)
786
+ .all(...params).map((row) => num(row["duration_ms"]));
787
+ const agg = store.db
788
+ .prepare(`SELECT COUNT(*) AS count, AVG(r.duration_ms) AS avg_duration,
789
+ MAX(r.duration_ms) AS max_duration, AVG(r.ttfb_ms) AS avg_ttfb
790
+ FROM requests r WHERE ${scoped.sql} AND ${time.sql}`)
791
+ .get(...params);
792
+ const buckets = new Array(LAT_BUCKET_COUNT).fill(0);
793
+ for (const d of durations) {
794
+ const i = bucketOf(d);
795
+ buckets[i] = (buckets[i] ?? 0) + 1;
796
+ }
797
+ return {
798
+ count: num(agg?.["count"]),
799
+ avgDurationMs: numOrNull(agg?.["avg_duration"]),
800
+ maxDurationMs: numOrNull(agg?.["max_duration"]),
801
+ // AVG ignores NULLs, so this is already an average over requests that
802
+ // actually produced a first byte rather than one diluted by aborts.
803
+ avgTtfbMs: numOrNull(agg?.["avg_ttfb"]),
804
+ p50Ms: exactPercentile(durations, 0.5),
805
+ p95Ms: exactPercentile(durations, 0.95),
806
+ buckets,
807
+ };
808
+ }
809
+ // Org grain: summing per-user or per-model cells as well would count every
810
+ // request two to four times over. See the cube note at the top of the file.
811
+ const slice = rollupSlice(scope, ORG_GRAIN);
812
+ const time = rollupRange(range);
813
+ const row = store.db
814
+ .prepare(`SELECT COALESCE(SUM(h.requests), 0) AS count,
815
+ COALESCE(SUM(h.duration_ms_sum), 0) AS duration_sum,
816
+ MAX(h.duration_ms_max) AS max_duration,
817
+ COALESCE(SUM(h.ttfb_ms_sum), 0) AS ttfb_sum,
818
+ COALESCE(SUM(h.ttfb_count), 0) AS ttfb_count,
819
+ COALESCE(SUM(h.lat_b0), 0) AS b0, COALESCE(SUM(h.lat_b1), 0) AS b1,
820
+ COALESCE(SUM(h.lat_b2), 0) AS b2, COALESCE(SUM(h.lat_b3), 0) AS b3,
821
+ COALESCE(SUM(h.lat_b4), 0) AS b4, COALESCE(SUM(h.lat_b5), 0) AS b5
822
+ FROM usage_hourly h WHERE ${slice.sql} AND ${time.sql}`)
823
+ .get(...slice.params, ...time.params);
824
+ const r = row ?? EMPTY_ROW;
825
+ const count = num(r["count"]);
826
+ const ttfbCount = num(r["ttfb_count"]);
827
+ const buckets = [
828
+ num(r["b0"]),
829
+ num(r["b1"]),
830
+ num(r["b2"]),
831
+ num(r["b3"]),
832
+ num(r["b4"]),
833
+ num(r["b5"]),
834
+ ];
835
+ return {
836
+ count,
837
+ avgDurationMs: count > 0 ? num(r["duration_sum"]) / count : null,
838
+ maxDurationMs: numOrNull(r["max_duration"]),
839
+ // Divided by ttfb_count, not requests: a non-streaming or failed call has
840
+ // no TTFB, and dividing by the request count would silently deflate it.
841
+ avgTtfbMs: ttfbCount > 0 ? num(r["ttfb_sum"]) / ttfbCount : null,
842
+ p50Ms: interpolatePercentile(buckets, 0.5),
843
+ p95Ms: interpolatePercentile(buckets, 0.95),
844
+ buckets,
845
+ };
846
+ }
847
+ /**
848
+ * Latest observed quota snapshot per user.
849
+ *
850
+ * For a developer on a subscription THIS — not dollars — is the scarce
851
+ * resource, so it gets its own panel. There is no time range: the question is
852
+ * always "where does everyone stand right now", and the answer is whatever the
853
+ * most recent request that actually CARRIED quota headers reported. Quota lives
854
+ * only on raw rows, so this view is bounded by raw-row retention.
855
+ *
856
+ * Requests with no quota headers are skipped rather than surfaced as nulls. A
857
+ * failed or aborted call has no headers, and letting one overwrite a real
858
+ * snapshot with blanks would make the panel flicker to "unknown" exactly when a
859
+ * developer is hitting limits — the moment it matters most.
860
+ *
861
+ * Ties on `started_at` (same millisecond) break on `seq`, so the result is
862
+ * deterministic rather than whatever the planner happened to emit last.
863
+ */
864
+ export function latestQuotaByUser(store, scope) {
865
+ const scoped = scopeClause(scope, "r");
866
+ const sql = `SELECT q.user_id AS user_id, u.email AS email, q.started_at AS observed_at,
867
+ q.rl_5h_utilization, q.rl_5h_status, q.rl_5h_reset_at,
868
+ q.rl_7d_utilization, q.rl_7d_status, q.rl_7d_reset_at,
869
+ q.rl_claim, q.rl_overage_status, q.rl_overage_reason
870
+ FROM (
871
+ SELECT r.user_id, r.org_id, r.started_at, r.seq,
872
+ r.rl_5h_utilization, r.rl_5h_status, r.rl_5h_reset_at,
873
+ r.rl_7d_utilization, r.rl_7d_status, r.rl_7d_reset_at,
874
+ r.rl_claim, r.rl_overage_status, r.rl_overage_reason,
875
+ ROW_NUMBER() OVER (
876
+ PARTITION BY COALESCE(r.user_id, '')
877
+ ORDER BY r.started_at DESC, r.seq DESC
878
+ ) AS rn
879
+ FROM requests r
880
+ WHERE ${scoped.sql}
881
+ AND (r.rl_status IS NOT NULL
882
+ OR r.rl_5h_utilization IS NOT NULL
883
+ OR r.rl_7d_utilization IS NOT NULL
884
+ OR r.rl_claim IS NOT NULL)
885
+ ) q
886
+ LEFT JOIN users u ON u.id = q.user_id AND u.org_id = q.org_id
887
+ WHERE q.rn = 1
888
+ ORDER BY q.started_at DESC`;
889
+ return store.db.prepare(sql).all(...scoped.params).map((row) => ({
890
+ userId: strOrNull(row["user_id"]),
891
+ email: strOrNull(row["email"]),
892
+ observedAt: num(row["observed_at"]),
893
+ fiveHourUtilization: numOrNull(row["rl_5h_utilization"]),
894
+ fiveHourStatus: strOrNull(row["rl_5h_status"]),
895
+ fiveHourResetAt: numOrNull(row["rl_5h_reset_at"]),
896
+ sevenDayUtilization: numOrNull(row["rl_7d_utilization"]),
897
+ sevenDayStatus: strOrNull(row["rl_7d_status"]),
898
+ sevenDayResetAt: numOrNull(row["rl_7d_reset_at"]),
899
+ claim: strOrNull(row["rl_claim"]),
900
+ overageStatus: strOrNull(row["rl_overage_status"]),
901
+ overageReason: strOrNull(row["rl_overage_reason"]),
902
+ }));
903
+ }
904
+ //# sourceMappingURL=queries.js.map