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>
97 lines
2.9 KiB
Go
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
|
|
}
|