Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
127 lines
4.4 KiB
Go
127 lines
4.4 KiB
Go
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
|
|
}
|