244 lines
8.2 KiB
Go
244 lines
8.2 KiB
Go
package httpapi
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"net/http"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/aiaudit"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Admin AI cost report: what each tenant and each user costs, plus the total.
|
||
|
|
//
|
||
|
|
// Reads ai_usage_daily, the durable rollup written alongside every captured call.
|
||
|
|
// ai_call_logs (full prompts) is pruned after a week; this survives for
|
||
|
|
// aiaudit.UsageRetentionDays (3 years), so quarter- and year-scale questions work.
|
||
|
|
|
||
|
|
const (
|
||
|
|
adminAICostsTimeout = 30 * time.Second
|
||
|
|
adminAICostsMaxDays = 1200 // slightly over the 3-year retention
|
||
|
|
adminAICostsMaxRows = 500
|
||
|
|
adminAICostsDefDays = 30
|
||
|
|
)
|
||
|
|
|
||
|
|
// handleAdminAICosts returns spend grouped by company and by user, with totals.
|
||
|
|
// GET /api/admin/ai-costs?days=30&from=&to=&company_id=
|
||
|
|
func (s *Server) handleAdminAICosts(w http.ResponseWriter, r *http.Request) {
|
||
|
|
ctx, cancel := context.WithTimeout(r.Context(), adminAICostsTimeout)
|
||
|
|
defer cancel()
|
||
|
|
if s.Pool == nil {
|
||
|
|
Error(w, http.StatusServiceUnavailable, "database unavailable")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
q := r.URL.Query()
|
||
|
|
from, to, err := adminCostRange(q.Get("from"), q.Get("to"), q.Get("days"))
|
||
|
|
if err != nil {
|
||
|
|
Error(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
where := []string{"day >= $1", "day <= $2"}
|
||
|
|
args := []any{from, to}
|
||
|
|
if raw := strings.TrimSpace(q.Get("company_id")); raw != "" {
|
||
|
|
id, perr := uuid.Parse(raw)
|
||
|
|
if perr != nil {
|
||
|
|
Error(w, http.StatusBadRequest, "invalid company_id")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
args = append(args, id)
|
||
|
|
where = append(where, "company_id = $"+strconv.Itoa(len(args)))
|
||
|
|
}
|
||
|
|
whereSQL := strings.Join(where, " AND ")
|
||
|
|
|
||
|
|
totals, err := s.scanCostTotals(ctx, whereSQL, args)
|
||
|
|
if err != nil {
|
||
|
|
Error(w, http.StatusInternalServerError, "load ai costs failed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
byCompany, err := s.scanCostGroup(ctx, costGroupCompany, whereSQL, args)
|
||
|
|
if err != nil {
|
||
|
|
Error(w, http.StatusInternalServerError, "load ai costs by company failed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
byUser, err := s.scanCostGroup(ctx, costGroupUser, whereSQL, args)
|
||
|
|
if err != nil {
|
||
|
|
Error(w, http.StatusInternalServerError, "load ai costs by user failed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
byModel, err := s.scanCostGroup(ctx, costGroupModel, whereSQL, args)
|
||
|
|
if err != nil {
|
||
|
|
Error(w, http.StatusInternalServerError, "load ai costs by model failed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
JSON(w, http.StatusOK, map[string]any{
|
||
|
|
"from": from.Format("2006-01-02"),
|
||
|
|
"to": to.Format("2006-01-02"),
|
||
|
|
"currency": "USD",
|
||
|
|
"totals": totals,
|
||
|
|
"by_company": byCompany,
|
||
|
|
"by_user": byUser,
|
||
|
|
"by_model": byModel,
|
||
|
|
"usage_retention_days": aiaudit.UsageRetentionDays,
|
||
|
|
"prompt_retention_days": aiaudit.RetentionDays,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func adminCostRange(fromRaw, toRaw, daysRaw string) (time.Time, time.Time, error) {
|
||
|
|
today := time.Now().UTC().Truncate(24 * time.Hour)
|
||
|
|
to := today
|
||
|
|
if s := strings.TrimSpace(toRaw); s != "" {
|
||
|
|
t, err := time.Parse("2006-01-02", s)
|
||
|
|
if err != nil {
|
||
|
|
return time.Time{}, time.Time{}, errBadRequest("invalid to date (YYYY-MM-DD)")
|
||
|
|
}
|
||
|
|
to = t.UTC()
|
||
|
|
}
|
||
|
|
if s := strings.TrimSpace(fromRaw); s != "" {
|
||
|
|
f, err := time.Parse("2006-01-02", s)
|
||
|
|
if err != nil {
|
||
|
|
return time.Time{}, time.Time{}, errBadRequest("invalid from date (YYYY-MM-DD)")
|
||
|
|
}
|
||
|
|
from := f.UTC()
|
||
|
|
if from.After(to) {
|
||
|
|
return time.Time{}, time.Time{}, errBadRequest("from must not be after to")
|
||
|
|
}
|
||
|
|
if to.Sub(from) > time.Duration(adminAICostsMaxDays)*24*time.Hour {
|
||
|
|
return time.Time{}, time.Time{}, errBadRequest("range too large")
|
||
|
|
}
|
||
|
|
return from, to, nil
|
||
|
|
}
|
||
|
|
days := adminAICostsDefDays
|
||
|
|
if s := strings.TrimSpace(daysRaw); s != "" {
|
||
|
|
n, err := strconv.Atoi(s)
|
||
|
|
if err != nil || n <= 0 {
|
||
|
|
return time.Time{}, time.Time{}, errBadRequest("invalid days")
|
||
|
|
}
|
||
|
|
days = n
|
||
|
|
}
|
||
|
|
if days > adminAICostsMaxDays {
|
||
|
|
days = adminAICostsMaxDays
|
||
|
|
}
|
||
|
|
return to.AddDate(0, 0, -(days - 1)), to, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type badRequestErr string
|
||
|
|
|
||
|
|
func (e badRequestErr) Error() string { return string(e) }
|
||
|
|
|
||
|
|
func errBadRequest(msg string) error { return badRequestErr(msg) }
|
||
|
|
|
||
|
|
func (s *Server) scanCostTotals(ctx context.Context, whereSQL string, args []any) (map[string]any, error) {
|
||
|
|
var calls, failed, promptTok, cachedTok, outTok, totalTok, costMicros int64
|
||
|
|
var companies, users int64
|
||
|
|
err := s.Pool.QueryRow(ctx, `
|
||
|
|
SELECT COALESCE(sum(calls),0), COALESCE(sum(failed_calls),0),
|
||
|
|
COALESCE(sum(prompt_tokens),0), COALESCE(sum(cached_prompt_tokens),0),
|
||
|
|
COALESCE(sum(completion_tokens),0), COALESCE(sum(total_tokens),0),
|
||
|
|
COALESCE(sum(cost_micros),0),
|
||
|
|
count(DISTINCT company_id), count(DISTINCT user_id)
|
||
|
|
FROM ai_usage_daily WHERE `+whereSQL, args...).
|
||
|
|
Scan(&calls, &failed, &promptTok, &cachedTok, &outTok, &totalTok, &costMicros, &companies, &users)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return costRow(map[string]any{
|
||
|
|
"companies": companies,
|
||
|
|
"users": users,
|
||
|
|
}, calls, failed, promptTok, cachedTok, outTok, totalTok, costMicros), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type costGroup string
|
||
|
|
|
||
|
|
const (
|
||
|
|
costGroupCompany costGroup = "company"
|
||
|
|
costGroupUser costGroup = "user"
|
||
|
|
costGroupModel costGroup = "model"
|
||
|
|
)
|
||
|
|
|
||
|
|
func (s *Server) scanCostGroup(ctx context.Context, group costGroup, whereSQL string, args []any) ([]map[string]any, error) {
|
||
|
|
var sql string
|
||
|
|
switch group {
|
||
|
|
case costGroupCompany:
|
||
|
|
sql = `
|
||
|
|
SELECT u.company_id::text, COALESCE(c.name, ''), '', '',
|
||
|
|
COALESCE(sum(u.calls),0), COALESCE(sum(u.failed_calls),0),
|
||
|
|
COALESCE(sum(u.prompt_tokens),0), COALESCE(sum(u.cached_prompt_tokens),0),
|
||
|
|
COALESCE(sum(u.completion_tokens),0), COALESCE(sum(u.total_tokens),0),
|
||
|
|
COALESCE(sum(u.cost_micros),0)
|
||
|
|
FROM ai_usage_daily u
|
||
|
|
LEFT JOIN companies c ON c.id = u.company_id
|
||
|
|
WHERE ` + whereSQL + `
|
||
|
|
GROUP BY u.company_id, c.name
|
||
|
|
ORDER BY COALESCE(sum(u.cost_micros),0) DESC
|
||
|
|
LIMIT ` + strconv.Itoa(adminAICostsMaxRows)
|
||
|
|
case costGroupUser:
|
||
|
|
sql = `
|
||
|
|
SELECT u.user_id::text, COALESCE(usr.email, ''), u.company_id::text, COALESCE(c.name, ''),
|
||
|
|
COALESCE(sum(u.calls),0), COALESCE(sum(u.failed_calls),0),
|
||
|
|
COALESCE(sum(u.prompt_tokens),0), COALESCE(sum(u.cached_prompt_tokens),0),
|
||
|
|
COALESCE(sum(u.completion_tokens),0), COALESCE(sum(u.total_tokens),0),
|
||
|
|
COALESCE(sum(u.cost_micros),0)
|
||
|
|
FROM ai_usage_daily u
|
||
|
|
LEFT JOIN users usr ON usr.id = u.user_id
|
||
|
|
LEFT JOIN companies c ON c.id = u.company_id
|
||
|
|
WHERE ` + whereSQL + `
|
||
|
|
GROUP BY u.user_id, usr.email, u.company_id, c.name
|
||
|
|
ORDER BY COALESCE(sum(u.cost_micros),0) DESC
|
||
|
|
LIMIT ` + strconv.Itoa(adminAICostsMaxRows)
|
||
|
|
default:
|
||
|
|
sql = `
|
||
|
|
SELECT u.model, u.model, '', '',
|
||
|
|
COALESCE(sum(u.calls),0), COALESCE(sum(u.failed_calls),0),
|
||
|
|
COALESCE(sum(u.prompt_tokens),0), COALESCE(sum(u.cached_prompt_tokens),0),
|
||
|
|
COALESCE(sum(u.completion_tokens),0), COALESCE(sum(u.total_tokens),0),
|
||
|
|
COALESCE(sum(u.cost_micros),0)
|
||
|
|
FROM ai_usage_daily u
|
||
|
|
WHERE ` + whereSQL + `
|
||
|
|
GROUP BY u.model
|
||
|
|
ORDER BY COALESCE(sum(u.cost_micros),0) DESC
|
||
|
|
LIMIT ` + strconv.Itoa(adminAICostsMaxRows)
|
||
|
|
}
|
||
|
|
|
||
|
|
rows, err := s.Pool.Query(ctx, sql, args...)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
out := []map[string]any{}
|
||
|
|
for rows.Next() {
|
||
|
|
var id, label, companyID, companyName string
|
||
|
|
var calls, failed, promptTok, cachedTok, outTok, totalTok, costMicros int64
|
||
|
|
if err := rows.Scan(&id, &label, &companyID, &companyName,
|
||
|
|
&calls, &failed, &promptTok, &cachedTok, &outTok, &totalTok, &costMicros); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
base := map[string]any{"id": id, "label": label}
|
||
|
|
if group == costGroupUser {
|
||
|
|
base["company_id"] = companyID
|
||
|
|
base["company_name"] = companyName
|
||
|
|
}
|
||
|
|
out = append(out, costRow(base, calls, failed, promptTok, cachedTok, outTok, totalTok, costMicros))
|
||
|
|
}
|
||
|
|
return out, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
// costRow returns cost as integer micros AND a display-ready USD float. Callers
|
||
|
|
// must sum micros, never the float — that is the whole reason cost is stored as an
|
||
|
|
// integer.
|
||
|
|
func costRow(base map[string]any, calls, failed, promptTok, cachedTok, outTok, totalTok, costMicros int64) map[string]any {
|
||
|
|
base["calls"] = calls
|
||
|
|
base["failed_calls"] = failed
|
||
|
|
base["prompt_tokens"] = promptTok
|
||
|
|
base["cached_prompt_tokens"] = cachedTok
|
||
|
|
base["completion_tokens"] = outTok
|
||
|
|
base["total_tokens"] = totalTok
|
||
|
|
base["cost_micros"] = costMicros
|
||
|
|
base["cost_usd"] = float64(costMicros) / float64(aiaudit.MicrosPerUSD)
|
||
|
|
return base
|
||
|
|
}
|