fix
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
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
|
||||
}
|
||||
@@ -405,6 +405,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/readiness", s.handleAdminReadiness)
|
||||
r.Get("/diagnostics", s.handleAdminDiagnostics)
|
||||
r.Get("/ai-calls", s.handleAdminListAICalls)
|
||||
r.Get("/ai-costs", s.handleAdminAICosts)
|
||||
r.Get("/ai-calls/{id}", s.handleAdminGetAICall)
|
||||
r.Get("/analytics", s.handleAdminAnalytics)
|
||||
r.Get("/jobs", s.handleAdminListJobs)
|
||||
|
||||
Reference in New Issue
Block a user