package cli import ( "encoding/json" "fmt" "net/url" "os" "text/tabwriter" "time" "github.com/spf13/cobra" "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" "gitea.mynodes.xyz/ilker/llmgw/internal/store" ) // 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 newUsageExportCmd(client func() *ctlClient) *cobra.Command { var from, to, period, by, output string c := &cobra.Command{ Use: "export", Short: "Usage as CSV, by period and by organization, team, project, user, key or model", Long: "Totals from the hourly and daily sums, so it reaches back past the request\n" + "records' retention. Periods are UTC. Cost is given in micro-units and in\n" + "units; names, not ids, except for keys.", Example: " llmgw ctl usage export --from 2026-09-01 --to 2026-10-01 --by project,model > september.csv\n" + " llmgw ctl usage export --period month --by org --output 2026.csv", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { q := url.Values{"period": {period}, "by": {by}, "format": {"json"}} if from != "" { q.Set("from", from) } if to != "" { q.Set("to", to) } var rows []store.ExportRow if err := client().get(cmd.Context(), "/api/v1/usage/export?"+q.Encode(), &rows); err != nil { return err } out := cmd.OutOrStdout() if output != "" { f, err := os.Create(output) if err != nil { return err } defer f.Close() out = f } adminapi.WriteExportCSV(out, rows) if output != "" { cmd.PrintErrf("%d rows written to %s\n", len(rows), output) } return nil }, } f := c.Flags() f.StringVar(&from, "from", "", "first day, 2026-09-01 (default: the first of this month)") f.StringVar(&to, "to", "", "the day after the last, 2026-10-01 (default: now)") f.StringVar(&period, "period", "day", "hour, day or month") f.StringVar(&by, "by", "project,model", "any of org, team, project, user, key, model, comma-separated") f.StringVarP(&output, "output", "o", "", "write to this file instead of standard output") return c } 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") c.AddCommand(newUsageExportCmd(client)) return c }