ai-servers/llm-gateway/internal/dashboard/api.go

308 lines
8.4 KiB
Go

package dashboard
import (
"encoding/json"
"net/http"
"time"
"llm-gateway/internal/auth"
"llm-gateway/internal/storage"
)
// Exported types for template rendering and JSON API.
type Period struct {
Requests int `json:"requests"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CostUSD float64 `json:"cost_usd"`
Errors int `json:"errors"`
CachedHits int `json:"cached_hits"`
}
type SummaryResult struct {
Today *Period `json:"today"`
Week *Period `json:"week"`
Month *Period `json:"month"`
}
type ModelStats struct {
Model string `json:"model"`
Requests int `json:"requests"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CostUSD float64 `json:"cost_usd"`
AvgLatencyMS float64 `json:"avg_latency_ms"`
}
type ProviderStats struct {
Provider string `json:"provider"`
Requests int `json:"requests"`
Successes int `json:"successes"`
Errors int `json:"errors"`
AvgLatencyMS float64 `json:"avg_latency_ms"`
CostUSD float64 `json:"cost_usd"`
}
type TokenUsageStats struct {
TokenName string `json:"token_name"`
Requests int `json:"requests"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CostUSD float64 `json:"cost_usd"`
}
type StatsAPI struct {
db *storage.DB
authStore *auth.Store
}
func NewStatsAPI(db *storage.DB, authStore *auth.Store) *StatsAPI {
return &StatsAPI{db: db, authStore: authStore}
}
// TokenNamesForUser returns the token names that belong to the user.
// Admins get nil (no filter), non-admins get their token names.
func (s *StatsAPI) TokenNamesForUser(user *auth.User) []string {
if user == nil || user.IsAdmin {
return nil
}
tokens, err := s.authStore.ListAPITokens(user.ID)
if err != nil {
return []string{"__none__"}
}
names := make([]string, len(tokens))
for i, t := range tokens {
names[i] = t.Name
}
if len(names) == 0 {
return []string{"__none__"}
}
return names
}
// tokenNamesForUser returns token names from request context (for HTTP handlers).
func (s *StatsAPI) tokenNamesForUser(r *http.Request) []string {
user := auth.UserFromContext(r.Context())
return s.TokenNamesForUser(user)
}
func buildTokenFilter(tokenNames []string) (string, []any) {
if tokenNames == nil {
return "", nil
}
placeholders := ""
args := make([]any, len(tokenNames))
for i, n := range tokenNames {
if i > 0 {
placeholders += ","
}
placeholders += "?"
args[i] = n
}
return " AND token_name IN (" + placeholders + ")", args
}
// Data-fetching methods (used by both JSON handlers and template handlers).
func (s *StatsAPI) GetSummary(tokenNames []string) *SummaryResult {
now := time.Now()
todayStart := now.Truncate(24 * time.Hour).Unix()
weekStart := now.AddDate(0, 0, -7).Unix()
monthStart := now.AddDate(0, -1, 0).Unix()
tokenFilter, filterArgs := buildTokenFilter(tokenNames)
result := &SummaryResult{
Today: &Period{},
Week: &Period{},
Month: &Period{},
}
periods := map[string]struct {
since int64
period *Period
}{
"today": {todayStart, result.Today},
"week": {weekStart, result.Week},
"month": {monthStart, result.Month},
}
for _, p := range periods {
args := append([]any{p.since}, filterArgs...)
row := s.db.QueryRow(`SELECT
COUNT(*),
COALESCE(SUM(input_tokens), 0),
COALESCE(SUM(output_tokens), 0),
COALESCE(SUM(cost_usd), 0),
COALESCE(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN cached = 1 THEN 1 ELSE 0 END), 0)
FROM request_logs WHERE timestamp >= ?`+tokenFilter, args...)
row.Scan(&p.period.Requests, &p.period.InputTokens, &p.period.OutputTokens, &p.period.CostUSD, &p.period.Errors, &p.period.CachedHits)
}
return result
}
func (s *StatsAPI) GetModels(tokenNames []string) []ModelStats {
since := time.Now().AddDate(0, 0, -30).Unix()
tokenFilter, filterArgs := buildTokenFilter(tokenNames)
args := append([]any{since}, filterArgs...)
rows, err := s.db.Query(`SELECT
model,
COUNT(*) as requests,
COALESCE(SUM(input_tokens), 0) as input_tokens,
COALESCE(SUM(output_tokens), 0) as output_tokens,
COALESCE(SUM(cost_usd), 0) as cost,
COALESCE(AVG(latency_ms), 0) as avg_latency
FROM request_logs WHERE timestamp >= ?`+tokenFilter+`
GROUP BY model ORDER BY requests DESC`, args...)
if err != nil {
return nil
}
defer rows.Close()
var results []ModelStats
for rows.Next() {
var m ModelStats
rows.Scan(&m.Model, &m.Requests, &m.InputTokens, &m.OutputTokens, &m.CostUSD, &m.AvgLatencyMS)
results = append(results, m)
}
return results
}
func (s *StatsAPI) GetProviders(tokenNames []string) []ProviderStats {
since := time.Now().AddDate(0, 0, -30).Unix()
tokenFilter, filterArgs := buildTokenFilter(tokenNames)
args := append([]any{since}, filterArgs...)
rows, err := s.db.Query(`SELECT
provider,
COUNT(*) as requests,
COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) as successes,
COALESCE(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END), 0) as errors,
COALESCE(AVG(latency_ms), 0) as avg_latency,
COALESCE(SUM(cost_usd), 0) as cost
FROM request_logs WHERE timestamp >= ?`+tokenFilter+`
GROUP BY provider ORDER BY requests DESC`, args...)
if err != nil {
return nil
}
defer rows.Close()
var results []ProviderStats
for rows.Next() {
var p ProviderStats
rows.Scan(&p.Provider, &p.Requests, &p.Successes, &p.Errors, &p.AvgLatencyMS, &p.CostUSD)
results = append(results, p)
}
return results
}
func (s *StatsAPI) GetTokenUsage(tokenNames []string) []TokenUsageStats {
since := time.Now().AddDate(0, 0, -30).Unix()
tokenFilter, filterArgs := buildTokenFilter(tokenNames)
args := append([]any{since}, filterArgs...)
rows, err := s.db.Query(`SELECT
token_name,
COUNT(*) as requests,
COALESCE(SUM(input_tokens), 0) as input_tokens,
COALESCE(SUM(output_tokens), 0) as output_tokens,
COALESCE(SUM(cost_usd), 0) as cost
FROM request_logs WHERE timestamp >= ?`+tokenFilter+`
GROUP BY token_name ORDER BY requests DESC`, args...)
if err != nil {
return nil
}
defer rows.Close()
var results []TokenUsageStats
for rows.Next() {
var t TokenUsageStats
rows.Scan(&t.TokenName, &t.Requests, &t.InputTokens, &t.OutputTokens, &t.CostUSD)
results = append(results, t)
}
return results
}
// JSON HTTP handlers (thin wrappers).
func (s *StatsAPI) Summary(w http.ResponseWriter, r *http.Request) {
tokenNames := s.tokenNamesForUser(r)
result := s.GetSummary(tokenNames)
writeJSON(w, result)
}
func (s *StatsAPI) Models(w http.ResponseWriter, r *http.Request) {
tokenNames := s.tokenNamesForUser(r)
results := s.GetModels(tokenNames)
writeJSON(w, results)
}
func (s *StatsAPI) Providers(w http.ResponseWriter, r *http.Request) {
tokenNames := s.tokenNamesForUser(r)
results := s.GetProviders(tokenNames)
writeJSON(w, results)
}
func (s *StatsAPI) Tokens(w http.ResponseWriter, r *http.Request) {
tokenNames := s.tokenNamesForUser(r)
results := s.GetTokenUsage(tokenNames)
writeJSON(w, results)
}
func (s *StatsAPI) Timeseries(w http.ResponseWriter, r *http.Request) {
period := r.URL.Query().Get("period")
var since int64
var groupFmt string
switch period {
case "7d":
since = time.Now().AddDate(0, 0, -7).Unix()
groupFmt = "%Y-%m-%d"
case "30d":
since = time.Now().AddDate(0, -1, 0).Unix()
groupFmt = "%Y-%m-%d"
default:
since = time.Now().Add(-24 * time.Hour).Unix()
groupFmt = "%Y-%m-%d %H:00"
}
tokenNames := s.tokenNamesForUser(r)
tokenFilter, filterArgs := buildTokenFilter(tokenNames)
args := append([]any{since}, filterArgs...)
rows, err := s.db.Query(`SELECT
strftime('`+groupFmt+`', timestamp, 'unixepoch') as bucket,
COUNT(*) as requests,
COALESCE(SUM(cost_usd), 0) as cost,
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens
FROM request_logs WHERE timestamp >= ?`+tokenFilter+`
GROUP BY bucket ORDER BY bucket`, args...)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
type point struct {
Bucket string `json:"bucket"`
Requests int `json:"requests"`
CostUSD float64 `json:"cost_usd"`
TotalTokens int `json:"total_tokens"`
}
var results []point
for rows.Next() {
var p point
rows.Scan(&p.Bucket, &p.Requests, &p.CostUSD, &p.TotalTokens)
results = append(results, p)
}
writeJSON(w, results)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}