Files
llmgw/internal/control/adminapi/export.go
T
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

97 lines
2.9 KiB
Go

package adminapi
import (
"context"
"encoding/csv"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// Exporter totals usage for an export.
type Exporter interface {
UsageExport(ctx context.Context, from, to time.Time, period string, by []string) ([]store.ExportRow, error)
}
// usageExport answers GET /api/v1/usage/export?from=2026-09-01&to=2026-10-01
// &period=day&by=project,model as CSV, or JSON with format=json.
func (a *API) usageExport(w http.ResponseWriter, r *http.Request) {
ex, ok := a.usage.(Exporter)
if !ok {
writeError(w, http.StatusNotImplemented, "this control plane keeps no usage records")
return
}
q := r.URL.Query()
day := func(s string, def time.Time) (time.Time, error) {
if s == "" {
return def, nil
}
if t, err := time.Parse("2006-01-02", s); err == nil {
return t, nil
}
return time.Parse(time.RFC3339, s)
}
now := time.Now().UTC()
from, err := day(q.Get("from"), time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC))
if err != nil {
writeError(w, http.StatusBadRequest, "from: a date, 2026-09-01")
return
}
to, err := day(q.Get("to"), now)
if err != nil {
writeError(w, http.StatusBadRequest, "to: a date, 2026-10-01")
return
}
period := q.Get("period")
if period == "" {
period = "day"
}
var by []string
if b := q.Get("by"); b != "" {
by = strings.Split(b, ",")
}
rows, err := ex.UsageExport(r.Context(), from, to, period, by)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if q.Get("format") == "json" {
writeJSON(w, http.StatusOK, rows)
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="llmgw-usage-%s-%s.csv"`,
from.Format("20060102"), to.Format("20060102")))
WriteExportCSV(w, rows)
}
// WriteExportCSV writes rows with a header; cost in units as well as micros,
// so a spreadsheet needs no arithmetic.
func WriteExportCSV(w io.Writer, rows []store.ExportRow) {
c := csv.NewWriter(w)
_ = c.Write([]string{"period_start", "org", "team", "project", "user", "key", "model",
"requests", "errors", "limited", "prompt_tokens", "completion_tokens", "cost_micros", "cost", "images", "meshes"})
for _, r := range rows {
_ = c.Write([]string{r.Period.Format(time.RFC3339), safe(r.Org), safe(r.Team), safe(r.Project), safe(r.User),
r.Key, safe(r.Model), i(r.Requests), i(r.Errors), i(r.Limited), i(r.Prompt), i(r.Completion),
i(r.CostMicros), strconv.FormatFloat(float64(r.CostMicros)/1e6, 'f', 6, 64), i(r.Images), i(r.Meshes)})
}
c.Flush()
}
func i(n int64) string { return strconv.FormatInt(n, 10) }
// safe keeps a name from being read as a formula by a spreadsheet: a cell
// starting with = + - @ is one in Excel and LibreOffice.
func safe(s string) string {
if s != "" && strings.ContainsRune("=+-@\t\r", rune(s[0])) {
return "'" + s
}
return s
}