package web import ( "context" "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 { return english.pctile(q, which) } // pctile is the package's pctile in this language's numbers. func (l *Lang) 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 "> " + l.T("unit.s", l.Int(v/1000)) } } if v < 1000 { return l.T("unit.ms", l.Int(v)) } return l.T("unit.s", l.Num(float64(v)/1000, 1)) } // 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. // // Its name on the page is the catalogue's usage.window.KEY. type usageWindow struct { Key string Span time.Duration Width time.Duration } var usageWindows = []usageWindow{ {"24h", 24 * time.Hour, 20 * time.Minute}, {"7d", 7 * 24 * time.Hour, 2 * time.Hour}, {"30d", 30 * 24 * time.Hour, 12 * time.Hour}, } func init() { registerKeys("usage.window.24h", "usage.window.7d", "usage.window.30d", "usage.group.org", "usage.group.team", "usage.group.project", "usage.group.user", "usage.group.model") } // windowChoices are the windows as the chooser offers them, in l. func windowChoices(l *Lang, selected string) []windowChoice { out := make([]windowChoice, 0, len(usageWindows)) for _, uw := range usageWindows { out = append(out, windowChoice{uw.Key, l.T("usage.window." + uw.Key), uw.Key == selected}) } return out } // windowChoice is one window in the chooser. type windowChoice struct { Key, Label string Selected bool } 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 []windowChoice 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")) l := langOf(r) 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(l, g), Selected: g == by, }) } d.Windows = windowChoices(l, 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 = l.T("usage.err.read") return d } d.Requests, d.Tokens, d.Spend, d.Rows = usageChartsIn(l, 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(), l, 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, l *Lang, 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 = l.T("usage.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 l.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(l *Lang, 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(l, 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(l *Lang, u store.UsageSeries) string { if u.Name != "" { return u.Name } if u.Grouping == store.GroupByUser { return l.T("usage.no_owner_keys") } if u.ID != "" { return u.ID } return l.T("usage.unknown") } // groupLabel names a grouping in l. func groupLabel(l *Lang, g store.Grouping) string { switch g { case store.GroupByOrg, store.GroupByTeam, store.GroupByProject, store.GroupByUser, store.GroupByModel: return l.T("usage.group." + string(g)) default: return string(g) } } // usageCharts is usageChartsIn in English. func usageCharts(rows []store.UsageSeries) (req, tok, spend TimeSeries, table []usageRow) { return usageChartsIn(english, rows) } // usageChartsIn draws usage over time three ways, with the table beneath // them, the names and figures in l. func usageChartsIn(l *Lang, rows []store.UsageSeries) (req, tok, spend TimeSeries, table []usageRow) { req = l.LineChartTotals(seriesOf(l, rows, func(b store.Bucket) float64 { return float64(b.Requests) }), "", 4) tok = l.LineChartTotals(seriesOf(l, rows, func(b store.Bucket) float64 { return float64(b.Prompt + b.Completion) }), "", 4) spend = l.LineChartTotals(seriesOf(l, 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(l, u), Requests: u.Requests, Tokens: u.Tokens(), Spend: l.Units(u.CostMicros), Errors: u.Errors, }) } return req, tok, spend, table }