Files
llmgw/internal/store/export.go
T
Ilker ManapandClaude Opus 5.5 eaea671f23 The usage sums and exports count pictures
The five-minute, hourly and daily sums gain images (migration 0042), and
the CSV and JSON exports a column for them: once request rows were past
their retention, an export said what pictures cost but not how many there
were. The pictures made before are added to the sums from their rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 10:36:19 +02:00

157 lines
4.1 KiB
Go

package store
import (
"context"
"database/sql"
"fmt"
"slices"
"strings"
"time"
)
// ExportRow is one line of a usage export: a period, the place, person, key
// and model it is about — those asked for, the rest empty — and what was used.
type ExportRow struct {
Period time.Time
Org string
Team string
Project string
User string
Key string
Model string
Requests int64
Errors int64
Limited int64
Prompt int64
Completion int64
CostMicros int64
Images int64
}
// ExportDims are what an export can be broken down by.
var ExportDims = []string{"org", "team", "project", "user", "key", "model"}
// UsageExport totals usage between two moments, by period — hour, day or
// month, in UTC — and by the dimensions asked for. It reads the hourly and
// daily sums, so request records past their retention are still counted.
func (s *DB) UsageExport(ctx context.Context, from, to time.Time, period string, by []string) ([]ExportRow, error) {
for _, d := range by {
if !slices.Contains(ExportDims, d) {
return nil, fmt.Errorf("%q is not one of %s", d, strings.Join(ExportDims, ", "))
}
}
table := "usage_1d"
switch period {
case "hour":
table = "usage_1h"
case "day", "month":
default:
return nil, fmt.Errorf("period %q is not hour, day or month", period)
}
type raw struct {
bucket int64
key, user, project, model string
requests, errors, limited, prompt, completion, cost int64
images int64
}
//nolint:gosec // table is one of two literals above
rows, err := queryAll(ctx, s.db, `SELECT bucket, key_id, user_id, project_id, served_model,
SUM(requests), SUM(errors), SUM(limited), SUM(prompt_tokens), SUM(completion_tokens), SUM(cost_micros),
SUM(images)
FROM `+table+` WHERE bucket >= ? AND bucket < ?
GROUP BY bucket, key_id, user_id, project_id, served_model`,
[]any{millis(from), millis(to)}, func(r *sql.Rows) (raw, error) {
var x raw
err := r.Scan(&x.bucket, &x.key, &x.user, &x.project, &x.model,
&x.requests, &x.errors, &x.limited, &x.prompt, &x.completion, &x.cost, &x.images)
return x, err
})
if err != nil {
return nil, err
}
// Names, not ids: an export is read by people and spreadsheets.
type place struct{ org, team, project string }
places := map[string]place{"": {project: "personal"}}
tree, err := s.Tree(ctx)
if err != nil {
return nil, err
}
for _, o := range tree {
for _, t := range o.Teams {
for _, p := range t.Projects {
places[p.ID] = place{o.Name, t.Name, p.Name}
}
}
}
emails := map[string]string{}
users, err := s.Users(ctx, "")
if err != nil {
return nil, err
}
for _, u := range users {
emails[u.ID] = u.Email
}
has := func(d string) bool { return slices.Contains(by, d) }
totals := map[ExportRow]*ExportRow{}
var order []ExportRow
for _, x := range rows {
at := fromMillis(x.bucket).UTC()
if period == "month" {
at = time.Date(at.Year(), at.Month(), 1, 0, 0, 0, 0, time.UTC)
}
pl := places[x.project]
k := ExportRow{Period: at}
if has("org") {
k.Org = pl.org
}
if has("team") {
k.Team = pl.team
}
if has("project") {
k.Project = pl.project
if pl.project == "" {
k.Project = x.project
}
}
if has("user") {
k.User = emails[x.user]
if k.User == "" {
k.User = x.user
}
}
if has("key") {
k.Key = x.key
}
if has("model") {
k.Model = x.model
}
t, ok := totals[k]
if !ok {
row := k
t = &row
totals[k] = t
order = append(order, k)
}
t.Requests += x.requests
t.Errors += x.errors
t.Limited += x.limited
t.Prompt += x.prompt
t.Completion += x.completion
t.CostMicros += x.cost
t.Images += x.images
}
out := make([]ExportRow, 0, len(order))
for _, k := range order {
out = append(out, *totals[k])
}
slices.SortFunc(out, func(a, b ExportRow) int {
if c := a.Period.Compare(b.Period); c != 0 {
return c
}
return strings.Compare(a.Org+a.Team+a.Project+a.User+a.Key+a.Model, b.Org+b.Team+b.Project+b.User+b.Key+b.Model)
})
return out, nil
}