Files
llmgw/internal/store/export.go
Ilker ManapandClaude Opus 5.5 c87e413360 A ComfyUI model may make meshes: from a picture, with its licence inside
G3, the part that needs no model: a ComfyUI model's comfyui.output (or
external.output) says pictures or meshes. A request for a 3D model — a
job at /v1/3d/generations — carries the picture as base64; at the replica
it is uploaded to ComfyUI's input directory (the gate lets /upload/image
through), the workflow is filled in with its name, the seed and the
resolution, and the mesh the workflow saves comes back with its type,
which is the file the job keeps. comfy.Client reads every list of files an
output names, not "images" only, and types a file by its extension where
ComfyUI sends octet-stream.

A model's license (name, url, notice) is said in /v1/models and in the
job, and written into a GLB's asset.extras, since the file travels without
the API; a mesh it cannot be written into is refused. per_mesh in
auth.pricing, usage.meshes in the rows, the sums and the export
(migration 0045). A picture asked of a mesh model, or a mesh of a picture
model, is refused saying which it makes.

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

159 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
Meshes 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, meshes 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), SUM(meshes)
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, &x.meshes)
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
t.Meshes += x.meshes
}
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
}