2fa6f0df62
- analytics.go: UsagePeriodFilter, UsageSummary, TimeSeries, ProvidersUsage, ClientsUsage, AnalyticsBreakdown, DetailedUsage - models_config.go: handleGetModels, handleUpdateModel - Fix all import blocks with missing closing parens - Remove leftover fmt.Printf warnings in server.go
384 lines
9.7 KiB
Go
384 lines
9.7 KiB
Go
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gophergate/internal/db"
|
|
"gophergate/internal/models"
|
|
"gophergate/internal/utils"
|
|
"log/slog"
|
|
|
|
"github.com/shirou/gopsutil/v3/cpu"
|
|
)
|
|
|
|
type UsagePeriodFilter struct {
|
|
Period string `form:"period"`
|
|
From string `form:"from"`
|
|
To string `form:"to"`
|
|
}
|
|
|
|
func (f *UsagePeriodFilter) ToSQL() (string, []interface{}) {
|
|
period := f.Period
|
|
if period == "" {
|
|
period = "all"
|
|
}
|
|
|
|
if period == "custom" {
|
|
var clauses []string
|
|
var binds []interface{}
|
|
if f.From != "" {
|
|
clauses = append(clauses, "timestamp >= ?")
|
|
binds = append(binds, f.From)
|
|
}
|
|
if f.To != "" {
|
|
clauses = append(clauses, "timestamp <= ?")
|
|
binds = append(binds, f.To)
|
|
}
|
|
if len(clauses) > 0 {
|
|
return " AND " + strings.Join(clauses, " AND "), binds
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
var cutoff time.Time
|
|
switch period {
|
|
case "today":
|
|
cutoff = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
case "24h":
|
|
cutoff = now.Add(-24 * time.Hour)
|
|
case "7d":
|
|
cutoff = now.Add(-7 * 24 * time.Hour)
|
|
case "30d":
|
|
cutoff = now.Add(-30 * 24 * time.Hour)
|
|
default:
|
|
return "", nil
|
|
}
|
|
|
|
return " AND timestamp >= ?", []interface{}{cutoff.Format(time.RFC3339)}
|
|
}
|
|
|
|
func (s *Server) handleUsageSummary(c *gin.Context) {
|
|
var filter UsagePeriodFilter
|
|
if err := c.ShouldBindQuery(&filter); err != nil {
|
|
// ignore
|
|
}
|
|
|
|
clause, binds := filter.ToSQL()
|
|
|
|
// Total stats
|
|
var totalStats struct {
|
|
TotalRequests int `db:"total_requests"`
|
|
TotalTokens int `db:"total_tokens"`
|
|
CacheReadTokens int `db:"total_cache_read_tokens"`
|
|
CacheWriteTokens int `db:"total_cache_write_tokens"`
|
|
TotalCost float64 `db:"total_cost"`
|
|
ActiveClients int `db:"active_clients"`
|
|
}
|
|
err := s.database.Get(&totalStats, fmt.Sprintf(`
|
|
SELECT
|
|
COUNT(*) as total_requests,
|
|
COALESCE(SUM(total_tokens), 0) as total_tokens,
|
|
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,
|
|
COALESCE(SUM(cache_write_tokens), 0) as total_cache_write_tokens,
|
|
COALESCE(SUM(cost), 0.0) as total_cost,
|
|
COUNT(DISTINCT client_id) as active_clients
|
|
FROM llm_requests
|
|
WHERE 1=1 %s
|
|
`, clause), binds...)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
|
return
|
|
}
|
|
|
|
// Today stats
|
|
var todayStats struct {
|
|
TodayRequests int `db:"today_requests"`
|
|
TodayCost float64 `db:"today_cost"`
|
|
}
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
err = s.database.Get(&todayStats, `
|
|
SELECT
|
|
COUNT(*) as today_requests,
|
|
COALESCE(SUM(cost), 0.0) as today_cost
|
|
FROM llm_requests
|
|
WHERE timestamp LIKE ?
|
|
`, today+"%")
|
|
if err != nil {
|
|
todayStats.TodayRequests = 0
|
|
todayStats.TodayCost = 0.0
|
|
}
|
|
|
|
// Error rate & Avg response time
|
|
var miscStats struct {
|
|
ErrorRate float64 `db:"error_rate"`
|
|
AvgResponseTime float64 `db:"avg_response_time"`
|
|
}
|
|
err = s.database.Get(&miscStats, `
|
|
SELECT
|
|
CASE WHEN COUNT(*) = 0 THEN 0.0 ELSE (CAST(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS FLOAT) / COUNT(*)) * 100.0 END as error_rate,
|
|
COALESCE(AVG(duration_ms), 0.0) as avg_response_time
|
|
FROM llm_requests
|
|
`)
|
|
if err != nil {
|
|
miscStats.ErrorRate = 0.0
|
|
miscStats.AvgResponseTime = 0.0
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
|
"total_requests": totalStats.TotalRequests,
|
|
"total_tokens": totalStats.TotalTokens,
|
|
"total_cache_read_tokens": totalStats.CacheReadTokens,
|
|
"total_cache_write_tokens": totalStats.CacheWriteTokens,
|
|
"total_cost": totalStats.TotalCost,
|
|
"active_clients": totalStats.ActiveClients,
|
|
"today_requests": todayStats.TodayRequests,
|
|
"today_cost": todayStats.TodayCost,
|
|
"error_rate": miscStats.ErrorRate,
|
|
"avg_response_time": miscStats.AvgResponseTime,
|
|
}))
|
|
}
|
|
|
|
func (s *Server) handleTimeSeries(c *gin.Context) {
|
|
var filter UsagePeriodFilter
|
|
if err := c.ShouldBindQuery(&filter); err != nil {
|
|
// ignore
|
|
}
|
|
|
|
clause, binds := filter.ToSQL()
|
|
|
|
if clause == "" {
|
|
cutoff := time.Now().UTC().Add(-30 * 24 * time.Hour)
|
|
clause = " AND timestamp >= ?"
|
|
binds = []interface{}{cutoff.Format(time.RFC3339)}
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT
|
|
COALESCE(SUBSTR(timestamp, 1, 10), 'unknown') as bucket,
|
|
COUNT(*) as requests,
|
|
COALESCE(SUM(total_tokens), 0) as tokens,
|
|
COALESCE(SUM(cost), 0.0) as cost
|
|
FROM llm_requests
|
|
WHERE 1=1 %s
|
|
GROUP BY bucket
|
|
ORDER BY bucket
|
|
`, clause)
|
|
|
|
rows, err := s.database.Queryx(query, binds...)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var series []gin.H
|
|
for rows.Next() {
|
|
var bucket string
|
|
var requests int
|
|
var tokens int
|
|
var cost float64
|
|
if err := rows.Scan(&bucket, &requests, &tokens, &cost); err != nil {
|
|
continue
|
|
}
|
|
series = append(series, gin.H{
|
|
"time": bucket,
|
|
"requests": requests,
|
|
"tokens": tokens,
|
|
"cost": cost,
|
|
})
|
|
}
|
|
|
|
granularity := "day"
|
|
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
|
"series": series,
|
|
"granularity": granularity,
|
|
}))
|
|
}
|
|
|
|
func (s *Server) handleProvidersUsage(c *gin.Context) {
|
|
var filter UsagePeriodFilter
|
|
if err := c.ShouldBindQuery(&filter); err != nil {
|
|
// ignore
|
|
}
|
|
|
|
clause, binds := filter.ToSQL()
|
|
|
|
rows, err := s.database.Queryx(fmt.Sprintf(`
|
|
SELECT
|
|
COALESCE(provider, 'unknown') as provider,
|
|
COUNT(*) as requests,
|
|
COALESCE(SUM(cost), 0.0) as cost
|
|
FROM llm_requests
|
|
WHERE 1=1 %s
|
|
GROUP BY provider
|
|
`, clause), binds...)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, SuccessResponse([]interface{}{}))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []gin.H
|
|
for rows.Next() {
|
|
var provider string
|
|
var requests int
|
|
var cost float64
|
|
if err := rows.Scan(&provider, &requests, &cost); err == nil {
|
|
results = append(results, gin.H{"provider": provider, "requests": requests, "cost": cost})
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SuccessResponse(results))
|
|
}
|
|
|
|
func (s *Server) handleClientsUsage(c *gin.Context) {
|
|
var filter UsagePeriodFilter
|
|
if err := c.ShouldBindQuery(&filter); err != nil {
|
|
// ignore
|
|
}
|
|
|
|
clause, binds := filter.ToSQL()
|
|
|
|
rows, err := s.database.Queryx(fmt.Sprintf(`
|
|
SELECT COALESCE(client_id, 'unknown') as client_id, COUNT(*) as requests
|
|
FROM llm_requests
|
|
WHERE 1=1 %s
|
|
GROUP BY client_id
|
|
`, clause), binds...)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, SuccessResponse([]interface{}{}))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []gin.H
|
|
for rows.Next() {
|
|
var clientID string
|
|
var requests int
|
|
if err := rows.Scan(&clientID, &requests); err == nil {
|
|
results = append(results, gin.H{"client_id": clientID, "requests": requests})
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SuccessResponse(results))
|
|
}
|
|
|
|
func (s *Server) handleAnalyticsBreakdown(c *gin.Context) {
|
|
var filter UsagePeriodFilter
|
|
if err := c.ShouldBindQuery(&filter); err != nil {
|
|
// ignore
|
|
}
|
|
|
|
clause, binds := filter.ToSQL()
|
|
|
|
// Models breakdown
|
|
var models []struct {
|
|
Label string `json:"label"`
|
|
Value int `json:"value"`
|
|
}
|
|
mRows, err := s.database.Queryx(fmt.Sprintf("SELECT COALESCE(model, 'unknown') as label, COUNT(*) as value FROM llm_requests WHERE 1=1 %s GROUP BY model ORDER BY value DESC", clause), binds...)
|
|
if err == nil {
|
|
for mRows.Next() {
|
|
var label string
|
|
var value int
|
|
if err := mRows.Scan(&label, &value); err == nil {
|
|
models = append(models, struct {
|
|
Label string `json:"label"`
|
|
Value int `json:"value"`
|
|
}{label, value})
|
|
}
|
|
}
|
|
mRows.Close()
|
|
}
|
|
|
|
// Clients breakdown
|
|
var clients []struct {
|
|
Label string `json:"label"`
|
|
Value int `json:"value"`
|
|
}
|
|
cRows, err := s.database.Queryx(fmt.Sprintf("SELECT COALESCE(client_id, 'unknown') as label, COUNT(*) as value FROM llm_requests WHERE 1=1 %s GROUP BY client_id ORDER BY value DESC", clause), binds...)
|
|
if err == nil {
|
|
for cRows.Next() {
|
|
var label string
|
|
var value int
|
|
if err := cRows.Scan(&label, &value); err == nil {
|
|
clients = append(clients, struct {
|
|
Label string `json:"label"`
|
|
Value int `json:"value"`
|
|
}{label, value})
|
|
}
|
|
}
|
|
cRows.Close()
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
|
"models": models,
|
|
"clients": clients,
|
|
}))
|
|
}
|
|
|
|
func (s *Server) handleDetailedUsage(c *gin.Context) {
|
|
var filter UsagePeriodFilter
|
|
if err := c.ShouldBindQuery(&filter); err != nil {
|
|
// ignore
|
|
}
|
|
|
|
clause, binds := filter.ToSQL()
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT
|
|
COALESCE(SUBSTR(timestamp, 1, 10), 'unknown') as date,
|
|
COALESCE(client_id, 'unknown') as client,
|
|
COALESCE(provider, 'unknown') as provider,
|
|
COALESCE(model, 'unknown') as model,
|
|
COUNT(*) as requests,
|
|
COALESCE(SUM(total_tokens), 0) as tokens,
|
|
COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens,
|
|
COALESCE(SUM(cache_write_tokens), 0) as cache_write_tokens,
|
|
COALESCE(SUM(cost), 0.0) as cost
|
|
FROM llm_requests
|
|
WHERE 1=1 %s
|
|
GROUP BY date, client, provider, model
|
|
ORDER BY date DESC, cost DESC
|
|
`, clause)
|
|
|
|
rows, err := s.database.Queryx(query, binds...)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, SuccessResponse([]interface{}{}))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []gin.H
|
|
for rows.Next() {
|
|
var date, client, provider, model string
|
|
var requests, tokens, cacheRead, cacheWrite int
|
|
var cost float64
|
|
if err := rows.Scan(&date, &client, &provider, &model, &requests, &tokens, &cacheRead, &cacheWrite, &cost); err == nil {
|
|
results = append(results, gin.H{
|
|
"date": date,
|
|
"client": client,
|
|
"provider": provider,
|
|
"model": model,
|
|
"requests": requests,
|
|
"tokens": tokens,
|
|
"cache_read_tokens": cacheRead,
|
|
"cache_write_tokens": cacheWrite,
|
|
"cost": cost,
|
|
})
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SuccessResponse(results))
|
|
}
|
|
|