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.
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/dist/cli/claude.js +326 -0
- package/dist/cli/claude.js.map +1 -0
- package/dist/cli/config.js +59 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/login.js +130 -0
- package/dist/cli/login.js.map +1 -0
- package/dist/cli/logout.js +20 -0
- package/dist/cli/logout.js.map +1 -0
- package/dist/cli/whoami.js +34 -0
- package/dist/cli/whoami.js.map +1 -0
- package/dist/server/adapters/anthropic.js +74 -0
- package/dist/server/adapters/anthropic.js.map +1 -0
- package/dist/server/adapters/fireworks.js +54 -0
- package/dist/server/adapters/fireworks.js.map +1 -0
- package/dist/server/adapters/index.js +16 -0
- package/dist/server/adapters/index.js.map +1 -0
- package/dist/server/adapters/registry.js +38 -0
- package/dist/server/adapters/registry.js.map +1 -0
- package/dist/server/adapters/rewrite.js +152 -0
- package/dist/server/adapters/rewrite.js.map +1 -0
- package/dist/server/adapters/url.js +24 -0
- package/dist/server/adapters/url.js.map +1 -0
- package/dist/server/api/auth.js +213 -0
- package/dist/server/api/auth.js.map +1 -0
- package/dist/server/api/live.js +108 -0
- package/dist/server/api/live.js.map +1 -0
- package/dist/server/api/models.js +129 -0
- package/dist/server/api/models.js.map +1 -0
- package/dist/server/api/oauth.js +300 -0
- package/dist/server/api/oauth.js.map +1 -0
- package/dist/server/api/routes.js +164 -0
- package/dist/server/api/routes.js.map +1 -0
- package/dist/server/api/routing-view.js +47 -0
- package/dist/server/api/routing-view.js.map +1 -0
- package/dist/server/auth/accounts.js +111 -0
- package/dist/server/auth/accounts.js.map +1 -0
- package/dist/server/auth/gateway-401.js +85 -0
- package/dist/server/auth/gateway-401.js.map +1 -0
- package/dist/server/auth/guard.js +74 -0
- package/dist/server/auth/guard.js.map +1 -0
- package/dist/server/auth/oauth.js +43 -0
- package/dist/server/auth/oauth.js.map +1 -0
- package/dist/server/auth/password.js +104 -0
- package/dist/server/auth/password.js.map +1 -0
- package/dist/server/auth/posture.js +145 -0
- package/dist/server/auth/posture.js.map +1 -0
- package/dist/server/auth/session.js +161 -0
- package/dist/server/auth/session.js.map +1 -0
- package/dist/server/bin/fest.js +589 -0
- package/dist/server/bin/fest.js.map +1 -0
- package/dist/server/config.js +109 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/credentials/provider.js +70 -0
- package/dist/server/credentials/provider.js.map +1 -0
- package/dist/server/credentials/resolve.js +89 -0
- package/dist/server/credentials/resolve.js.map +1 -0
- package/dist/server/http/body.js +66 -0
- package/dist/server/http/body.js.map +1 -0
- package/dist/server/http/errors.js +144 -0
- package/dist/server/http/errors.js.map +1 -0
- package/dist/server/http/headers.js +189 -0
- package/dist/server/http/headers.js.map +1 -0
- package/dist/server/http/pipe.js +97 -0
- package/dist/server/http/pipe.js.map +1 -0
- package/dist/server/http/server.js +226 -0
- package/dist/server/http/server.js.map +1 -0
- package/dist/server/http/sse.js +167 -0
- package/dist/server/http/sse.js.map +1 -0
- package/dist/server/http/static.js +138 -0
- package/dist/server/http/static.js.map +1 -0
- package/dist/server/ingest/live-bus.js +67 -0
- package/dist/server/ingest/live-bus.js.map +1 -0
- package/dist/server/ingest/sink.js +141 -0
- package/dist/server/ingest/sink.js.map +1 -0
- package/dist/server/log.js +31 -0
- package/dist/server/log.js.map +1 -0
- package/dist/server/pipeline/count-tokens.js +48 -0
- package/dist/server/pipeline/count-tokens.js.map +1 -0
- package/dist/server/pipeline/dispatch.js +138 -0
- package/dist/server/pipeline/dispatch.js.map +1 -0
- package/dist/server/pipeline/passthrough.js +392 -0
- package/dist/server/pipeline/passthrough.js.map +1 -0
- package/dist/server/pipeline/preflight.js +66 -0
- package/dist/server/pipeline/preflight.js.map +1 -0
- package/dist/server/pipeline/record.js +49 -0
- package/dist/server/pipeline/record.js.map +1 -0
- package/dist/server/pipeline/substitute.js +287 -0
- package/dist/server/pipeline/substitute.js.map +1 -0
- package/dist/server/routes/resolve.js +176 -0
- package/dist/server/routes/resolve.js.map +1 -0
- package/dist/server/routes/table.js +247 -0
- package/dist/server/routes/table.js.map +1 -0
- package/dist/server/routes/watch.js +101 -0
- package/dist/server/routes/watch.js.map +1 -0
- package/dist/server/secret/fingerprint.js +107 -0
- package/dist/server/secret/fingerprint.js.map +1 -0
- package/dist/server/secret/non-persistable.js +69 -0
- package/dist/server/secret/non-persistable.js.map +1 -0
- package/dist/server/store/audit.js +53 -0
- package/dist/server/store/audit.js.map +1 -0
- package/dist/server/store/bootstrap.js +53 -0
- package/dist/server/store/bootstrap.js.map +1 -0
- package/dist/server/store/db.js +157 -0
- package/dist/server/store/db.js.map +1 -0
- package/dist/server/store/ids.js +14 -0
- package/dist/server/store/ids.js.map +1 -0
- package/dist/server/store/migrations/001-init.sql +213 -0
- package/dist/server/store/migrations/002-feed-and-error-indexes.sql +26 -0
- package/dist/server/store/migrations/003-routing.sql +42 -0
- package/dist/server/store/migrations/004-admin-auth.sql +72 -0
- package/dist/server/store/migrations/005-notional-cost.sql +28 -0
- package/dist/server/store/queries.js +904 -0
- package/dist/server/store/queries.js.map +1 -0
- package/dist/server/store/retention.js +79 -0
- package/dist/server/store/retention.js.map +1 -0
- package/dist/server/store/seed.js +203 -0
- package/dist/server/store/seed.js.map +1 -0
- package/dist/server/store/tokens.js +133 -0
- package/dist/server/store/tokens.js.map +1 -0
- package/dist/server/store/write.js +330 -0
- package/dist/server/store/write.js.map +1 -0
- package/dist/server/usage/accumulator.js +209 -0
- package/dist/server/usage/accumulator.js.map +1 -0
- package/dist/server/usage/cost.js +57 -0
- package/dist/server/usage/cost.js.map +1 -0
- package/dist/server/usage/prices/catalog.js +136 -0
- package/dist/server/usage/prices/catalog.js.map +1 -0
- package/dist/server/usage/prices/refresh.js +125 -0
- package/dist/server/usage/prices/refresh.js.map +1 -0
- package/dist/server/usage/prices/snapshot.json.gz +0 -0
- package/dist/server/usage/prices/table.js +102 -0
- package/dist/server/usage/prices/table.js.map +1 -0
- package/dist/server/usage/pricing.js +103 -0
- package/dist/server/usage/pricing.js.map +1 -0
- package/dist/shared/api.js +19 -0
- package/dist/shared/api.js.map +1 -0
- package/dist/shared/base-models.js +28 -0
- package/dist/shared/base-models.js.map +1 -0
- package/dist/shared/demotion-vars.js +17 -0
- package/dist/shared/demotion-vars.js.map +1 -0
- package/dist/shared/series.js +50 -0
- package/dist/shared/series.js.map +1 -0
- package/dist/shared/types.js +40 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/web/dist/assets/index-8HlKsJqt.css +2 -0
- package/dist/web/dist/assets/index-DQ7fyPVm.js +56 -0
- package/dist/web/dist/assets/index-DQ7fyPVm.js.map +1 -0
- package/dist/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/dist/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/dist/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/dist/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/dist/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/dist/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/dist/web/dist/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/dist/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/dist/web/dist/index.html +44 -0
- package/dist/web/dist/nearform-icon.svg +4 -0
- package/package.json +82 -0
- 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
|