feat(gateway): add provider health tracking and dashboard stats API

This commit is contained in:
Ray Andrew 2026-02-15 03:09:14 -06:00
parent 98822eadb8
commit 2b6e20544b
Signed by: rayandrew
SSH key fingerprint: SHA256:EUCV+qCSqkap8rR+p+zGjxHfKI06G0GJKgo1DIOniQY
14 changed files with 1309 additions and 74 deletions

View file

@ -90,6 +90,9 @@ func main() {
} }
log.Printf("Registered %d models", len(cfg.Models)) log.Printf("Registered %d models", len(cfg.Models))
// Provider health tracker
healthTracker := provider.NewHealthTracker(5 * time.Minute)
// Auth store (static tokens checked in-memory, not seeded to DB) // Auth store (static tokens checked in-memory, not seeded to DB)
var staticTokens []auth.StaticToken var staticTokens []auth.StaticToken
for _, t := range cfg.Tokens { for _, t := range cfg.Tokens {
@ -114,12 +117,20 @@ func main() {
m := metrics.New() m := metrics.New()
// Handlers // Handlers
proxyHandler := proxy.NewHandler(registry, asyncLogger, c, m, cfg) proxyHandler := proxy.NewHandler(registry, asyncLogger, c, m, cfg, healthTracker)
modelsHandler := proxy.NewModelsHandler(registry) modelsHandler := proxy.NewModelsHandler(registry)
proxyAuth := proxy.NewAuthMiddleware(authStore) proxyAuth := proxy.NewAuthMiddleware(authStore)
rateLimiter := proxy.NewRateLimiter(db) rateLimiter := proxy.NewRateLimiter(db)
statsAPI := dashboard.NewStatsAPI(db, authStore) statsAPI := dashboard.NewStatsAPI(db, authStore)
statsAPI.SetHealthTracker(healthTracker)
if c != nil {
statsAPI.SetCache(c)
}
dash := dashboard.NewDashboard(authStore, statsAPI) dash := dashboard.NewDashboard(authStore, statsAPI)
dash.SetRegistry(registry)
if c != nil {
dash.SetCache(c)
}
// Router // Router
r := chi.NewRouter() r := chi.NewRouter()
@ -172,6 +183,8 @@ func main() {
// Dashboard pages (HTMX) // Dashboard pages (HTMX)
r.Get("/dashboard", dash.DashboardPage) r.Get("/dashboard", dash.DashboardPage)
r.Get("/logs", dash.LogsPage)
r.Get("/models", dash.ModelsPage)
r.Get("/tokens", dash.TokensPage) r.Get("/tokens", dash.TokensPage)
r.Get("/settings", dash.SettingsPage) r.Get("/settings", dash.SettingsPage)
@ -205,6 +218,11 @@ func main() {
r.Get("/api/stats/providers", statsAPI.Providers) r.Get("/api/stats/providers", statsAPI.Providers)
r.Get("/api/stats/tokens", statsAPI.Tokens) r.Get("/api/stats/tokens", statsAPI.Tokens)
r.Get("/api/stats/timeseries", statsAPI.Timeseries) r.Get("/api/stats/timeseries", statsAPI.Timeseries)
r.Get("/api/stats/logs", statsAPI.Logs)
r.Get("/api/stats/latency", statsAPI.Latency)
r.Get("/api/stats/cost-breakdown", statsAPI.CostBreakdown)
r.Get("/api/stats/provider-health", statsAPI.ProviderHealthHandler)
r.Get("/api/stats/cache", statsAPI.CacheStats)
// Admin-only: user management // Admin-only: user management
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {

View file

@ -56,6 +56,120 @@ func (c *Cache) Close() error {
return c.client.Close() return c.client.Close()
} }
// CacheStats holds cache statistics from the Valkey/Redis server.
type CacheStats struct {
Hits int64 `json:"hits"`
Misses int64 `json:"misses"`
HitRate float64 `json:"hit_rate"`
MemoryUsed string `json:"memory_used"`
Keys int64 `json:"keys"`
Connected bool `json:"connected"`
}
// Stats returns cache statistics by querying Valkey/Redis INFO.
func (c *Cache) Stats(ctx context.Context) *CacheStats {
stats := &CacheStats{}
// Check connectivity
if err := c.client.Ping(ctx).Err(); err != nil {
return stats
}
stats.Connected = true
// Parse INFO stats for hits/misses
info, err := c.client.Info(ctx, "stats").Result()
if err == nil {
stats.Hits = parseInfoInt(info, "keyspace_hits")
stats.Misses = parseInfoInt(info, "keyspace_misses")
total := stats.Hits + stats.Misses
if total > 0 {
stats.HitRate = float64(stats.Hits) / float64(total)
}
}
// Parse INFO memory
memInfo, err := c.client.Info(ctx, "memory").Result()
if err == nil {
stats.MemoryUsed = parseInfoString(memInfo, "used_memory_human")
}
// Parse INFO keyspace
ksInfo, err := c.client.Info(ctx, "keyspace").Result()
if err == nil {
stats.Keys = parseKeyspaceKeys(ksInfo)
}
return stats
}
func parseInfoInt(info, key string) int64 {
prefix := key + ":"
for _, line := range splitLines(info) {
if len(line) > len(prefix) && line[:len(prefix)] == prefix {
var v int64
fmt.Sscanf(line[len(prefix):], "%d", &v)
return v
}
}
return 0
}
func parseInfoString(info, key string) string {
prefix := key + ":"
for _, line := range splitLines(info) {
if len(line) > len(prefix) && line[:len(prefix)] == prefix {
val := line[len(prefix):]
// Trim trailing \r
if len(val) > 0 && val[len(val)-1] == '\r' {
val = val[:len(val)-1]
}
return val
}
}
return ""
}
func parseKeyspaceKeys(info string) int64 {
// Format: db0:keys=123,expires=45,avg_ttl=6789
for _, line := range splitLines(info) {
if len(line) > 3 && line[:2] == "db" {
prefix := "keys="
idx := -1
for i := 0; i <= len(line)-len(prefix); i++ {
if line[i:i+len(prefix)] == prefix {
idx = i + len(prefix)
break
}
}
if idx >= 0 {
end := idx
for end < len(line) && line[end] >= '0' && line[end] <= '9' {
end++
}
var v int64
fmt.Sscanf(line[idx:end], "%d", &v)
return v
}
}
}
return 0
}
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
if start < len(s) {
lines = append(lines, s[start:])
}
return lines
}
func (c *Cache) cacheKey(model string, requestBody []byte) string { func (c *Cache) cacheKey(model string, requestBody []byte) string {
h := sha256.New() h := sha256.New()
h.Write([]byte(model)) h.Write([]byte(model))

View file

@ -3,9 +3,13 @@ package dashboard
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"sort"
"strconv"
"time" "time"
"llm-gateway/internal/auth" "llm-gateway/internal/auth"
"llm-gateway/internal/cache"
"llm-gateway/internal/provider"
"llm-gateway/internal/storage" "llm-gateway/internal/storage"
) )
@ -52,15 +56,70 @@ type TokenUsageStats struct {
CostUSD float64 `json:"cost_usd"` CostUSD float64 `json:"cost_usd"`
} }
// RequestLogEntry represents a single request log row.
type RequestLogEntry struct {
Timestamp int64 `json:"timestamp"`
TokenName string `json:"token_name"`
Model string `json:"model"`
Provider string `json:"provider"`
ProviderModel string `json:"provider_model"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CostUSD float64 `json:"cost_usd"`
LatencyMS int64 `json:"latency_ms"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
Streaming bool `json:"streaming"`
Cached bool `json:"cached"`
}
// LogsResult holds paginated logs.
type LogsResult struct {
Logs []RequestLogEntry `json:"logs"`
Page int `json:"page"`
TotalPages int `json:"total_pages"`
Total int `json:"total"`
}
// LatencyResult holds latency percentiles.
type LatencyResult struct {
P50 float64 `json:"p50"`
P95 float64 `json:"p95"`
P99 float64 `json:"p99"`
Avg float64 `json:"avg"`
Min float64 `json:"min"`
Max float64 `json:"max"`
}
// CostBreakdownEntry holds cost data grouped by day and dimension.
type CostBreakdownEntry struct {
Day string `json:"day"`
GroupBy string `json:"group_by"`
CostUSD float64 `json:"cost_usd"`
Requests int `json:"requests"`
}
type StatsAPI struct { type StatsAPI struct {
db *storage.DB db *storage.DB
authStore *auth.Store authStore *auth.Store
healthTracker *provider.HealthTracker
cache *cache.Cache
} }
func NewStatsAPI(db *storage.DB, authStore *auth.Store) *StatsAPI { func NewStatsAPI(db *storage.DB, authStore *auth.Store) *StatsAPI {
return &StatsAPI{db: db, authStore: authStore} return &StatsAPI{db: db, authStore: authStore}
} }
// SetHealthTracker sets the provider health tracker.
func (s *StatsAPI) SetHealthTracker(ht *provider.HealthTracker) {
s.healthTracker = ht
}
// SetCache sets the cache for stats.
func (s *StatsAPI) SetCache(c *cache.Cache) {
s.cache = c
}
// TokenNamesForUser returns the token names that belong to the user. // TokenNamesForUser returns the token names that belong to the user.
// Admins get nil (no filter), non-admins get their token names. // Admins get nil (no filter), non-admins get their token names.
func (s *StatsAPI) TokenNamesForUser(user *auth.User) []string { func (s *StatsAPI) TokenNamesForUser(user *auth.User) []string {
@ -227,6 +286,217 @@ func (s *StatsAPI) GetTokenUsage(tokenNames []string) []TokenUsageStats {
return results return results
} }
// GetLogs returns paginated request logs with filters.
func (s *StatsAPI) GetLogs(tokenNames []string, page int, model, token, status string) *LogsResult {
if page < 1 {
page = 1
}
limit := 50
offset := (page - 1) * limit
tokenFilter, filterArgs := buildTokenFilter(tokenNames)
where := "WHERE 1=1" + tokenFilter
args := make([]any, 0)
args = append(args, filterArgs...)
if model != "" {
where += " AND model = ?"
args = append(args, model)
}
if token != "" {
where += " AND token_name = ?"
args = append(args, token)
}
if status != "" {
where += " AND status = ?"
args = append(args, status)
}
// Get total count
var total int
countArgs := make([]any, len(args))
copy(countArgs, args)
s.db.QueryRow("SELECT COUNT(*) FROM request_logs "+where, countArgs...).Scan(&total)
totalPages := (total + limit - 1) / limit
if totalPages < 1 {
totalPages = 1
}
// Get page
query := `SELECT timestamp, token_name, model, provider, provider_model,
input_tokens, output_tokens, cost_usd, latency_ms, status,
COALESCE(error_message, ''), streaming, cached
FROM request_logs ` + where + ` ORDER BY timestamp DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := s.db.Query(query, args...)
if err != nil {
return &LogsResult{Logs: []RequestLogEntry{}, Page: page, TotalPages: totalPages, Total: total}
}
defer rows.Close()
var logs []RequestLogEntry
for rows.Next() {
var l RequestLogEntry
var streaming, cached int
rows.Scan(&l.Timestamp, &l.TokenName, &l.Model, &l.Provider, &l.ProviderModel,
&l.InputTokens, &l.OutputTokens, &l.CostUSD, &l.LatencyMS, &l.Status,
&l.ErrorMessage, &streaming, &cached)
l.Streaming = streaming == 1
l.Cached = cached == 1
logs = append(logs, l)
}
if logs == nil {
logs = []RequestLogEntry{}
}
return &LogsResult{
Logs: logs,
Page: page,
TotalPages: totalPages,
Total: total,
}
}
// GetDistinctModels returns distinct model names from logs.
func (s *StatsAPI) GetDistinctModels() []string {
rows, err := s.db.Query("SELECT DISTINCT model FROM request_logs ORDER BY model")
if err != nil {
return nil
}
defer rows.Close()
var models []string
for rows.Next() {
var m string
rows.Scan(&m)
models = append(models, m)
}
return models
}
// GetDistinctTokens returns distinct token names from logs.
func (s *StatsAPI) GetDistinctTokens() []string {
rows, err := s.db.Query("SELECT DISTINCT token_name FROM request_logs ORDER BY token_name")
if err != nil {
return nil
}
defer rows.Close()
var tokens []string
for rows.Next() {
var t string
rows.Scan(&t)
tokens = append(tokens, t)
}
return tokens
}
// GetLatency computes latency percentiles from request_logs.
func (s *StatsAPI) GetLatency(tokenNames []string, period, model, providerName string) *LatencyResult {
var since int64
switch period {
case "7d":
since = time.Now().AddDate(0, 0, -7).Unix()
case "30d":
since = time.Now().AddDate(0, -1, 0).Unix()
default:
since = time.Now().Add(-24 * time.Hour).Unix()
}
tokenFilter, filterArgs := buildTokenFilter(tokenNames)
where := "WHERE timestamp >= ? AND status = 'success'" + tokenFilter
args := []any{since}
args = append(args, filterArgs...)
if model != "" {
where += " AND model = ?"
args = append(args, model)
}
if providerName != "" {
where += " AND provider = ?"
args = append(args, providerName)
}
rows, err := s.db.Query("SELECT latency_ms FROM request_logs "+where+" ORDER BY latency_ms", args...)
if err != nil {
return &LatencyResult{}
}
defer rows.Close()
var latencies []float64
for rows.Next() {
var l float64
rows.Scan(&l)
latencies = append(latencies, l)
}
if len(latencies) == 0 {
return &LatencyResult{}
}
sort.Float64s(latencies)
n := len(latencies)
var sum float64
for _, l := range latencies {
sum += l
}
return &LatencyResult{
P50: latencies[n*50/100],
P95: latencies[n*95/100],
P99: latencies[min(n*99/100, n-1)],
Avg: sum / float64(n),
Min: latencies[0],
Max: latencies[n-1],
}
}
// GetCostBreakdown returns cost data grouped by day and dimension.
func (s *StatsAPI) GetCostBreakdown(tokenNames []string, period, groupBy string) []CostBreakdownEntry {
var since int64
switch period {
case "30d":
since = time.Now().AddDate(0, -1, 0).Unix()
case "7d":
since = time.Now().AddDate(0, 0, -7).Unix()
default:
since = time.Now().Add(-24 * time.Hour).Unix()
}
tokenFilter, filterArgs := buildTokenFilter(tokenNames)
groupCol := "model"
if groupBy == "token" {
groupCol = "token_name"
} else if groupBy == "provider" {
groupCol = "provider"
}
args := []any{since}
args = append(args, filterArgs...)
query := `SELECT date(timestamp, 'unixepoch') as day, ` + groupCol + `,
COALESCE(SUM(cost_usd), 0), COUNT(*)
FROM request_logs WHERE timestamp >= ?` + tokenFilter + `
GROUP BY day, ` + groupCol + ` ORDER BY day, ` + groupCol
rows, err := s.db.Query(query, args...)
if err != nil {
return nil
}
defer rows.Close()
var results []CostBreakdownEntry
for rows.Next() {
var e CostBreakdownEntry
rows.Scan(&e.Day, &e.GroupBy, &e.CostUSD, &e.Requests)
results = append(results, e)
}
return results
}
// JSON HTTP handlers (thin wrappers). // JSON HTTP handlers (thin wrappers).
func (s *StatsAPI) Summary(w http.ResponseWriter, r *http.Request) { func (s *StatsAPI) Summary(w http.ResponseWriter, r *http.Request) {
@ -302,6 +572,58 @@ func (s *StatsAPI) Timeseries(w http.ResponseWriter, r *http.Request) {
writeJSON(w, results) writeJSON(w, results)
} }
// Logs serves the paginated logs API.
func (s *StatsAPI) Logs(w http.ResponseWriter, r *http.Request) {
tokenNames := s.tokenNamesForUser(r)
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
model := r.URL.Query().Get("model")
token := r.URL.Query().Get("token")
status := r.URL.Query().Get("status")
result := s.GetLogs(tokenNames, page, model, token, status)
writeJSON(w, result)
}
// Latency serves latency percentiles API.
func (s *StatsAPI) Latency(w http.ResponseWriter, r *http.Request) {
tokenNames := s.tokenNamesForUser(r)
period := r.URL.Query().Get("period")
model := r.URL.Query().Get("model")
providerName := r.URL.Query().Get("provider")
result := s.GetLatency(tokenNames, period, model, providerName)
writeJSON(w, result)
}
// CostBreakdown serves cost breakdown API.
func (s *StatsAPI) CostBreakdown(w http.ResponseWriter, r *http.Request) {
tokenNames := s.tokenNamesForUser(r)
period := r.URL.Query().Get("period")
groupBy := r.URL.Query().Get("group_by")
if groupBy == "" {
groupBy = "model"
}
result := s.GetCostBreakdown(tokenNames, period, groupBy)
writeJSON(w, result)
}
// ProviderHealthHandler serves provider health status API.
func (s *StatsAPI) ProviderHealthHandler(w http.ResponseWriter, r *http.Request) {
if s.healthTracker == nil {
writeJSON(w, []provider.ProviderHealth{})
return
}
writeJSON(w, s.healthTracker.Status())
}
// CacheStats serves cache statistics API.
func (s *StatsAPI) CacheStats(w http.ResponseWriter, r *http.Request) {
if s.cache == nil {
writeJSON(w, map[string]any{"enabled": false})
return
}
stats := s.cache.Stats(r.Context())
writeJSON(w, stats)
}
func writeJSON(w http.ResponseWriter, v any) { func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v) json.NewEncoder(w).Encode(v)

View file

@ -5,9 +5,12 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"net/http" "net/http"
"strconv"
"time" "time"
"llm-gateway/internal/auth" "llm-gateway/internal/auth"
"llm-gateway/internal/cache"
"llm-gateway/internal/provider"
) )
//go:embed templates/*.html templates/partials/*.html //go:embed templates/*.html templates/partials/*.html
@ -20,9 +23,18 @@ var templateFuncs = template.FuncMap{
} }
return time.Unix(ts, 0).Format("2006-01-02") return time.Unix(ts, 0).Format("2006-01-02")
}, },
"formatTimeDetail": func(ts int64) string {
if ts == 0 {
return "never"
}
return time.Unix(ts, 0).Format("2006-01-02 15:04:05")
},
"addInt": func(a, b int) int { "addInt": func(a, b int) int {
return a + b return a + b
}, },
"subInt": func(a, b int) int {
return a - b
},
"formatCost": func(v float64) string { "formatCost": func(v float64) string {
if v == 0 { if v == 0 {
return "$0.00" return "$0.00"
@ -32,19 +44,87 @@ var templateFuncs = template.FuncMap{
} }
return fmt.Sprintf("$%.4f", v) return fmt.Sprintf("$%.4f", v)
}, },
"formatPrice": func(v float64) string {
if v == 0 {
return "-"
}
return fmt.Sprintf("$%.2f", v)
},
"formatPct": func(v float64) string {
return fmt.Sprintf("%.1f%%", v*100)
},
"budgetPct": func(spend, budget float64) float64 {
if budget <= 0 {
return 0
}
return spend / budget * 100
},
"budgetColor": func(pct float64) string {
if pct >= 80 {
return "#f87171"
}
if pct >= 50 {
return "#fbbf24"
}
return "#4ade80"
},
"seq": func(start, end int) []int {
var s []int
for i := start; i <= end; i++ {
s = append(s, i)
}
return s
},
"paginationStart": func(page, totalPages int) int {
start := page - 2
if start < 1 {
start = 1
}
if totalPages-start < 4 && totalPages > 4 {
start = totalPages - 4
}
return start
},
"paginationEnd": func(page, totalPages int) int {
start := page - 2
if start < 1 {
start = 1
}
end := start + 4
if end > totalPages {
end = totalPages
}
return end
},
} }
// PageData is the common data passed to all templates. // PageData is the common data passed to all templates.
type PageData struct { type PageData struct {
ActivePage string ActivePage string
User *auth.User User *auth.User
// Page-specific data // Dashboard data
Summary *SummaryResult Summary *SummaryResult
Models []ModelStats Models []ModelStats
Providers []ProviderStats Providers []ProviderStats
TokenStats []TokenUsageStats TokenStats []TokenUsageStats
ProviderHealth []provider.ProviderHealth
Latency *LatencyResult
CacheEnabled bool
CacheInfo *cache.CacheStats
// Tokens page data
Tokens []auth.APIToken Tokens []auth.APIToken
Users []auth.User TokenSpend map[string]float64
// Users page data
Users []auth.User
// Logs page data
LogsResult *LogsResult
LogModels []string
LogTokens []string
FilterModel string
FilterToken string
FilterStatus string
// Models routing page data
ModelRoutes []provider.ModelRouteInfo
} }
// Dashboard serves the HTMX-based dashboard pages. // Dashboard serves the HTMX-based dashboard pages.
@ -52,6 +132,8 @@ type Dashboard struct {
templates *template.Template templates *template.Template
authStore *auth.Store authStore *auth.Store
statsAPI *StatsAPI statsAPI *StatsAPI
registry *provider.Registry
cache *cache.Cache
} }
// NewDashboard creates a new Dashboard handler. // NewDashboard creates a new Dashboard handler.
@ -70,6 +152,16 @@ func NewDashboard(authStore *auth.Store, statsAPI *StatsAPI) *Dashboard {
} }
} }
// SetRegistry sets the provider registry for model routing display.
func (d *Dashboard) SetRegistry(r *provider.Registry) {
d.registry = r
}
// SetCache sets the cache reference for cache stats display.
func (d *Dashboard) SetCache(c *cache.Cache) {
d.cache = c
}
// LoginPage serves the login page. // LoginPage serves the login page.
func (d *Dashboard) LoginPage(w http.ResponseWriter, r *http.Request) { func (d *Dashboard) LoginPage(w http.ResponseWriter, r *http.Request) {
if !d.authStore.HasAnyUser() { if !d.authStore.HasAnyUser() {
@ -106,11 +198,66 @@ func (d *Dashboard) DashboardPage(w http.ResponseWriter, r *http.Request) {
Models: d.statsAPI.GetModels(tokenNames), Models: d.statsAPI.GetModels(tokenNames),
Providers: d.statsAPI.GetProviders(tokenNames), Providers: d.statsAPI.GetProviders(tokenNames),
TokenStats: d.statsAPI.GetTokenUsage(tokenNames), TokenStats: d.statsAPI.GetTokenUsage(tokenNames),
Latency: d.statsAPI.GetLatency(tokenNames, "24h", "", ""),
}
// Provider health
if d.statsAPI.healthTracker != nil {
data.ProviderHealth = d.statsAPI.healthTracker.Status()
}
// Cache stats
if d.cache != nil {
data.CacheEnabled = true
data.CacheInfo = d.cache.Stats(r.Context())
} }
d.renderDashboardPage(w, r, "partials/dashboard.html", data) d.renderDashboardPage(w, r, "partials/dashboard.html", data)
} }
// LogsPage serves the request logs view.
func (d *Dashboard) LogsPage(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
tokenNames := d.statsAPI.TokenNamesForUser(user)
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
model := r.URL.Query().Get("model")
token := r.URL.Query().Get("token")
status := r.URL.Query().Get("status")
data := PageData{
ActivePage: "logs",
User: user,
LogsResult: d.statsAPI.GetLogs(tokenNames, page, model, token, status),
LogModels: d.statsAPI.GetDistinctModels(),
LogTokens: d.statsAPI.GetDistinctTokens(),
FilterModel: model,
FilterToken: token,
FilterStatus: status,
}
d.renderDashboardPage(w, r, "partials/logs.html", data)
}
// ModelsPage serves the model routing table view.
func (d *Dashboard) ModelsPage(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
data := PageData{
ActivePage: "models",
User: user,
}
if d.registry != nil {
data.ModelRoutes = d.registry.AllRoutes()
}
d.renderDashboardPage(w, r, "partials/models-page.html", data)
}
// TokensPage serves the tokens management view. // TokensPage serves the tokens management view.
func (d *Dashboard) TokensPage(w http.ResponseWriter, r *http.Request) { func (d *Dashboard) TokensPage(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context()) user := auth.UserFromContext(r.Context())
@ -125,10 +272,17 @@ func (d *Dashboard) TokensPage(w http.ResponseWriter, r *http.Request) {
tokens = []auth.APIToken{} tokens = []auth.APIToken{}
} }
// Get today's spend for budget display
spend, _ := d.statsAPI.db.TodaySpendAll()
if spend == nil {
spend = make(map[string]float64)
}
d.renderDashboardPage(w, r, "partials/tokens.html", PageData{ d.renderDashboardPage(w, r, "partials/tokens.html", PageData{
ActivePage: "tokens", ActivePage: "tokens",
User: user, User: user,
Tokens: tokens, Tokens: tokens,
TokenSpend: spend,
}) })
} }

View file

@ -5,93 +5,228 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LLM Gateway</title> <title>LLM Gateway</title>
<script>
// Prevent flash of wrong theme
(function() {
var pref = localStorage.getItem('theme') || 'auto';
var effective = pref;
if (pref === 'auto') effective = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
if (effective === 'light') document.documentElement.setAttribute('data-theme', 'light');
})();
</script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx-ext-json-enc@2.0.3/json-enc.js"></script> <script src="https://unpkg.com/htmx-ext-json-enc@2.0.3/json-enc.js"></script>
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script> <script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style> <style>
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--border-color: #334155;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--text-heading: #f8fafc;
--text-subheading: #cbd5e1;
--accent-blue: #3b82f6;
--accent-blue-hover: #2563eb;
--accent-blue-bg: #3b82f620;
--accent-green: #4ade80;
--accent-green-bg: #4ade8020;
--accent-red: #f87171;
--accent-red-bg: #7f1d1d40;
--accent-red-border: #991b1b;
--accent-red-text: #fca5a5;
--accent-yellow: #fbbf24;
--accent-yellow-bg: #92400e40;
--accent-purple: #a78bfa;
--accent-purple-bg: #a78bfa20;
--success-bg: #14532d40;
--success-border: #166534;
--success-text: #86efac;
--modal-overlay: #00000080;
--chart-grid: #1e293b;
}
[data-theme="light"] {
--bg-primary: #f8fafc;
--bg-secondary: #ffffff;
--bg-tertiary: #e2e8f0;
--border-color: #cbd5e1;
--text-primary: #1e293b;
--text-secondary: #475569;
--text-muted: #94a3b8;
--text-heading: #0f172a;
--text-subheading: #334155;
--accent-blue: #2563eb;
--accent-blue-hover: #1d4ed8;
--accent-blue-bg: #dbeafe;
--accent-green: #16a34a;
--accent-green-bg: #dcfce7;
--accent-red: #dc2626;
--accent-red-bg: #fef2f2;
--accent-red-border: #fca5a5;
--accent-red-text: #991b1b;
--accent-yellow: #d97706;
--accent-yellow-bg: #fef3c7;
--accent-purple: #7c3aed;
--accent-purple-bg: #ede9fe;
--success-bg: #f0fdf4;
--success-border: #86efac;
--success-text: #166534;
--modal-overlay: #00000040;
--chart-grid: #e2e8f0;
}
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg-primary); color: var(--text-primary); min-height: 100vh; display: flex; }
/* Sidebar */ /* Sidebar */
.sidebar { width: 220px; background: #1e293b; border-right: 1px solid #334155; min-height: 100vh; display: flex; flex-direction: column; position: fixed; top: 0; left: 0; } .sidebar { width: 220px; background: var(--bg-secondary); border-right: 1px solid var(--border-color); min-height: 100vh; display: flex; flex-direction: column; position: fixed; top: 0; left: 0; }
.sidebar-brand { padding: 20px 16px; font-size: 1.1rem; font-weight: 700; color: #f8fafc; border-bottom: 1px solid #334155; } .sidebar-brand { padding: 20px 16px; font-size: 1.1rem; font-weight: 700; color: var(--text-heading); border-bottom: 1px solid var(--border-color); }
.sidebar-nav { flex: 1; padding: 12px 0; } .sidebar-nav { flex: 1; padding: 12px 0; }
.sidebar-nav a { display: block; padding: 10px 20px; color: #94a3b8; text-decoration: none; font-size: 0.9rem; transition: all 0.15s; } .sidebar-nav a { display: block; padding: 10px 20px; color: var(--text-secondary); text-decoration: none; font-size: 0.9rem; transition: all 0.15s; }
.sidebar-nav a:hover { background: #334155; color: #e2e8f0; } .sidebar-nav a:hover { background: var(--bg-tertiary); color: var(--text-primary); }
.sidebar-nav a.active { background: #3b82f620; color: #3b82f6; border-right: 3px solid #3b82f6; } .sidebar-nav a.active { background: var(--accent-blue-bg); color: var(--accent-blue); border-right: 3px solid var(--accent-blue); }
.sidebar-footer { padding: 16px; border-top: 1px solid #334155; } .sidebar-footer { padding: 16px; border-top: 1px solid var(--border-color); }
.sidebar-footer .user-info { font-size: 0.85rem; color: #94a3b8; margin-bottom: 8px; } .sidebar-footer .user-info { font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 8px; }
.sidebar-footer a { display: block; padding: 6px 0; color: #94a3b8; text-decoration: none; font-size: 0.85rem; } .sidebar-footer a { display: block; padding: 6px 0; color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; }
.sidebar-footer a:hover { color: #f87171; } .sidebar-footer a:hover { color: var(--accent-red); }
.theme-toggle { cursor: pointer; background: var(--bg-tertiary); border: 1px solid var(--border-color); color: var(--text-secondary); padding: 6px 12px; border-radius: 6px; font-size: 0.8rem; width: 100%; margin-bottom: 8px; }
.theme-toggle:hover { color: var(--text-primary); }
/* Main content */ /* Main content */
.main { flex: 1; margin-left: 220px; padding: 24px; min-height: 100vh; max-width: calc(100vw - 220px); overflow-x: hidden; } .main { flex: 1; margin-left: 220px; padding: 24px; min-height: 100vh; max-width: calc(100vw - 220px); overflow-x: hidden; }
/* Cards & tables */ /* Cards & tables */
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; max-width: 100%; } .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; max-width: 100%; }
.card { background: #1e293b; border-radius: 8px; padding: 16px; } .card { background: var(--bg-secondary); border-radius: 8px; padding: 16px; }
.card .label { font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; } .card .label { font-size: 0.75rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; }
.card .value { font-size: 1.5rem; font-weight: 700; margin-top: 4px; } .card .value { font-size: 1.5rem; font-weight: 700; margin-top: 4px; }
.card .sub { font-size: 0.75rem; color: #64748b; margin-top: 2px; } .card .sub { font-size: 0.75rem; color: var(--text-muted); margin-top: 2px; }
.section { background: #1e293b; border-radius: 8px; padding: 16px; margin-bottom: 16px; overflow-x: auto; } .section { background: var(--bg-secondary); border-radius: 8px; padding: 16px; margin-bottom: 16px; overflow-x: auto; }
.section h2 { font-size: 1.1rem; margin-bottom: 12px; color: #cbd5e1; } .section h2 { font-size: 1.1rem; margin-bottom: 12px; color: var(--text-subheading); }
.tabs { display: flex; gap: 8px; margin-bottom: 16px; } .tabs { display: flex; gap: 8px; margin-bottom: 16px; }
.tabs button { background: #1e293b; border: 1px solid #334155; color: #94a3b8; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; } .tabs button { background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-secondary); padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
.tabs button.active { background: #3b82f6; border-color: #3b82f6; color: #fff; } .tabs button.active { background: var(--accent-blue); border-color: var(--accent-blue); color: #fff; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th { text-align: left; padding: 8px; color: #94a3b8; border-bottom: 1px solid #334155; font-weight: 500; } th { text-align: left; padding: 8px; color: var(--text-secondary); border-bottom: 1px solid var(--border-color); font-weight: 500; }
td { padding: 8px; border-bottom: 1px solid #334155; } td { padding: 8px; border-bottom: 1px solid var(--border-color); }
.green { color: #4ade80; } .green { color: var(--accent-green); }
.red { color: #f87171; } .red { color: var(--accent-red); }
.blue { color: #60a5fa; } .blue { color: var(--accent-blue); }
.yellow { color: var(--accent-yellow); }
/* Buttons */ /* Buttons */
.btn { display: inline-block; padding: 10px 20px; border-radius: 6px; border: none; cursor: pointer; font-size: 0.9rem; font-weight: 500; text-decoration: none; } .btn { display: inline-block; padding: 10px 20px; border-radius: 6px; border: none; cursor: pointer; font-size: 0.9rem; font-weight: 500; text-decoration: none; }
.btn-primary { background: #3b82f6; color: #fff; } .btn-primary { background: var(--accent-blue); color: #fff; }
.btn-primary:hover { background: #2563eb; } .btn-primary:hover { background: var(--accent-blue-hover); }
.btn-danger { background: #ef4444; color: #fff; } .btn-danger { background: #ef4444; color: #fff; }
.btn-danger:hover { background: #dc2626; } .btn-danger:hover { background: #dc2626; }
.btn-sm { padding: 6px 12px; font-size: 0.8rem; } .btn-sm { padding: 6px 12px; font-size: 0.8rem; }
.btn-outline { background: transparent; border: 1px solid #334155; color: #94a3b8; } .btn-outline { background: transparent; border: 1px solid var(--border-color); color: var(--text-secondary); }
.btn-outline:hover { border-color: #64748b; color: #e2e8f0; } .btn-outline:hover { border-color: var(--text-muted); color: var(--text-primary); }
/* Forms */ /* Forms */
.form-group { margin-bottom: 16px; } .form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 4px; } .form-group label { display: block; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 4px; }
.form-group input, .form-group select { width: 100%; padding: 10px 12px; background: #0f172a; border: 1px solid #334155; border-radius: 6px; color: #e2e8f0; font-size: 0.95rem; } .form-group input, .form-group select { width: 100%; padding: 10px 12px; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary); font-size: 0.95rem; }
.form-group input:focus { outline: none; border-color: #3b82f6; } .form-group input:focus, .form-group select:focus { outline: none; border-color: var(--accent-blue); }
.error-msg { background: #7f1d1d40; border: 1px solid #991b1b; color: #fca5a5; padding: 10px; border-radius: 6px; margin-bottom: 16px; font-size: 0.85rem; } .error-msg { background: var(--accent-red-bg); border: 1px solid var(--accent-red-border); color: var(--accent-red-text); padding: 10px; border-radius: 6px; margin-bottom: 16px; font-size: 0.85rem; }
.success-msg { background: #14532d40; border: 1px solid #166534; color: #86efac; padding: 10px; border-radius: 6px; margin-bottom: 16px; font-size: 0.85rem; } .success-msg { background: var(--success-bg); border: 1px solid var(--success-border); color: var(--success-text); padding: 10px; border-radius: 6px; margin-bottom: 16px; font-size: 0.85rem; }
/* Modal */ /* Modal */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: #00000080; display: none; align-items: center; justify-content: center; z-index: 100; } .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: var(--modal-overlay); display: none; align-items: center; justify-content: center; z-index: 100; }
.modal-overlay.show { display: flex; } .modal-overlay.show { display: flex; }
.modal { background: #1e293b; border-radius: 12px; padding: 24px; width: 100%; max-width: 440px; } .modal { background: var(--bg-secondary); border-radius: 12px; padding: 24px; width: 100%; max-width: 440px; }
.modal h2 { margin-bottom: 16px; color: #cbd5e1; } .modal h2 { margin-bottom: 16px; color: var(--text-subheading); }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; } .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
/* Token display */ /* Token display */
.token-key { background: #0f172a; padding: 8px 12px; border-radius: 6px; font-family: monospace; font-size: 0.85rem; word-break: break-all; margin: 8px 0; display: flex; align-items: center; gap: 8px; } .token-key { background: var(--bg-primary); padding: 8px 12px; border-radius: 6px; font-family: monospace; font-size: 0.85rem; word-break: break-all; margin: 8px 0; display: flex; align-items: center; gap: 8px; }
.token-key code { flex: 1; } .token-key code { flex: 1; }
.copy-btn { background: #334155; border: none; color: #94a3b8; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 0.75rem; } .copy-btn { background: var(--bg-tertiary); border: none; color: var(--text-secondary); padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 0.75rem; }
.copy-btn:hover { color: #e2e8f0; } .copy-btn:hover { color: var(--text-primary); }
/* Badge */ /* Badge */
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.7rem; font-weight: 600; } .badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.7rem; font-weight: 600; }
.badge-admin { background: #3b82f620; color: #60a5fa; } .badge-admin { background: var(--accent-blue-bg); color: var(--accent-blue); }
.badge-user { background: #4ade8020; color: #4ade80; } .badge-user { background: var(--accent-green-bg); color: var(--accent-green); }
.badge-totp { background: #a78bfa20; color: #a78bfa; } .badge-totp { background: var(--accent-purple-bg); color: var(--accent-purple); }
.badge-healthy { background: var(--accent-green-bg); color: var(--accent-green); }
.badge-degraded { background: var(--accent-yellow-bg); color: var(--accent-yellow); }
.badge-down { background: var(--accent-red-bg); color: var(--accent-red); }
.badge-success { background: var(--accent-green-bg); color: var(--accent-green); }
.badge-error { background: var(--accent-red-bg); color: var(--accent-red); }
.badge-cached { background: var(--accent-blue-bg); color: var(--accent-blue); }
.badge-priority { background: var(--bg-tertiary); color: var(--text-secondary); }
.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; } .page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.page-header h1 { font-size: 1.3rem; color: #f8fafc; } .page-header h1 { font-size: 1.3rem; color: var(--text-heading); }
/* Filter bar */
.filter-bar { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
.filter-bar select { padding: 6px 10px; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary); font-size: 0.85rem; }
.filter-bar select:focus { outline: none; border-color: var(--accent-blue); }
/* Pagination */
.pagination { display: flex; gap: 4px; align-items: center; margin-top: 16px; justify-content: center; }
.pagination button, .pagination span { padding: 6px 12px; border-radius: 6px; font-size: 0.8rem; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-secondary); cursor: pointer; }
.pagination button:hover { background: var(--bg-tertiary); color: var(--text-primary); }
.pagination button.active { background: var(--accent-blue); border-color: var(--accent-blue); color: #fff; }
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.pagination .page-info { border: none; background: none; cursor: default; color: var(--text-muted); font-size: 0.8rem; }
/* Progress bar */
.progress-bar { width: 100%; height: 8px; background: var(--bg-tertiary); border-radius: 4px; overflow: hidden; }
.progress-bar-fill { height: 100%; border-radius: 4px; transition: width 0.3s; }
.budget-info { font-size: 0.75rem; color: var(--text-muted); margin-top: 2px; }
/* Expandable row */
.expandable { cursor: pointer; }
.expand-content { display: none; padding: 8px 12px; background: var(--bg-primary); border-radius: 6px; margin: 4px 0; font-size: 0.8rem; font-family: monospace; white-space: pre-wrap; word-break: break-all; }
.expand-content.show { display: block; }
/* Health status row */
.health-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.health-item { display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: var(--bg-secondary); border-radius: 8px; }
.health-item .provider-name { font-weight: 600; font-size: 0.85rem; }
</style> </style>
</head> </head>
<body> <body>
<script>
function getThemePref() { return localStorage.getItem('theme') || 'auto'; }
function applyTheme(pref) {
var effective = pref;
if (pref === 'auto') effective = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
if (effective === 'light') document.documentElement.setAttribute('data-theme', 'light');
else document.documentElement.removeAttribute('data-theme');
}
function themeLabel(pref) {
if (pref === 'light') return 'Light';
if (pref === 'dark') return 'Dark';
return 'Auto';
}
function toggleTheme() {
var order = ['dark', 'light', 'auto'];
var cur = getThemePref();
var next = order[(order.indexOf(cur) + 1) % order.length];
localStorage.setItem('theme', next);
applyTheme(next);
var btn = document.getElementById('theme-btn');
if (btn) btn.textContent = 'Theme: ' + themeLabel(next);
}
// Follow system changes when set to auto
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function() {
if (getThemePref() === 'auto') applyTheme('auto');
});
</script>
<div class="sidebar"> <div class="sidebar">
<div class="sidebar-brand">LLM Gateway</div> <div class="sidebar-brand">LLM Gateway</div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<a href="/dashboard" hx-get="/dashboard" hx-target="#content" hx-push-url="true" {{if eq .ActivePage "dashboard"}}class="active"{{end}}>Dashboard</a> <a href="/dashboard" hx-get="/dashboard" hx-target="#content" hx-push-url="true" {{if eq .ActivePage "dashboard"}}class="active"{{end}}>Dashboard</a>
<a href="/logs" hx-get="/logs" hx-target="#content" hx-push-url="true" {{if eq .ActivePage "logs"}}class="active"{{end}}>Logs</a>
<a href="/models" hx-get="/models" hx-target="#content" hx-push-url="true" {{if eq .ActivePage "models"}}class="active"{{end}}>Models</a>
<a href="/tokens" hx-get="/tokens" hx-target="#content" hx-push-url="true" {{if eq .ActivePage "tokens"}}class="active"{{end}}>API Tokens</a> <a href="/tokens" hx-get="/tokens" hx-target="#content" hx-push-url="true" {{if eq .ActivePage "tokens"}}class="active"{{end}}>API Tokens</a>
{{if .User.IsAdmin}} {{if .User.IsAdmin}}
<a href="/users" hx-get="/users" hx-target="#content" hx-push-url="true" {{if eq .ActivePage "users"}}class="active"{{end}}>Users</a> <a href="/users" hx-get="/users" hx-target="#content" hx-push-url="true" {{if eq .ActivePage "users"}}class="active"{{end}}>Users</a>
@ -99,6 +234,7 @@
<a href="/settings" hx-get="/settings" hx-target="#content" hx-push-url="true" {{if eq .ActivePage "settings"}}class="active"{{end}}>Settings</a> <a href="/settings" hx-get="/settings" hx-target="#content" hx-push-url="true" {{if eq .ActivePage "settings"}}class="active"{{end}}>Settings</a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="theme-toggle" onclick="toggleTheme()" id="theme-btn">Switch Theme</button>
<div class="user-info">{{.User.Username}}</div> <div class="user-info">{{.User.Username}}</div>
<a href="#" hx-post="/api/auth/logout" hx-swap="none" onclick="setTimeout(()=>window.location='/login',100)">Logout</a> <a href="#" hx-post="/api/auth/logout" hx-swap="none" onclick="setTimeout(()=>window.location='/login',100)">Logout</a>
</div> </div>
@ -115,6 +251,11 @@ document.body.addEventListener('htmx:pushedIntoHistory', function(e) {
a.classList.toggle('active', a.getAttribute('href') === window.location.pathname); a.classList.toggle('active', a.getAttribute('href') === window.location.pathname);
}); });
}); });
// Set initial theme button label
(function() {
var btn = document.getElementById('theme-btn');
if (btn) btn.textContent = 'Theme: ' + themeLabel(getThemePref());
})();
</script> </script>
</body> </body>
</html> </html>

View file

@ -17,6 +17,38 @@
{{end}} {{end}}
</div> </div>
{{if .ProviderHealth}}
<div class="section">
<h2>Provider Health</h2>
<div class="health-row">
{{range .ProviderHealth}}
<div class="health-item">
<span class="provider-name">{{.Provider}}</span>
<span class="badge badge-{{.Status}}">{{.Status}}</span>
<span style="font-size:0.75rem;color:var(--text-muted)">{{printf "%.0f" .AvgLatency}}ms avg | {{formatPct .ErrorRate}} errors</span>
</div>
{{end}}
</div>
</div>
{{end}}
{{if .Latency}}{{if gt .Latency.Max 0.0}}
<div class="cards">
<div class="card"><div class="label">P50 Latency</div><div class="value">{{printf "%.0f" .Latency.P50}}ms</div></div>
<div class="card"><div class="label">P95 Latency</div><div class="value yellow">{{printf "%.0f" .Latency.P95}}ms</div></div>
<div class="card"><div class="label">P99 Latency</div><div class="value red">{{printf "%.0f" .Latency.P99}}ms</div></div>
<div class="card"><div class="label">Avg Latency</div><div class="value">{{printf "%.0f" .Latency.Avg}}ms</div></div>
</div>
{{end}}{{end}}
{{if .CacheEnabled}}{{if .CacheInfo}}{{if .CacheInfo.Connected}}
<div class="cards">
<div class="card"><div class="label">Cache Hit Rate</div><div class="value green">{{formatPct .CacheInfo.HitRate}}</div><div class="sub">{{.CacheInfo.Hits}} hits / {{.CacheInfo.Misses}} misses</div></div>
<div class="card"><div class="label">Cache Memory</div><div class="value">{{.CacheInfo.MemoryUsed}}</div></div>
<div class="card"><div class="label">Cached Keys</div><div class="value">{{.CacheInfo.Keys}}</div></div>
</div>
{{end}}{{end}}{{end}}
<div class="tabs"> <div class="tabs">
<button class="active" onclick="loadTimeseries('24h', this)">24h</button> <button class="active" onclick="loadTimeseries('24h', this)">24h</button>
<button onclick="loadTimeseries('7d', this)">7d</button> <button onclick="loadTimeseries('7d', this)">7d</button>
@ -27,6 +59,16 @@
<canvas id="chart" height="200"></canvas> <canvas id="chart" height="200"></canvas>
</div> </div>
<div class="section">
<h2>Cost Breakdown</h2>
<div class="tabs" id="cost-tabs">
<button class="active" onclick="loadCostBreakdown('model', this)">By Model</button>
<button onclick="loadCostBreakdown('token', this)">By Token</button>
<button onclick="loadCostBreakdown('provider', this)">By Provider</button>
</div>
<canvas id="cost-chart" height="200"></canvas>
</div>
{{if .Models}} {{if .Models}}
<div class="section"> <div class="section">
<h2>Models</h2> <h2>Models</h2>
@ -88,7 +130,22 @@
{{end}} {{end}}
<script> <script>
var _chart; var _chart, _costChart;
function chartColors() {
var isLight = document.documentElement.hasAttribute('data-theme');
return {
text: isLight ? '#475569' : '#94a3b8',
grid: isLight ? '#e2e8f020' : '#334155',
green: '#4ade80',
legend: isLight ? '#1e293b' : '#e2e8f0'
};
}
function formatCostTick(v) {
if (v === 0) return '$0';
if (v < 0.001) return '$' + v.toFixed(6);
if (v < 0.01) return '$' + v.toFixed(4);
return '$' + v.toFixed(2);
}
function loadTimeseries(period, btn) { function loadTimeseries(period, btn) {
document.querySelectorAll('.tabs button').forEach(function(b) { b.classList.remove('active'); }); document.querySelectorAll('.tabs button').forEach(function(b) { b.classList.remove('active'); });
if (btn) btn.classList.add('active'); if (btn) btn.classList.add('active');
@ -96,6 +153,7 @@ function loadTimeseries(period, btn) {
fetch('/api/stats/timeseries?period=' + period, {credentials: 'same-origin'}) fetch('/api/stats/timeseries?period=' + period, {credentials: 'same-origin'})
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
var c = chartColors();
var labels = (data||[]).map(function(d) { return d.bucket; }); var labels = (data||[]).map(function(d) { return d.bucket; });
var requests = (data||[]).map(function(d) { return d.requests; }); var requests = (data||[]).map(function(d) { return d.requests; });
var costs = (data||[]).map(function(d) { return d.cost_usd; }); var costs = (data||[]).map(function(d) { return d.cost_usd; });
@ -105,24 +163,68 @@ function loadTimeseries(period, btn) {
data: { data: {
labels: labels, labels: labels,
datasets: [ datasets: [
{ label: 'Requests', data: requests, backgroundColor: '#3b82f680', yAxisID: 'y' }, { label: 'Requests', data: requests, backgroundColor: 'rgba(59,130,246,0.5)', yAxisID: 'y' },
{ label: 'Cost ($)', data: costs, type: 'line', borderColor: '#4ade80', backgroundColor: '#4ade8020', yAxisID: 'y1', tension: 0.3 } { label: 'Cost ($)', data: costs, type: 'line', borderColor: c.green, backgroundColor: 'rgba(74,222,128,0.1)', yAxisID: 'y1', tension: 0.3, pointRadius: 3 }
] ]
}, },
options: { options: {
responsive: true, responsive: true,
interaction: { mode: 'index', intersect: false }, interaction: { mode: 'index', intersect: false },
scales: { scales: {
y: { position: 'left', ticks: { color: '#94a3b8' }, grid: { color: '#1e293b' } }, y: { position: 'left', beginAtZero: true, ticks: { color: c.text, precision: 0 }, grid: { color: c.grid } },
y1: { position: 'right', ticks: { color: '#4ade80' }, grid: { display: false } }, y1: { position: 'right', beginAtZero: true, ticks: { color: c.green, callback: formatCostTick }, grid: { display: false } },
x: { ticks: { color: '#94a3b8', maxRotation: 45 }, grid: { color: '#1e293b' } } x: { ticks: { color: c.text, maxRotation: 45 }, grid: { color: c.grid } }
}, },
plugins: { legend: { labels: { color: '#e2e8f0' } } } plugins: { legend: { labels: { color: c.legend } } }
} }
}); });
}).catch(function(){}); }).catch(function(){});
} }
loadTimeseries('24h'); loadTimeseries('24h');
function loadCostBreakdown(groupBy, btn) {
document.querySelectorAll('#cost-tabs button').forEach(function(b) { b.classList.remove('active'); });
if (btn) btn.classList.add('active');
fetch('/api/stats/cost-breakdown?period=7d&group_by=' + groupBy, {credentials: 'same-origin'})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data || data.length === 0) {
if (_costChart) _costChart.destroy();
return;
}
var c = chartColors();
var days = [], groups = {};
(data||[]).forEach(function(d) {
if (days.indexOf(d.day) === -1) days.push(d.day);
if (!groups[d.group_by]) groups[d.group_by] = {};
groups[d.group_by][d.day] = d.cost_usd;
});
var palette = ['#3b82f6','#4ade80','#f87171','#fbbf24','#a78bfa','#f472b6','#22d3ee','#fb923c'];
var datasets = [], ci = 0;
for (var g in groups) {
datasets.push({
label: g,
data: days.map(function(day) { return groups[g][day] || 0; }),
backgroundColor: palette[ci % palette.length] + '80'
});
ci++;
}
if (_costChart) _costChart.destroy();
_costChart = new Chart(document.getElementById('cost-chart'), {
type: 'bar',
data: { labels: days, datasets: datasets },
options: {
responsive: true,
scales: {
x: { stacked: true, ticks: { color: c.text }, grid: { color: c.grid } },
y: { stacked: true, beginAtZero: true, ticks: { color: c.text, callback: formatCostTick }, grid: { color: c.grid } }
},
plugins: { legend: { labels: { color: c.legend } } }
}
});
}).catch(function(){});
}
loadCostBreakdown('model');
</script> </script>
</div> </div>
{{end}} {{end}}

View file

@ -0,0 +1,120 @@
{{define "content"}}
<div class="page-header">
<h1>Request Logs</h1>
<span style="font-size:0.85rem;color:var(--text-muted)">{{.LogsResult.Total}} total</span>
</div>
<div class="filter-bar">
<select id="filter-model" onchange="applyLogsFilter()">
<option value="">All Models</option>
{{range .LogModels}}<option value="{{.}}" {{if eq . $.FilterModel}}selected{{end}}>{{.}}</option>{{end}}
</select>
<select id="filter-token" onchange="applyLogsFilter()">
<option value="">All Tokens</option>
{{range .LogTokens}}<option value="{{.}}" {{if eq . $.FilterToken}}selected{{end}}>{{.}}</option>{{end}}
</select>
<select id="filter-status" onchange="applyLogsFilter()">
<option value="">All Status</option>
<option value="success" {{if eq .FilterStatus "success"}}selected{{end}}>Success</option>
<option value="error" {{if eq .FilterStatus "error"}}selected{{end}}>Errors Only</option>
<option value="cached" {{if eq .FilterStatus "cached"}}selected{{end}}>Cached</option>
</select>
<button class="btn btn-sm btn-outline" onclick="clearLogsFilter()">Clear</button>
</div>
<div class="section">
<table>
<thead>
<tr>
<th>Time</th>
<th>Token</th>
<th>Model</th>
<th>Provider</th>
<th>Status</th>
<th>Latency</th>
<th>Tokens</th>
<th>Cost</th>
</tr>
</thead>
<tbody>
{{range $i, $log := .LogsResult.Logs}}
<tr class="{{if $log.ErrorMessage}}expandable{{end}}" {{if $log.ErrorMessage}}onclick="toggleExpand('expand-{{$i}}')"{{end}}>
<td>{{formatTimeDetail $log.Timestamp}}</td>
<td>{{$log.TokenName}}</td>
<td>{{$log.Model}}</td>
<td>{{$log.Provider}}</td>
<td>
{{if eq $log.Status "success"}}<span class="badge badge-success">success</span>
{{else if eq $log.Status "error"}}<span class="badge badge-error">error</span>
{{else if eq $log.Status "cached"}}<span class="badge badge-cached">cached</span>
{{else}}<span class="badge">{{$log.Status}}</span>{{end}}
{{if $log.Streaming}} <span class="badge badge-totp">stream</span>{{end}}
</td>
<td>{{$log.LatencyMS}}ms</td>
<td>{{$log.InputTokens}} / {{$log.OutputTokens}}</td>
<td class="green">{{formatCost $log.CostUSD}}</td>
</tr>
{{if $log.ErrorMessage}}
<tr>
<td colspan="8" style="padding:0;">
<div id="expand-{{$i}}" class="expand-content {{if eq $.FilterStatus "error"}}show{{end}}">{{$log.ErrorMessage}}</div>
</td>
</tr>
{{end}}
{{end}}
{{if not .LogsResult.Logs}}
<tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:24px;">No logs found</td></tr>
{{end}}
</tbody>
</table>
{{if gt .LogsResult.TotalPages 1}}
<div class="pagination">
<button {{if le .LogsResult.Page 1}}disabled{{end}} onclick="goToLogsPage(1)">First</button>
<button {{if le .LogsResult.Page 1}}disabled{{end}} onclick="goToLogsPage({{subInt .LogsResult.Page 1}})">Prev</button>
{{$page := .LogsResult.Page}}
{{$total := .LogsResult.TotalPages}}
{{range seq (paginationStart $page $total) (paginationEnd $page $total)}}
<button class="{{if eq . $page}}active{{end}}" onclick="goToLogsPage({{.}})">{{.}}</button>
{{end}}
<button {{if ge .LogsResult.Page .LogsResult.TotalPages}}disabled{{end}} onclick="goToLogsPage({{addInt .LogsResult.Page 1}})">Next</button>
<button {{if ge .LogsResult.Page .LogsResult.TotalPages}}disabled{{end}} onclick="goToLogsPage({{.LogsResult.TotalPages}})">Last</button>
<span class="page-info">Page {{.LogsResult.Page}} of {{.LogsResult.TotalPages}}</span>
</div>
{{end}}
</div>
<script>
function buildLogsURL(page) {
var params = [];
var model = document.getElementById('filter-model').value;
var token = document.getElementById('filter-token').value;
var status = document.getElementById('filter-status').value;
if (model) params.push('model=' + encodeURIComponent(model));
if (token) params.push('token=' + encodeURIComponent(token));
if (status) params.push('status=' + encodeURIComponent(status));
if (page > 1) params.push('page=' + page);
return '/logs' + (params.length ? '?' + params.join('&') : '');
}
function applyLogsFilter() {
var url = buildLogsURL(1);
htmx.ajax('GET', url, {target: '#content', swap: 'innerHTML'});
history.pushState({}, '', url);
}
function goToLogsPage(page) {
var url = buildLogsURL(page);
htmx.ajax('GET', url, {target: '#content', swap: 'innerHTML'});
history.pushState({}, '', url);
}
function clearLogsFilter() {
document.getElementById('filter-model').value = '';
document.getElementById('filter-token').value = '';
document.getElementById('filter-status').value = '';
applyLogsFilter();
}
function toggleExpand(id) {
var el = document.getElementById(id);
if (el) el.classList.toggle('show');
}
</script>
{{end}}

View file

@ -0,0 +1,39 @@
{{define "content"}}
<div class="page-header">
<h1>Model Routing</h1>
</div>
{{if .ModelRoutes}}
{{range .ModelRoutes}}
<div class="section">
<h2>{{.Name}}</h2>
<table>
<thead>
<tr>
<th>Provider</th>
<th>Provider Model</th>
<th>Priority</th>
<th>Input Price (per 1M)</th>
<th>Output Price (per 1M)</th>
</tr>
</thead>
<tbody>
{{range .Routes}}
<tr>
<td>{{.ProviderName}}</td>
<td><code>{{.ProviderModel}}</code></td>
<td><span class="badge badge-priority">{{.Priority}}</span></td>
<td>{{formatPrice .InputPrice}}</td>
<td>{{formatPrice .OutputPrice}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{else}}
<div class="section" style="text-align:center;color:var(--text-muted);padding:24px;">
No models configured
</div>
{{end}}
{{end}}

View file

@ -10,9 +10,9 @@
</div> </div>
<div class="section"> <div class="section">
<h2>Static Tokens <span style="font-size:0.75rem;color:#64748b;font-weight:400;">(from config, managed via environment variables)</span></h2> <h2>Static Tokens <span style="font-size:0.75rem;color:var(--text-muted);font-weight:400;">(from config, managed via environment variables)</span></h2>
<table> <table>
<thead><tr><th>Name</th><th>Prefix</th><th>Rate Limit</th><th>Budget</th><th></th></tr></thead> <thead><tr><th>Name</th><th>Prefix</th><th>Rate Limit</th><th>Budget</th><th>Today's Spend</th><th></th></tr></thead>
<tbody> <tbody>
{{range .Tokens}}{{if lt .ID 0}} {{range .Tokens}}{{if lt .ID 0}}
<tr> <tr>
@ -20,6 +20,18 @@
<td><code>{{.KeyPrefix}}...</code></td> <td><code>{{.KeyPrefix}}...</code></td>
<td>{{if eq .RateLimitRPM 0}}unlimited{{else}}{{.RateLimitRPM}} rpm{{end}}</td> <td>{{if eq .RateLimitRPM 0}}unlimited{{else}}{{.RateLimitRPM}} rpm{{end}}</td>
<td>{{if gt .DailyBudgetUSD 0.0}}${{printf "%.2f" .DailyBudgetUSD}}{{else}}unlimited{{end}}</td> <td>{{if gt .DailyBudgetUSD 0.0}}${{printf "%.2f" .DailyBudgetUSD}}{{else}}unlimited{{end}}</td>
<td>
{{$spend := index $.TokenSpend .Name}}
{{if gt .DailyBudgetUSD 0.0}}
{{$pct := budgetPct $spend .DailyBudgetUSD}}
<div style="min-width:120px;">
<div class="progress-bar"><div class="progress-bar-fill" style="width:{{if gt $pct 100.0}}100{{else}}{{printf "%.0f" $pct}}{{end}}%;background:{{budgetColor $pct}};"></div></div>
<div class="budget-info">${{printf "%.4f" $spend}} / ${{printf "%.2f" .DailyBudgetUSD}} ({{printf "%.1f" $pct}}%)</div>
</div>
{{else}}
{{if gt $spend 0.0}}{{formatCost $spend}}{{else}}-{{end}}
{{end}}
</td>
<td><span class="badge badge-totp">config</span></td> <td><span class="badge badge-totp">config</span></td>
</tr> </tr>
{{end}}{{end}} {{end}}{{end}}
@ -28,17 +40,28 @@
</div> </div>
<div class="section"> <div class="section">
<h2>Dynamic Tokens <span style="font-size:0.75rem;color:#64748b;font-weight:400;">(created via dashboard)</span></h2> <h2>Dynamic Tokens <span style="font-size:0.75rem;color:var(--text-muted);font-weight:400;">(created via dashboard)</span></h2>
<table> <table>
<thead><tr><th>Name</th><th>Prefix</th><th>Rate Limit</th><th>Budget</th><th>Created</th><th>Last Used</th><th></th></tr></thead> <thead><tr><th>Name</th><th>Prefix</th><th>Rate Limit</th><th>Budget</th><th>Today's Spend</th><th>Created</th><th>Last Used</th><th></th></tr></thead>
<tbody id="tokens-tbody"> <tbody id="tokens-tbody">
{{$hasDynamic := false}}
{{range .Tokens}}{{if gt .ID 0}} {{range .Tokens}}{{if gt .ID 0}}
<tr> <tr>
<td>{{.Name}}</td> <td>{{.Name}}</td>
<td><code>{{.KeyPrefix}}...</code></td> <td><code>{{.KeyPrefix}}...</code></td>
<td>{{if eq .RateLimitRPM 0}}unlimited{{else}}{{.RateLimitRPM}} rpm{{end}}</td> <td>{{if eq .RateLimitRPM 0}}unlimited{{else}}{{.RateLimitRPM}} rpm{{end}}</td>
<td>{{if gt .DailyBudgetUSD 0.0}}${{printf "%.2f" .DailyBudgetUSD}}{{else}}unlimited{{end}}</td> <td>{{if gt .DailyBudgetUSD 0.0}}${{printf "%.2f" .DailyBudgetUSD}}{{else}}unlimited{{end}}</td>
<td>
{{$spend := index $.TokenSpend .Name}}
{{if gt .DailyBudgetUSD 0.0}}
{{$pct := budgetPct $spend .DailyBudgetUSD}}
<div style="min-width:120px;">
<div class="progress-bar"><div class="progress-bar-fill" style="width:{{if gt $pct 100.0}}100{{else}}{{printf "%.0f" $pct}}{{end}}%;background:{{budgetColor $pct}};"></div></div>
<div class="budget-info">${{printf "%.4f" $spend}} / ${{printf "%.2f" .DailyBudgetUSD}} ({{printf "%.1f" $pct}}%)</div>
</div>
{{else}}
{{if gt $spend 0.0}}{{formatCost $spend}}{{else}}-{{end}}
{{end}}
</td>
<td>{{formatTime .CreatedAt}}</td> <td>{{formatTime .CreatedAt}}</td>
<td>{{if gt .LastUsedAt 0}}{{formatTime .LastUsedAt}}{{else}}never{{end}}</td> <td>{{if gt .LastUsedAt 0}}{{formatTime .LastUsedAt}}{{else}}never{{end}}</td>
<td><button class="btn btn-sm btn-danger" onclick="deleteToken({{.ID}})">Revoke</button></td> <td><button class="btn btn-sm btn-danger" onclick="deleteToken({{.ID}})">Revoke</button></td>

View file

@ -0,0 +1,121 @@
package provider
import (
"sync"
"time"
)
// HealthEvent represents a single request outcome for a provider.
type HealthEvent struct {
Timestamp time.Time
LatencyMS int64
IsError bool
ErrorMsg string
}
// ProviderHealth is the computed health status for a provider.
type ProviderHealth struct {
Provider string `json:"provider"`
Status string `json:"status"` // healthy, degraded, down
ErrorRate float64 `json:"error_rate"`
AvgLatency float64 `json:"avg_latency_ms"`
Total int `json:"total"`
Errors int `json:"errors"`
}
// HealthTracker tracks per-provider health using a sliding window.
type HealthTracker struct {
mu sync.RWMutex
windows map[string][]HealthEvent
windowDu time.Duration
}
// NewHealthTracker creates a health tracker with the given window duration.
func NewHealthTracker(window time.Duration) *HealthTracker {
if window == 0 {
window = 5 * time.Minute
}
return &HealthTracker{
windows: make(map[string][]HealthEvent),
windowDu: window,
}
}
// Record adds a health event for a provider.
func (h *HealthTracker) Record(provider string, latencyMS int64, err error) {
event := HealthEvent{
Timestamp: time.Now(),
LatencyMS: latencyMS,
IsError: err != nil,
}
if err != nil {
event.ErrorMsg = err.Error()
}
h.mu.Lock()
defer h.mu.Unlock()
h.windows[provider] = append(h.windows[provider], event)
h.prune(provider)
}
// Status returns computed health for all tracked providers.
func (h *HealthTracker) Status() []ProviderHealth {
h.mu.RLock()
defer h.mu.RUnlock()
cutoff := time.Now().Add(-h.windowDu)
var results []ProviderHealth
for provider, events := range h.windows {
var total, errors int
var totalLatency int64
for _, e := range events {
if e.Timestamp.Before(cutoff) {
continue
}
total++
totalLatency += e.LatencyMS
if e.IsError {
errors++
}
}
if total == 0 {
continue
}
errorRate := float64(errors) / float64(total)
status := "healthy"
if errorRate >= 0.5 {
status = "down"
} else if errorRate >= 0.1 {
status = "degraded"
}
results = append(results, ProviderHealth{
Provider: provider,
Status: status,
ErrorRate: errorRate,
AvgLatency: float64(totalLatency) / float64(total),
Total: total,
Errors: errors,
})
}
return results
}
// prune removes events outside the window. Must be called with lock held.
func (h *HealthTracker) prune(provider string) {
cutoff := time.Now().Add(-h.windowDu)
events := h.windows[provider]
i := 0
for i < len(events) && events[i].Timestamp.Before(cutoff) {
i++
}
if i > 0 {
h.windows[provider] = events[i:]
}
}

View file

@ -70,3 +70,38 @@ func (r *Registry) Lookup(model string) ([]Route, bool) {
func (r *Registry) ModelNames() []string { func (r *Registry) ModelNames() []string {
return r.order return r.order
} }
// RouteInfo exposes route details for dashboard display.
type RouteInfo struct {
ProviderName string `json:"provider_name"`
ProviderModel string `json:"provider_model"`
Priority int `json:"priority"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
}
// ModelRouteInfo exposes a model and its routes for dashboard display.
type ModelRouteInfo struct {
Name string `json:"name"`
Routes []RouteInfo `json:"routes"`
}
// AllRoutes returns all models and their routes in config order.
func (r *Registry) AllRoutes() []ModelRouteInfo {
results := make([]ModelRouteInfo, 0, len(r.order))
for _, name := range r.order {
routes := r.routes[name]
info := ModelRouteInfo{Name: name}
for _, rt := range routes {
info.Routes = append(info.Routes, RouteInfo{
ProviderName: rt.Provider.Name(),
ProviderModel: rt.ProviderModel,
Priority: rt.Priority,
InputPrice: rt.InputPrice,
OutputPrice: rt.OutputPrice,
})
}
results = append(results, info)
}
return results
}

View file

@ -41,20 +41,22 @@ func getAPIToken(ctx context.Context) *auth.APIToken {
} }
type Handler struct { type Handler struct {
registry *provider.Registry registry *provider.Registry
logger *storage.AsyncLogger logger *storage.AsyncLogger
cache *cache.Cache cache *cache.Cache
metrics *metrics.Metrics metrics *metrics.Metrics
cfg *config.Config cfg *config.Config
healthTracker *provider.HealthTracker
} }
func NewHandler(registry *provider.Registry, logger *storage.AsyncLogger, c *cache.Cache, m *metrics.Metrics, cfg *config.Config) *Handler { func NewHandler(registry *provider.Registry, logger *storage.AsyncLogger, c *cache.Cache, m *metrics.Metrics, cfg *config.Config, ht *provider.HealthTracker) *Handler {
return &Handler{ return &Handler{
registry: registry, registry: registry,
logger: logger, logger: logger,
cache: c, cache: c,
metrics: m, metrics: m,
cfg: cfg, cfg: cfg,
healthTracker: ht,
} }
} }
@ -117,6 +119,9 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, r *http.Request, req *p
// Client error — don't retry // Client error — don't retry
h.metrics.RecordRequest(req.Model, route.Provider.Name(), tokenName, "error", latency, 0, 0, 0) h.metrics.RecordRequest(req.Model, route.Provider.Name(), tokenName, "error", latency, 0, 0, 0)
h.logRequest(tokenName, req.Model, route.Provider.Name(), route.ProviderModel, 0, 0, 0, latency, "error", err.Error(), false, false) h.logRequest(tokenName, req.Model, route.Provider.Name(), route.ProviderModel, 0, 0, 0, latency, "error", err.Error(), false, false)
if h.healthTracker != nil {
h.healthTracker.Record(route.Provider.Name(), latency, err)
}
writeErrorRaw(w, pe.StatusCode, pe.Body) writeErrorRaw(w, pe.StatusCode, pe.Body)
return return
} }
@ -124,9 +129,16 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, r *http.Request, req *p
log.Printf("Provider %s failed for %s: %v", route.Provider.Name(), req.Model, err) log.Printf("Provider %s failed for %s: %v", route.Provider.Name(), req.Model, err)
h.metrics.RecordRequest(req.Model, route.Provider.Name(), tokenName, "error", latency, 0, 0, 0) h.metrics.RecordRequest(req.Model, route.Provider.Name(), tokenName, "error", latency, 0, 0, 0)
h.logRequest(tokenName, req.Model, route.Provider.Name(), route.ProviderModel, 0, 0, 0, latency, "error", err.Error(), false, false) h.logRequest(tokenName, req.Model, route.Provider.Name(), route.ProviderModel, 0, 0, 0, latency, "error", err.Error(), false, false)
if h.healthTracker != nil {
h.healthTracker.Record(route.Provider.Name(), latency, err)
}
continue continue
} }
if h.healthTracker != nil {
h.healthTracker.Record(route.Provider.Name(), latency, nil)
}
// Compute cost // Compute cost
inputTokens, outputTokens := 0, 0 inputTokens, outputTokens := 0, 0
if resp.Usage != nil { if resp.Usage != nil {

View file

@ -31,12 +31,19 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, req *prov
latency := time.Since(start).Milliseconds() latency := time.Since(start).Milliseconds()
h.metrics.RecordRequest(req.Model, route.Provider.Name(), tokenName, "error", latency, 0, 0, 0) h.metrics.RecordRequest(req.Model, route.Provider.Name(), tokenName, "error", latency, 0, 0, 0)
h.logRequest(tokenName, req.Model, route.Provider.Name(), route.ProviderModel, 0, 0, 0, latency, "error", err.Error(), true, false) h.logRequest(tokenName, req.Model, route.Provider.Name(), route.ProviderModel, 0, 0, 0, latency, "error", err.Error(), true, false)
if h.healthTracker != nil {
h.healthTracker.Record(route.Provider.Name(), latency, err)
}
writeErrorRaw(w, pe.StatusCode, pe.Body) writeErrorRaw(w, pe.StatusCode, pe.Body)
return return
} }
lastErr = err lastErr = err
latency := time.Since(start).Milliseconds()
log.Printf("Provider %s stream failed for %s: %v", route.Provider.Name(), req.Model, err) log.Printf("Provider %s stream failed for %s: %v", route.Provider.Name(), req.Model, err)
h.logRequest(tokenName, req.Model, route.Provider.Name(), route.ProviderModel, 0, 0, 0, time.Since(start).Milliseconds(), "error", err.Error(), true, false) h.logRequest(tokenName, req.Model, route.Provider.Name(), route.ProviderModel, 0, 0, 0, latency, "error", err.Error(), true, false)
if h.healthTracker != nil {
h.healthTracker.Record(route.Provider.Name(), latency, err)
}
continue continue
} }
@ -84,6 +91,9 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, req *prov
cost := computeCost(inputTokens, outputTokens, route.InputPrice, route.OutputPrice) cost := computeCost(inputTokens, outputTokens, route.InputPrice, route.OutputPrice)
h.metrics.RecordRequest(req.Model, route.Provider.Name(), tokenName, "success", latency, inputTokens, outputTokens, cost) h.metrics.RecordRequest(req.Model, route.Provider.Name(), tokenName, "success", latency, inputTokens, outputTokens, cost)
h.logRequest(tokenName, req.Model, route.Provider.Name(), route.ProviderModel, inputTokens, outputTokens, cost, latency, "success", "", true, false) h.logRequest(tokenName, req.Model, route.Provider.Name(), route.ProviderModel, inputTokens, outputTokens, cost, latency, "success", "", true, false)
if h.healthTracker != nil {
h.healthTracker.Record(route.Provider.Name(), latency, nil)
}
return return
} }

View file

@ -101,3 +101,27 @@ func (db *DB) TodaySpend(tokenName string) (float64, error) {
} }
return total.Float64, nil return total.Float64, nil
} }
// TodaySpendAll returns today's spend for all tokens as a map.
func (db *DB) TodaySpendAll() (map[string]float64, error) {
startOfDay := time.Now().Truncate(24 * time.Hour).Unix()
rows, err := db.Query(
"SELECT token_name, SUM(cost_usd) FROM request_logs WHERE timestamp >= ? GROUP BY token_name",
startOfDay,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]float64)
for rows.Next() {
var name string
var total float64
if err := rows.Scan(&name, &total); err != nil {
continue
}
result[name] = total
}
return result, nil
}