Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
97 lines
2.8 KiB
Go
97 lines
2.8 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"})
|
|
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)})
|
|
}
|
|
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
|
|
}
|