Files
llmgw/internal/store/usagescope_test.go
T
Ilker ManapandClaude Opus 5 a83493bddb Show what a group spent, and fix the reports that never ran on PostgreSQL
Quotas can be written on an organization, a team or a project, and nothing
could show what any of them had used: usage came back per key, per user and per
model only. A quota whose consumption cannot be seen is one nobody reviews
until the day it refuses somebody — and since the previous commit made a group
quota a shared pool, it is now a number several people are spending against
without being able to look at it.

`llmgw ctl usage --by team` sums every key beneath each group, with the budget
that level sets beside it.

Beside it, and only when that level sets one. A team bounded by its
organization's budget has none of its own, and printing the organization's
against each of its teams would read as though each had that much — the exact
misreading this work exists to remove.

Joined through api_keys rather than read from usage.project_id: that column
records where the key was when the request was served, while the quota is
written against the tree. They agree until a key is moved, and a report that
disagrees with the limit shown beside it is the useless one.

Three existing reports were fixed on the way, all failing on PostgreSQL and all
passing in tests, because the tests run on SQLite:

  column "k.name" must appear in the GROUP BY clause    (UsageSince)
  column "us.email" must appear in the GROUP BY clause  (UsageByUser)
  column "usage.ts" must appear in the GROUP BY clause  (UsageBuckets)

SQLite picks an arbitrary row for an ungrouped column and carries on; PostgreSQL
refuses. The first two were selecting names they had not grouped. The third
grouped "ts / ?" while selecting "(ts / ?) * ?", which PostgreSQL matches as
text and so saw a bare ts — it is grouped by ordinal now, the same expression by
construction. The dashboard's usage charts have been empty on the cluster this
whole time, with the reason only in the log.

All four queries were run against the cluster's own PostgreSQL before this was
committed, because passing on SQLite is what they were already doing.

The grouping level picks from a fixed table of levels rather than being
interpolated, so an unknown one is an error and not a fragment of SQL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 12:26:14 +02:00

159 lines
4.9 KiB
Go

