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>
308 lines
9.0 KiB
Go
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
|
|
}
|