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 }