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>
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"})
|
|
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)})
|
|
}
|
|
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
|
|
}
|