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)
}