package store
import (
"context"
"path/filepath"
"testing"
"time"
)
// twoTeams is one org, two teams, a project each, a key each.
func twoTeams(t *testing.T) *DB {
t.Helper()
ctx := context.Background()
s, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "u.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
must := func(err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
must(s.PutOrg(ctx, Org{ID: "o", Name: "Acme"}))
must(s.PutTeam(ctx, Team{ID: "t1", OrgID: "o", Name: "platform"}))
must(s.PutTeam(ctx, Team{ID: "t2", OrgID: "o", Name: "research"}))
must(s.PutProject(ctx, Project{ID: "p1", TeamID: "t1", Name: "gateway"}))
must(s.PutProject(ctx, Project{ID: "p2", TeamID: "t2", Name: "labs"}))
for id, proj := range map[string]string{"k1": "p1", "k2": "p1", "k3": "p2"} {
_, k, _ := NewKey(id, "n", proj)
k.CreatedAt = time.Now()
must(s.CreateKey(ctx, k))
}
must(s.WriteUsage(ctx, []Usage{
{At: time.Now(), KeyID: "k1", ProjectID: "p1", Model: "m", ServedModel: "m",
PromptTokens: 10, CompletionTokens: 5, CostMicros: 600, Status: 200},
{At: time.Now(), KeyID: "k2", ProjectID: "p1", Model: "m", ServedModel: "m",
PromptTokens: 10, CompletionTokens: 5, CostMicros: 400, Status: 500},
{At: time.Now(), KeyID: "k3", ProjectID: "p2", Model: "m", ServedModel: "m",
PromptTokens: 1, CompletionTokens: 1, CostMicros: 100, Status: 200},
}))
return s
}
func byID(rows []ScopeUsage, id string) (ScopeUsage, bool) {
for _, r := range rows {
if r.ID == id {
return r, true
}
}
return ScopeUsage{}, false
}
func TestATeamsSpendIsTheSumOfItsKeys(t *testing.T) {
s := twoTeams(t)
rows, err := s.UsageByScope(t.Context(), ScopeTeam, time.Now().Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
// Two members of one team, spending separately, is one number for the team.
// Per-key totals could not answer this at all.
got, ok := byID(rows, "t1")
if !ok {
t.Fatalf("the team is missing from %+v", rows)
}
if got.CostMicros != 1000 {
t.Errorf("expected both members summed, got %d", got.CostMicros)
}
if got.Keys != 2 {
t.Errorf("expected two keys counted, got %d", got.Keys)
}
if got.Errors != 1 {
t.Errorf("expected the one failure counted, got %d", got.Errors)
}
if got.Name != "platform" {
t.Errorf("the group should be named, got %q", got.Name)
}
}
func TestTeamsAreNotAddedTogether(t *testing.T) {
s := twoTeams(t)
rows, _ := s.UsageByScope(t.Context(), ScopeTeam, time.Now().Add(-time.Hour))
if len(rows) != 2 {
t.Fatalf("expected a row per team, got %+v", rows)
}
other, _ := byID(rows, "t2")
if other.CostMicros != 100 {
t.Errorf("the second team's spend leaked, got %d", other.CostMicros)
}
}
func TestTheOrgRollsUpEveryTeamBeneathIt(t *testing.T) {
s := twoTeams(t)
rows, err := s.UsageByScope(t.Context(), ScopeOrg, time.Now().Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].CostMicros != 1100 {
t.Errorf("expected one org totalling 1100, got %+v", rows)
}
}
func TestTheQuotaComesBackBesideTheSpend(t *testing.T) {
s := twoTeams(t)
budget := int64(2000)
if err := s.PutLimits(t.Context(), Limits{
Scope: ScopeTeam, ScopeID: "t1", Budget: &budget, BudgetPeriod: PeriodMonth,
}); err != nil {
t.Fatal(err)
}
rows, _ := s.UsageByScope(t.Context(), ScopeTeam, time.Now().Add(-time.Hour))
got, _ := byID(rows, "t1")
// Spend without the quota beside it is a number nobody can act on. Half of
// a budget is the answer; 1000 micro-units on its own is trivia.
if got.Budget == nil || *got.Budget != 2000 {
t.Fatalf("the budget set on the team should come back, got %+v", got.Budget)
}
frac, ok := got.Spent()
if !ok || frac != 0.5 {
t.Errorf("expected half the budget used, got %v %v", frac, ok)
}
}
func TestAGroupWithNoBudgetOfItsOwnShowsNone(t *testing.T) {
s := twoTeams(t)
budget := int64(9000)
if err := s.PutLimits(t.Context(), Limits{
Scope: ScopeOrg, ScopeID: "o", Budget: &budget, BudgetPeriod: PeriodMonth,
}); err != nil {
t.Fatal(err)
}
rows, _ := s.UsageByScope(t.Context(), ScopeTeam, time.Now().Add(-time.Hour))
got, _ := byID(rows, "t1")
// The org's budget bounds this team but is not the team's to spend.
// Printing 9000 beside each of two teams would read as 18000 available,
// which is the exact misreading this work exists to remove.
if got.Budget != nil {
t.Errorf("a team with no budget of its own should show none, got %d", *got.Budget)
}
if _, ok := got.Spent(); ok {
t.Error("there is no fraction to report without a budget at this level")
}
}
func TestGroupingBySomethingThatIsNotALevelIsRefused(t *testing.T) {
s := twoTeams(t)
// Never interpolated: the grouping picks from a fixed table of levels, so
// an unknown one is an error rather than a fragment of SQL.
if _, err := s.UsageByScope(t.Context(), Scope("key; DROP TABLE usage"), time.Now()); err == nil {
t.Error("expected an unknown grouping to be refused")
}
}