Files
llmgw/internal/web/selfusage.go
T
Ilker ManapandClaude Opus 5.5 16a1e8b4d8 Show a person their own use and what is left of their budgets
The account page (was "Your keys") shows the last thirty days of the
person's own use - requests, tokens, spend and tokens per day - and, for
each place they can spend, what they used there and a bar for every
budget that applies: their own, a per-member one, or a project's, team's
or organization's shared with others, named as such. Bars turn amber past
four-fifths and red when spent. It sits under the keys now, above the
password form.

Spend is read from the usage table for the pool each limit counts
against, from the start of its period.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:51:13 +02:00

138 lines
3.8 KiB
Go

package web
import (
"context"
"fmt"
"html/template"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/accounting"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// SelfUsage is what a person's own page reads of their use. Optional: a
// store without it leaves the page showing limits alone.
type SelfUsage interface {
UsageOfUser(ctx context.Context, userID string, since time.Time, width time.Duration) ([]store.PlaceUsage, []store.Bucket, error)
SpendOfPool(ctx context.Context, l store.Limits, since time.Time) (int64, error)
}
// selfWindow is how far back a person's own page looks.
const selfWindow = 30 * 24 * time.Hour
// ownUse is the person's last thirty days, for the top of their page.
type ownUse struct {
Requests, Tokens int64
Spend string
Chart template.HTML
}
// budgetBar is one budget that applies, and how much of it is gone.
type budgetBar struct {
// Label says whose pool it is: the person's own, or one they share.
Label string
Used, Budget string
Period string
Percent int
// Near is past four-fifths, when it is worth colouring.
Near, Over bool
}
// placeUse is what a person spent in one place over the window.
type placeUse struct {
Requests, Tokens int64
Spend string
}
func (p *AccountPages) fillOwnUse(ctx context.Context, user store.User, d *profileData) {
su, ok := p.keys.(SelfUsage)
if !ok {
return
}
places, buckets, err := su.UsageOfUser(ctx, user.ID, time.Now().Add(-selfWindow), 24*time.Hour)
if err != nil {
p.log.Warn("could not read a person's own usage", "err", err)
return
}
u := &ownUse{}
var cost int64
byPlace := map[string]placeUse{}
for _, pl := range places {
u.Requests += pl.Requests
u.Tokens += pl.Tokens
cost += pl.CostMicros
byPlace[pl.ProjectID] = placeUse{pl.Requests, pl.Tokens, units(pl.CostMicros)}
}
u.Spend = units(cost)
s := Series{Colour: "--accent"}
for _, b := range buckets {
s.Values = append(s.Values, float64(b.Prompt+b.Completion))
s.Labels = append(s.Labels, b.Start.UTC().Format("Jan 2"))
}
if u.Requests > 0 {
u.Chart = BarChart(s, "tokens")
}
d.Own = u
for i := range d.LimitsBy {
c := &d.LimitsBy[i]
if pu, ok := byPlace[c.project]; ok {
c.Use = &pu
}
for _, l := range c.chain {
if l.Budget == nil || l.Scope == store.ScopeKey {
continue
}
start := accounting.PeriodStart(l.BudgetPeriod, time.Now())
spent, err := su.SpendOfPool(ctx, l, start)
if err != nil {
p.log.Warn("could not read a pool's spend", "scope", l.Scope, "err", err)
continue
}
bar := budgetBar{Label: poolLabel(l.Scope), Used: units(spent), Budget: units(*l.Budget),
Period: periodWord(l.BudgetPeriod)}
if *l.Budget > 0 {
bar.Percent = int(min(spent*100 / *l.Budget, 100))
bar.Near = spent*5 >= *l.Budget*4
bar.Over = spent >= *l.Budget
}
c.Budgets = append(c.Budgets, bar)
}
}
}
// poolLabel says whose budget a limit is, which is the thing a person needs
// to read a bar right: their own is theirs to spend, a project's is shared
// with everyone in it.
func poolLabel(s store.Scope) string {
switch s {
case store.ScopeUser:
return "Your budget, across everything you use"
case store.ScopeProjectMember:
return "Your budget in this project"
case store.ScopeTeamMember:
return "Your budget in this team"
case store.ScopeOrgMember:
return "Your budget in this organization"
case store.ScopeProject:
return "The project's budget, shared with its members"
case store.ScopeTeam:
return "The team's budget, shared across its projects"
case store.ScopeOrg:
return "The organization's budget, shared across it"
}
return string(s)
}
func periodWord(p store.Period) string {
switch p {
case store.PeriodDay:
return "today"
case store.PeriodMonth:
return "this month"
case store.PeriodNone:
return "in total"
}
return fmt.Sprintf("this %s", p)
}