fix: split dashboard.go properly — extract analytics + models_config
- 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
This commit is contained in:
@@ -0,0 +1,383 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
@@ -175,360 +175,3 @@ func (s *Server) handleLogout(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Logged out"}))
|
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Logged out"}))
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleGetModels(c *gin.Context) {
|
||||||
|
usedOnly := c.Query("used_only") == "true"
|
||||||
|
|
||||||
|
// Registry provider normalized name -> Proxy-internal provider ID
|
||||||
|
allowedRegistryProviders := map[string]string{
|
||||||
|
"openai": "openai",
|
||||||
|
"google": "gemini",
|
||||||
|
"deepseek": "deepseek",
|
||||||
|
"xai": "grok",
|
||||||
|
"ollama": "ollama",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge registry models with DB overrides
|
||||||
|
var dbModels []db.ModelConfig
|
||||||
|
_ = s.database.Select(&dbModels, "SELECT * FROM model_configs")
|
||||||
|
|
||||||
|
dbMap := make(map[string]db.ModelConfig)
|
||||||
|
for _, m := range dbModels {
|
||||||
|
dbMap[m.ID] = m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch specific (model, provider) combinations that have been used
|
||||||
|
type modelProvider struct {
|
||||||
|
Model string `db:"model"`
|
||||||
|
Provider string `db:"provider"`
|
||||||
|
}
|
||||||
|
usedPairs := make(map[string]bool)
|
||||||
|
if usedOnly {
|
||||||
|
var pairs []modelProvider
|
||||||
|
err := s.database.Select(&pairs, "SELECT DISTINCT model, provider FROM llm_requests WHERE status = 'success'")
|
||||||
|
if err == nil {
|
||||||
|
for _, p := range pairs {
|
||||||
|
usedPairs[fmt.Sprintf("%s:%s", p.Model, p.Provider)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []gin.H
|
||||||
|
s.registryMu.RLock()
|
||||||
|
if s.registry != nil {
|
||||||
|
for pID, pInfo := range s.registry.Providers {
|
||||||
|
proxyProvider, allowed := allowedRegistryProviders[pID]
|
||||||
|
if !allowed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for mID, mMeta := range pInfo.Models {
|
||||||
|
if usedOnly && !usedPairs[fmt.Sprintf("%s:%s", mID, proxyProvider)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled := true
|
||||||
|
promptCost := 0.0
|
||||||
|
completionCost := 0.0
|
||||||
|
var cacheReadCost *float64
|
||||||
|
var cacheWriteCost *float64
|
||||||
|
var mapping *string
|
||||||
|
contextLimit := uint32(0)
|
||||||
|
|
||||||
|
if mMeta.Cost != nil {
|
||||||
|
promptCost = mMeta.Cost.Input
|
||||||
|
completionCost = mMeta.Cost.Output
|
||||||
|
cacheReadCost = mMeta.Cost.CacheRead
|
||||||
|
cacheWriteCost = mMeta.Cost.CacheWrite
|
||||||
|
}
|
||||||
|
if mMeta.Limit != nil {
|
||||||
|
contextLimit = mMeta.Limit.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override from DB
|
||||||
|
if dbCfg, ok := dbMap[mID]; ok {
|
||||||
|
enabled = dbCfg.Enabled
|
||||||
|
if dbCfg.PromptCostPerM != nil {
|
||||||
|
promptCost = *dbCfg.PromptCostPerM
|
||||||
|
}
|
||||||
|
if dbCfg.CompletionCostPerM != nil {
|
||||||
|
completionCost = *dbCfg.CompletionCostPerM
|
||||||
|
}
|
||||||
|
if dbCfg.CacheReadCostPerM != nil {
|
||||||
|
cacheReadCost = dbCfg.CacheReadCostPerM
|
||||||
|
}
|
||||||
|
if dbCfg.CacheWriteCostPerM != nil {
|
||||||
|
cacheWriteCost = dbCfg.CacheWriteCostPerM
|
||||||
|
}
|
||||||
|
mapping = dbCfg.Mapping
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, gin.H{
|
||||||
|
"id": mID,
|
||||||
|
"name": mMeta.Name,
|
||||||
|
"provider": proxyProvider,
|
||||||
|
"enabled": enabled,
|
||||||
|
"prompt_cost": promptCost,
|
||||||
|
"completion_cost": completionCost,
|
||||||
|
"cache_read_cost": cacheReadCost,
|
||||||
|
"cache_write_cost": cacheWriteCost,
|
||||||
|
"context_limit": contextLimit,
|
||||||
|
"mapping": mapping,
|
||||||
|
"tool_call": mMeta.ToolCall != nil && *mMeta.ToolCall,
|
||||||
|
"reasoning": mMeta.Reasoning != nil && *mMeta.Reasoning,
|
||||||
|
"modalities": mMeta.Modalities,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add configured Ollama models if they aren't in registry
|
||||||
|
if s.cfg.Providers.Ollama.Enabled {
|
||||||
|
for _, mID := range s.cfg.Providers.Ollama.Models {
|
||||||
|
// Check if already added from registry
|
||||||
|
exists := false
|
||||||
|
for _, r := range result {
|
||||||
|
if r["id"] == mID {
|
||||||
|
exists = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if usedOnly && !usedPairs[fmt.Sprintf("%s:ollama", mID)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled := true
|
||||||
|
promptCost := 0.0
|
||||||
|
completionCost := 0.0
|
||||||
|
var cacheReadCost *float64
|
||||||
|
var cacheWriteCost *float64
|
||||||
|
var mapping *string
|
||||||
|
contextLimit := uint32(0)
|
||||||
|
|
||||||
|
// Override from DB
|
||||||
|
if dbCfg, ok := dbMap[mID]; ok {
|
||||||
|
enabled = dbCfg.Enabled
|
||||||
|
if dbCfg.PromptCostPerM != nil {
|
||||||
|
promptCost = *dbCfg.PromptCostPerM
|
||||||
|
}
|
||||||
|
if dbCfg.CompletionCostPerM != nil {
|
||||||
|
completionCost = *dbCfg.CompletionCostPerM
|
||||||
|
}
|
||||||
|
if dbCfg.CacheReadCostPerM != nil {
|
||||||
|
cacheReadCost = dbCfg.CacheReadCostPerM
|
||||||
|
}
|
||||||
|
if dbCfg.CacheWriteCostPerM != nil {
|
||||||
|
cacheWriteCost = dbCfg.CacheWriteCostPerM
|
||||||
|
}
|
||||||
|
mapping = dbCfg.Mapping
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, gin.H{
|
||||||
|
"id": mID,
|
||||||
|
"name": mID,
|
||||||
|
"provider": "ollama",
|
||||||
|
"enabled": enabled,
|
||||||
|
"prompt_cost": promptCost,
|
||||||
|
"completion_cost": completionCost,
|
||||||
|
"cache_read_cost": cacheReadCost,
|
||||||
|
"cache_write_cost": cacheWriteCost,
|
||||||
|
"context_limit": contextLimit,
|
||||||
|
"modalities": gin.H{"input": []string{"text"}, "output": []string{"text"}},
|
||||||
|
"tool_call": false,
|
||||||
|
"reasoning": false,
|
||||||
|
"mapping": mapping,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, SuccessResponse(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateModel(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var req struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
PromptCost float64 `json:"prompt_cost"`
|
||||||
|
CompletionCost float64 `json:"completion_cost"`
|
||||||
|
CacheReadCost *float64 `json:"cache_read_cost"`
|
||||||
|
CacheWriteCost *float64 `json:"cache_write_cost"`
|
||||||
|
Mapping *string `json:"mapping"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find provider for this model
|
||||||
|
providerID := "unknown"
|
||||||
|
s.registryMu.RLock()
|
||||||
|
if s.registry != nil {
|
||||||
|
for pID, pInfo := range s.registry.Providers {
|
||||||
|
if _, ok := pInfo.Models[id]; ok {
|
||||||
|
providerID = pID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.database.Exec(`
|
||||||
|
INSERT INTO model_configs (id, provider_id, enabled, prompt_cost_per_m, completion_cost_per_m, cache_read_cost_per_m, cache_write_cost_per_m, mapping)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
enabled = excluded.enabled,
|
||||||
|
prompt_cost_per_m = excluded.prompt_cost_per_m,
|
||||||
|
completion_cost_per_m = excluded.completion_cost_per_m,
|
||||||
|
cache_read_cost_per_m = excluded.cache_read_cost_per_m,
|
||||||
|
cache_write_cost_per_m = excluded.cache_write_cost_per_m,
|
||||||
|
mapping = excluded.mapping,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, id, providerID, req.Enabled, req.PromptCost, req.CompletionCost, req.CacheReadCost, req.CacheWriteCost, req.Mapping)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Model updated"}))
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user