Files
llmgw/internal/cli/usage.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

75 lines
2.6 KiB
Go

package cli
import (
"encoding/json"
"fmt"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
// newUsageCmd shows what each group of the tenancy tree spent.
//
// Quotas could be written on an organization, a team or a project long before
// anything 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.
func newUsageCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
var by string
var since time.Duration
c := &cobra.Command{
Use: "usage",
Short: "What each organization, team or project has spent",
Long: "Grouped by a level of the tenancy tree, which is the level a quota is\n" +
"written on. Spend is summed across every key beneath it, so two people\n" +
"sharing a team budget appear as one number — the number that will refuse\n" +
"them.\n\n" +
"The budget column is what that level sets, and is blank when it sets none.\n" +
"A team bounded only by its organization has no budget of its own, and\n" +
"printing the organization's beside each of its teams would read as though\n" +
"each had that much to spend.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
var rows []adminapi.ScopeUsageView
path := fmt.Sprintf("/api/v1/usage?by=%s&since=%s", by, since)
if err := client().get(cmd.Context(), path, &rows); err != nil {
return err
}
if *asJSON {
return json.NewEncoder(cmd.OutOrStdout()).Encode(rows)
}
if len(rows) == 0 {
fmt.Fprintf(cmd.OutOrStdout(), "No %s used anything in the last %s.\n", by, since)
return nil
}
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tID\tKEYS\tREQUESTS\tERRORS\tTOKENS\tSPEND\tBUDGET\tUSED\tLAST")
for _, u := range rows {
budget, used := "—", "—"
if u.Budget != nil {
budget = fmt.Sprintf("%.2f", float64(*u.Budget)/1e6)
if u.BudgetPeriod != "" {
budget += "/" + u.BudgetPeriod
}
}
if u.Fraction != nil {
used = fmt.Sprintf("%.0f%%", *u.Fraction*100)
}
fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%d\t%d\t%.2f\t%s\t%s\t%s\n",
u.Name, u.ID, u.Keys, u.Requests, u.Errors,
u.Prompt+u.Completion, float64(u.CostMicros)/1e6,
budget, used, age(u.LastAt))
}
return w.Flush()
},
}
c.Flags().StringVar(&by, "by", "team", "level to group by: org, team or project")
c.Flags().DurationVar(&since, "since", 24*time.Hour, "how far back to total")
return c
}