feat(admin): cost, group-by and latency in usage

Three gaps in one view.

Cost: token_usage.cost has been written on every call since quotas landed, and
tokens_by_model already returned it, but bucketed_totals and top_token_users
selected tokens only. An admin could set a USD quota in /admin/quotas and had
no way to see the spend it was capping. Buckets and top users now carry cost,
the endpoint reports a window total and a per-model split, and the chart takes
a Tokens/Cost toggle with a currency axis.

Group-by: the endpoint has supported group_by=model|agent|source from the
start and the UI only ever sent bucket=day. The selector is now wired, and a
grouped series pads missing buckets so a model that was idle on Tuesday plots
a zero instead of shifting its whole row one bar left.

Latency: surfaced from the columns the previous commit added, as p50/p95 with
a median time-to-first-token underneath. Unmeasured rows are excluded rather
than counted as zero, and the card says so when nothing was measured.

Also surfaces the prompt-cache hit rate, computed only over rows whose
provider reported a cache breakdown -- NULL means "not reported", and folding
those in as 0% would understate it.

Per-user drill-down: GET /api/admin/users/<id>/usage returns a daily series
plus splits by model and by flow, reachable from the top-users table and from
the user detail dialog. The detail dialog previously showed one tokens_30d
number, which answers neither "what is this person costing" nor "what is
driving it".
This commit is contained in:
arc53-machine committed 2026-09-22 10:33:56 +01:00
1 parent b17f40a993
commit a56f9df122
14 files changed
+1019 -61

No files matched your search

+33 -2
View File
@@ -287,6 +287,26 @@ class AdminUserSessionsResource(Resource):
return make_response(jsonify({"success": True, "revoked": ok}), 200)
@admin_ns.route("/admin/users/<string:user_id>/usage")
class AdminUserUsageResource(Resource):
@admin_required
def get(self, user_id):
"""One user's spend: daily series plus a split by model and by flow."""
days = max(1, min(365, _int_arg("days", 30)))
start = datetime.now(timezone.utc) - timedelta(days=days)
with db_readonly() as conn:
series = TokenUsageRepository(conn).bucketed_totals(
bucket_unit="day", user_id=user_id, timestamp_gte=start
)
breakdown = AdminStatsRepository(conn).user_usage_breakdown(
user_id, since=start
)
return make_response(
jsonify({"success": True, "days": days, "series": series, **breakdown}),
200,
)
@admin_ns.route("/admin/admins")
class AdminAdminsResource(Resource):
@admin_required
@@ -304,7 +324,12 @@ class AdminUsageResource(Resource):
@admin_required
def get(self):
"""Global token usage: time-bucketed series + total + top users."""
"""Global usage: time-bucketed series, totals, spend, latency, top users.
Every bucket carries both tokens and USD cost. Quota policies have
always been settable in dollars; until now nothing showed the spend
they were capping.
"""
days = max(1, min(365, _int_arg("days", 30)))
bucket = request.args.get("bucket", "day")
group_by = request.args.get("group_by", "none")
@@ -313,6 +338,7 @@ class AdminUsageResource(Resource):
start = datetime.now(timezone.utc) - timedelta(days=days)
with db_readonly() as conn:
usage_repo = TokenUsageRepository(conn)
stats_repo = AdminStatsRepository(conn)
series = usage_repo.bucketed_totals(
bucket_unit=bucket,
timestamp_gte=start,
@@ -321,7 +347,9 @@ class AdminUsageResource(Resource):
total = usage_repo.sum_tokens_in_range(
start=start, end=datetime.now(timezone.utc)
)
top_users = AdminStatsRepository(conn).top_token_users(since=start, limit=10)
by_model = usage_repo.tokens_by_model(start=start)
latency = stats_repo.latency_summary(since=start)
top_users = stats_repo.top_token_users(since=start, limit=10)
return make_response(
jsonify(
{
@@ -331,6 +359,9 @@ class AdminUsageResource(Resource):
"group_by": group_by,
"series": series,
"total_tokens": int(total),
"total_cost": round(sum(row["cost"] for row in by_model), 4),
"by_model": by_model,
"latency": latency,
"top_users": top_users,
}
),