Files
llmgw/internal/web/admin_usage.go
T
Ilker ManapandClaude Opus 5.5 9f479b9a70 Show each team's first-token and latency percentiles, errors and refusals
Percentiles are read from the histograms kept with the usage sums and shown
per group on the usage tab and in the people page's panels. Requests refused
by a limit are recorded as 429s against their owner, free of charge, so a team
hitting its limits shows as such. Usage can be grouped by model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:50:16 +02:00

308 lines
9.0 KiB
Go

package web
import (
"context"
"fmt"
"net/http"
"sort"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/promq"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// UsageAdmin is the store half of the usage report.
type UsageAdmin interface {
UsageBucketsBy(ctx context.Context, g store.Grouping,
since time.Time, width time.Duration) ([]store.UsageSeries, error)
}
// StatsReader is how each group fared and how long its requests took; the
// store implements it. Optional: without it the usage table has no such
// columns.
type StatsReader interface {
StatsBy(ctx context.Context, g store.Grouping, since time.Time) (map[string]store.GroupStats, error)
}
// pctile is a percentile from the usage histograms as a person reads it:
// "180 ms", "4.2 s", "> 120 s" past the last bucket, "—" with nothing to read.
func pctile(q store.Quantiles, which int) string {
if q.N == 0 {
return "—"
}
v := q.P50
switch which {
case 95:
v = q.P95
case 99:
v = q.P99
if q.Over {
return fmt.Sprintf("> %d s", v/1000)
}
}
if v < 1000 {
return fmt.Sprintf("%d ms", v)
}
return fmt.Sprintf("%.1f s", float64(v)/1000)
}
// usageWindow is one length of history the page offers, and the slice it is cut
// into.
//
// The bucket width is chosen with the window rather than separately. A day in
// five-minute slices is 288 points across 720 pixels, which is a texture; a
// month in five-minute slices is 8,640, which is a smear. Roughly fifty to a
// hundred points is what a line of this size can actually show.
type usageWindow struct {
Key string
Label string
Span time.Duration
Width time.Duration
}
var usageWindows = []usageWindow{
{"24h", "last 24 hours", 24 * time.Hour, 20 * time.Minute},
{"7d", "last 7 days", 7 * 24 * time.Hour, 2 * time.Hour},
{"30d", "last 30 days", 30 * 24 * time.Hour, 12 * time.Hour},
}
func windowFor(key string) usageWindow {
for _, w := range usageWindows {
if w.Key == key {
return w
}
}
return usageWindows[1]
}
// usageGroup names one grouping for the chooser.
type usageGroup struct {
Key string
Label string
Selected bool
}
// usageRow is one group's totals, shown under the charts.
type usageRow struct {
ID string
Name string
Requests int64
Tokens int64
Spend string
Errors int64
// Stats is how its requests ended and how long they took; HasStats is
// false where the store does not say.
Stats store.GroupStats
HasStats bool
}
type adminUsageData struct {
Nav Nav
Groups []usageGroup
Windows []struct {
Key, Label string
Selected bool
}
By string
Window string
Requests TimeSeries
Tokens TimeSeries
Spend TimeSeries
Rows []usageRow
// Priced is false when no model has a price, which makes the spend chart a
// flat zero however busy the cluster is. Said rather than drawn, because a
// chart along the bottom reads as "nobody spent anything".
Priced bool
// GPUHeld and GPUHours are the cards each owner's models held over the
// window, from the metrics store; empty without one, or with no owned
// model on a GPU.
GPUHeld TimeSeries
GPUHours []gpuHoursRow
// HasStats adds outcomes and latency percentiles to the table.
HasStats bool
Error string
Version string
Now string
}
// RegisterAdminUsage mounts the usage report under /admin/usage.
//
// Separate from the quotas page although both read the same tree. Quotas is a
// page for writing a number and seeing whether it is being approached; this one
// answers "who is using the cluster, and when" — including per person, which is
// not a level quotas can be written on at all.
func (p *AccountPages) RegisterAdminUsage(mux *http.ServeMux, s UsageAdmin, priced Priced) {
p.usageStore, p.usagePriced = s, priced
mux.HandleFunc("GET /admin/usage", p.adminOnly(
func(w http.ResponseWriter, r *http.Request, admin store.User) {
p.renderPeople(w, r, admin, peopleView{Tab: tabUsage})
}))
}
// buildUsage is the cluster's use over time, as the usage tab shows it.
func (p *AccountPages) buildUsage(r *http.Request, s UsageAdmin, priced Priced) adminUsageData {
by := store.Grouping(r.URL.Query().Get("by"))
if !by.Valid() {
by = store.GroupByTeam
}
win := windowFor(r.URL.Query().Get("window"))
d := adminUsageData{
Nav: navFrom(r, "usage"),
By: string(by), Window: win.Key,
Priced: priced != nil && priced.AnythingPriced(),
Version: p.version,
Now: time.Now().UTC().Format(time.RFC3339),
}
for _, g := range store.Groupings() {
d.Groups = append(d.Groups, usageGroup{
Key: string(g), Label: groupLabel(g), Selected: g == by,
})
}
for _, uw := range usageWindows {
d.Windows = append(d.Windows, struct {
Key, Label string
Selected bool
}{uw.Key, uw.Label, uw.Key == win.Key})
}
rows, err := s.UsageBucketsBy(r.Context(), by, time.Now().Add(-win.Span), win.Width)
if err != nil {
p.log.Error("could not read usage over time", "by", by, "err", err)
d.Error = "could not read usage"
return d
}
d.Requests, d.Tokens, d.Spend, d.Rows = usageCharts(rows)
if sr, ok := s.(StatsReader); ok {
stats, err := sr.StatsBy(r.Context(), by, time.Now().Add(-win.Span))
if err != nil {
p.log.Warn("could not read latency and outcomes for the usage tab", "by", by, "err", err)
}
for i := range d.Rows {
d.Rows[i].Stats, d.Rows[i].HasStats = stats[d.Rows[i].ID], stats != nil
}
d.HasStats = stats != nil
}
d.GPUHeld, d.GPUHours = p.gpuHours(r.Context(), win)
return d
}
// gpuHoursRow is the GPU time one owner's models held over the window.
type gpuHoursRow struct {
Owner string
Hours float64
// Now is how many cards they hold at the end of the window.
Now float64
}
// gpuHours is the cards held by each owner's models over the window, drawn
// and summed into GPU-hours. From the metrics store, because what a model
// holds is placement's, not the request log's: a model nobody asks still
// holds its cards. As far back as the store keeps history, which for a
// default Prometheus is fifteen days.
func (p *AccountPages) gpuHours(ctx context.Context, win usageWindow) (TimeSeries, []gpuHoursRow) {
if p.history == nil {
return TimeSeries{}, nil
}
series, err := p.history.RangeQuery(ctx, `sum by (owner) (llmgw_gpu_assigned)`, "owner", win.Span, win.Width)
if err != nil || len(series) == 0 {
return TimeSeries{}, nil
}
var rows []gpuHoursRow
for i := range series {
if series[i].Name == "" {
series[i].Name = "no owner"
}
row := gpuHoursRow{Owner: series[i].Name, Now: series[i].Last()}
// Each point is the cards held at that instant, standing for the
// step that follows it.
row.Hours = series[i].Sum() * win.Width.Hours()
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool { return rows[i].Hours > rows[j].Hours })
return LineChart(series, "", 6), rows
}
// seriesOf turns each group's buckets into a line of one measure.
//
// One pass per measure rather than one query per measure: the buckets are
// already in memory and already share a clock, and three queries that could
// disagree about the window is a worse answer than three loops.
func seriesOf(rows []store.UsageSeries, value func(store.Bucket) float64) []promq.Series {
out := make([]promq.Series, 0, len(rows))
for _, u := range rows {
s := promq.Series{Name: displayName(u)}
for _, b := range u.Buckets {
s.Points = append(s.Points, promq.Point{At: b.Start, Value: value(b)})
}
out = append(out, s)
}
return out
}
// displayName is what a line and a row are labelled with.
//
// A person with no account is named rather than left blank: keys issued before
// accounts existed carry real traffic, and an unlabelled line in a legend is
// the one nobody can act on.
func displayName(u store.UsageSeries) string {
if u.Name != "" {
return u.Name
}
if u.Grouping == store.GroupByUser {
return "keys with no owner"
}
if u.ID != "" {
return u.ID
}
return "unknown"
}
func groupLabel(g store.Grouping) string {
switch g {
case store.GroupByOrg:
return "organization"
case store.GroupByTeam:
return "team"
case store.GroupByProject:
return "project"
case store.GroupByUser:
return "person"
case store.GroupByModel:
return "model"
default:
return string(g)
}
}
// usageCharts draws usage over time three ways, with the table beneath them.
func usageCharts(rows []store.UsageSeries) (req, tok, spend TimeSeries, table []usageRow) {
req = LineChartTotals(seriesOf(rows, func(b store.Bucket) float64 {
return float64(b.Requests)
}), "", 4)
tok = LineChartTotals(seriesOf(rows, func(b store.Bucket) float64 {
return float64(b.Prompt + b.Completion)
}), "", 4)
spend = LineChartTotals(seriesOf(rows, func(b store.Bucket) float64 {
// Shown in whole units, as every other spend figure is: a budget is
// written in units and a chart in micros would not compare with it.
return float64(b.CostMicros) / 1e6
}), "", 4)
for _, u := range rows {
table = append(table, usageRow{
ID: u.ID,
Name: displayName(u),
Requests: u.Requests,
Tokens: u.Tokens(),
Spend: unitsOf(u.CostMicros),
Errors: u.Errors,
})
}
return req, tok, spend, table
}