308 lines
8.4 KiB
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)
|
|
}
|