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>
75 lines
2.6 KiB
Go
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
|
|
}
|