Files

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
}