Files
GopherGate/internal/server/dashboard.go
hobokenchicken 3f1e6d3407
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
fix: restrict Model Pricing table to core providers and actual usage
Filtered registry iteration to only include openai, gemini, deepseek, and grok. Improved used_only logic to match specific (model, provider) pairs from logs.
2026-03-19 13:10:50 -04:00

1304 lines
36 KiB
Go

package server
import (
"database/sql"
"fmt"
"net/http"
"strings"
"time"
"llm-proxy/internal/db"
"llm-proxy/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
type ApiResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
func SuccessResponse(data interface{}) ApiResponse {
return ApiResponse{Success: true, Data: data}
}
func ErrorResponse(err string) ApiResponse {
return ApiResponse{Success: false, Error: err}
}
func (s *Server) adminAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse("Not authenticated"))
return
}
session, _, err := s.sessions.ValidateSession(token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse("Session expired or invalid"))
return
}
if session.Role != "admin" {
c.AbortWithStatusJSON(http.StatusForbidden, ErrorResponse("Admin access required"))
return
}
c.Set("session", session)
c.Next()
}
}
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
func (s *Server) handleLogin(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
return
}
var user db.User
err := s.database.Get(&user, "SELECT username, password_hash, display_name, role, must_change_password FROM users WHERE username = ?", req.Username)
if err != nil {
c.JSON(http.StatusUnauthorized, ErrorResponse("Invalid username or password"))
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, ErrorResponse("Invalid username or password"))
return
}
token, err := s.sessions.CreateSession(user.Username, user.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to create session"))
return
}
displayName := user.Username
if user.DisplayName != nil {
displayName = *user.DisplayName
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{
"token": token,
"must_change_password": user.MustChangePassword,
"user": gin.H{
"username": user.Username,
"name": displayName,
"role": user.Role,
},
}))
}
func (s *Server) handleAuthStatus(c *gin.Context) {
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
session, _, err := s.sessions.ValidateSession(token)
if err != nil {
c.JSON(http.StatusUnauthorized, ErrorResponse("Not authenticated"))
return
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{
"authenticated": true,
"user": gin.H{
"username": session.Username,
"role": session.Role,
},
}))
}
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required"`
}
func (s *Server) handleChangePassword(c *gin.Context) {
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
session, _, err := s.sessions.ValidateSession(token)
if err != nil {
c.JSON(http.StatusUnauthorized, ErrorResponse("Not authenticated"))
return
}
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
return
}
var user db.User
err = s.database.Get(&user, "SELECT password_hash FROM users WHERE username = ?", session.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse("User not found"))
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil {
c.JSON(http.StatusUnauthorized, ErrorResponse("Current password incorrect"))
return
}
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), 12)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to hash new password"))
return
}
_, err = s.database.Exec("UPDATE users SET password_hash = ?, must_change_password = 0 WHERE username = ?", string(newHash), session.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to update password"))
return
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Password updated successfully"}))
}
func (s *Server) handleLogout(c *gin.Context) {
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
s.sessions.RevokeSession(token)
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,
})
}
}
c.JSON(http.StatusOK, SuccessResponse(results))
}
func (s *Server) handleGetClients(c *gin.Context) {
var clients []db.Client
err := s.database.Select(&clients, "SELECT * FROM clients ORDER BY created_at DESC")
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
type UIClient struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
LastUsed *time.Time `json:"last_used"`
RequestsCount int `json:"requests_count"`
TokensCount int `json:"tokens_count"`
Status string `json:"status"`
RateLimitPerMinute int `json:"rate_limit_per_minute"`
}
uiClients := make([]UIClient, len(clients))
for i, cl := range clients {
status := "active"
if !cl.IsActive {
status = "disabled"
}
name := ""
if cl.Name != nil {
name = *cl.Name
}
desc := ""
if cl.Description != nil {
desc = *cl.Description
}
var lastUsedTime sql.NullTime
_ = s.database.Get(&lastUsedTime, "SELECT MAX(last_used_at) FROM client_tokens WHERE client_id = ?", cl.ClientID)
var lastUsed *time.Time
if lastUsedTime.Valid && !lastUsedTime.Time.IsZero() {
t := lastUsedTime.Time
lastUsed = &t
}
uiClients[i] = UIClient{
ID: cl.ClientID,
Name: name,
Description: desc,
CreatedAt: cl.CreatedAt,
LastUsed: lastUsed,
RequestsCount: cl.TotalRequests,
TokensCount: cl.TotalTokens,
Status: status,
RateLimitPerMinute: cl.RateLimitPerMinute,
}
}
c.JSON(http.StatusOK, SuccessResponse(uiClients))
}
func (s *Server) handleGetClient(c *gin.Context) {
id := c.Param("id")
var cl db.Client
err := s.database.Get(&cl, "SELECT * FROM clients WHERE client_id = ?", id)
if err != nil {
c.JSON(http.StatusNotFound, ErrorResponse("Client not found"))
return
}
name := ""
if cl.Name != nil {
name = *cl.Name
}
desc := ""
if cl.Description != nil {
desc = *cl.Description
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{
"id": cl.ClientID,
"name": name,
"description": desc,
"is_active": cl.IsActive,
"rate_limit_per_minute": cl.RateLimitPerMinute,
"created_at": cl.CreatedAt,
}))
}
type UpdateClientRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
IsActive bool `json:"is_active"`
RateLimitPerMinute *int `json:"rate_limit_per_minute"`
}
func (s *Server) handleUpdateClient(c *gin.Context) {
id := c.Param("id")
var req UpdateClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
return
}
_, err := s.database.Exec(`
UPDATE clients SET
name = ?,
description = ?,
is_active = ?,
rate_limit_per_minute = COALESCE(?, rate_limit_per_minute),
updated_at = CURRENT_TIMESTAMP
WHERE client_id = ?
`, req.Name, req.Description, req.IsActive, req.RateLimitPerMinute, id)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Client updated"}))
}
type CreateClientRequest struct {
Name string `json:"name" binding:"required"`
ClientID *string `json:"client_id"`
}
func (s *Server) handleCreateClient(c *gin.Context) {
var req CreateClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
return
}
clientID := ""
if req.ClientID != nil {
clientID = *req.ClientID
} else {
clientID = "client-" + uuid.New().String()[:8]
}
_, err := s.database.Exec("INSERT INTO clients (client_id, name, is_active) VALUES (?, ?, 1)", clientID, req.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
token := "sk-" + uuid.New().String() + uuid.New().String()
token = token[:51]
_, err = s.database.Exec("INSERT INTO client_tokens (client_id, token, name) VALUES (?, ?, 'default')", clientID, token)
if err != nil {
// Log error
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{
"id": clientID,
"name": req.Name,
"status": "active",
"token": token,
"created_at": time.Now(),
}))
}
func (s *Server) handleDeleteClient(c *gin.Context) {
id := c.Param("id")
if id == "default" {
c.JSON(http.StatusBadRequest, ErrorResponse("Cannot delete default client"))
return
}
_, err := s.database.Exec("DELETE FROM clients WHERE client_id = ?", id)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Client deleted"}))
}
func (s *Server) handleGetClientTokens(c *gin.Context) {
id := c.Param("id")
var tokens []db.ClientToken
err := s.database.Select(&tokens, "SELECT * FROM client_tokens WHERE client_id = ? ORDER BY created_at DESC", id)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
type MaskedToken struct {
ID int `json:"id"`
TokenMasked string `json:"token_masked"`
Name string `json:"name"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"`
}
masked := make([]MaskedToken, len(tokens))
for i, t := range tokens {
maskedToken := "••••"
if len(t.Token) > 8 {
maskedToken = t.Token[:3] + "••••" + t.Token[len(t.Token)-8:]
}
masked[i] = MaskedToken{
ID: t.ID,
TokenMasked: maskedToken,
Name: t.Name,
IsActive: t.IsActive,
CreatedAt: t.CreatedAt,
LastUsedAt: t.LastUsedAt,
}
}
c.JSON(http.StatusOK, SuccessResponse(masked))
}
type CreateTokenRequest struct {
Name string `json:"name"`
}
func (s *Server) handleCreateClientToken(c *gin.Context) {
clientID := c.Param("id")
var req CreateTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
// optional name
}
name := "default"
if req.Name != "" {
name = req.Name
}
token := "sk-" + uuid.New().String() + uuid.New().String()
token = token[:51]
_, err := s.database.Exec("INSERT INTO client_tokens (client_id, token, name) VALUES (?, ?, ?)", clientID, token, name)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{
"token": token,
"name": name,
"created_at": time.Now(),
}))
}
func (s *Server) handleDeleteClientToken(c *gin.Context) {
tokenID := c.Param("token_id")
_, err := s.database.Exec("DELETE FROM client_tokens WHERE id = ?", tokenID)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Token revoked"}))
}
func (s *Server) handleGetProviders(c *gin.Context) {
var dbConfigs []db.ProviderConfig
err := s.database.Select(&dbConfigs, "SELECT id, enabled, base_url, credit_balance, low_credit_threshold, billing_mode FROM provider_configs")
if err != nil {
// Log error
}
dbMap := make(map[string]db.ProviderConfig)
for _, cfg := range dbConfigs {
dbMap[cfg.ID] = cfg
}
providerIDs := []string{"openai", "gemini", "deepseek", "grok", "ollama"}
var result []gin.H
for _, id := range providerIDs {
var name string
var enabled bool
var baseURL string
switch id {
case "openai":
name = "OpenAI"
enabled = s.cfg.Providers.OpenAI.Enabled
baseURL = s.cfg.Providers.OpenAI.BaseURL
case "gemini":
name = "Google Gemini"
enabled = s.cfg.Providers.Gemini.Enabled
baseURL = s.cfg.Providers.Gemini.BaseURL
case "deepseek":
name = "DeepSeek"
enabled = s.cfg.Providers.DeepSeek.Enabled
baseURL = s.cfg.Providers.DeepSeek.BaseURL
case "grok":
name = "xAI Grok"
enabled = s.cfg.Providers.Grok.Enabled
baseURL = s.cfg.Providers.Grok.BaseURL
case "ollama":
name = "Ollama"
enabled = s.cfg.Providers.Ollama.Enabled
baseURL = s.cfg.Providers.Ollama.BaseURL
}
var balance float64
var threshold float64 = 5.0
var billingMode string
if dbCfg, ok := dbMap[id]; ok {
enabled = dbCfg.Enabled
if dbCfg.BaseURL != nil {
baseURL = *dbCfg.BaseURL
}
balance = dbCfg.CreditBalance
threshold = dbCfg.LowCreditThreshold
if dbCfg.BillingMode != nil {
billingMode = *dbCfg.BillingMode
}
}
status := "disabled"
if enabled {
if _, ok := s.providers[id]; ok {
status = "online"
} else {
status = "error"
}
}
result = append(result, gin.H{
"id": id,
"name": name,
"enabled": enabled,
"status": status,
"base_url": baseURL,
"credit_balance": balance,
"low_credit_threshold": threshold,
"billing_mode": billingMode,
})
}
c.JSON(http.StatusOK, SuccessResponse(result))
}
type UpdateProviderRequest struct {
Enabled bool `json:"enabled"`
BaseURL *string `json:"base_url"`
APIKey *string `json:"api_key"`
CreditBalance *float64 `json:"credit_balance"`
LowCreditThreshold *float64 `json:"low_credit_threshold"`
BillingMode *string `json:"billing_mode"`
}
func (s *Server) handleUpdateProvider(c *gin.Context) {
name := c.Param("name")
var req UpdateProviderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
return
}
_, err := s.database.Exec(`
INSERT INTO provider_configs (id, display_name, enabled, base_url, api_key, credit_balance, low_credit_threshold, billing_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
enabled = excluded.enabled,
base_url = COALESCE(excluded.base_url, provider_configs.base_url),
api_key = COALESCE(excluded.api_key, provider_configs.api_key),
credit_balance = COALESCE(excluded.credit_balance, provider_configs.credit_balance),
low_credit_threshold = COALESCE(excluded.low_credit_threshold, provider_configs.low_credit_threshold),
billing_mode = COALESCE(excluded.billing_mode, provider_configs.billing_mode),
updated_at = CURRENT_TIMESTAMP
`, name, strings.ToUpper(name), req.Enabled, req.BaseURL, req.APIKey, req.CreditBalance, req.LowCreditThreshold, req.BillingMode)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Provider updated"}))
}
func (s *Server) handleTestProvider(c *gin.Context) {
name := c.Param("name")
provider, ok := s.providers[name]
if !ok {
c.JSON(http.StatusNotFound, ErrorResponse(fmt.Sprintf("Provider %s not found or not enabled", name)))
return
}
startTime := time.Now()
// Prepare a simple test request
testReq := &models.UnifiedRequest{
Model: "gpt-4o-mini", // Default cheap test model
Messages: []models.UnifiedMessage{
{
Role: "user",
Content: []models.UnifiedContentPart{{Type: "text", Text: "Hi"}},
},
},
MaxTokens: new(uint32),
}
*testReq.MaxTokens = 5
// Adjust model for non-openai providers
if name == "gemini" {
testReq.Model = "gemini-2.0-flash"
} else if name == "deepseek" {
testReq.Model = "deepseek-chat"
} else if name == "grok" {
testReq.Model = "grok-2"
}
_, err := provider.ChatCompletion(c.Request.Context(), testReq)
latency := time.Since(startTime).Milliseconds()
if err != nil {
c.JSON(http.StatusOK, ErrorResponse(fmt.Sprintf("Provider test failed: %v", err)))
return
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{
"message": "Connection test successful",
"latency": latency,
}))
}
func (s *Server) handleGetModels(c *gin.Context) {
usedOnly := c.Query("used_only") == "true"
// Registry provider normalized name -> Proxy-internal provider ID
// This ensures we only show models from YOUR providers.
allowedRegistryProviders := map[string]string{
"openai": "openai",
"google": "gemini",
"deepseek": "deepseek",
"xai": "grok",
}
// 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 {
// Key format: "gpt-4o:openai"
usedPairs[fmt.Sprintf("%s:%s", p.Model, p.Provider)] = true
}
}
}
var result []gin.H
if s.registry != nil {
for pID, pInfo := range s.registry.Providers {
// Only include models from the 4 allowed registry IDs
proxyProvider, allowed := allowedRegistryProviders[pID]
if !allowed {
continue
}
for mID, mMeta := range pInfo.Models {
// If usedOnly is true, only include if this exact (model, provider) was logged
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, // Correctly normalized provider name
"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,
})
}
}
}
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"
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"}))
}
func (s *Server) handleGetUsers(c *gin.Context) {
var users []db.User
err := s.database.Select(&users, "SELECT id, username, display_name, role, must_change_password, created_at FROM users")
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
c.JSON(http.StatusOK, SuccessResponse(users))
}
type CreateUserRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
DisplayName *string `json:"display_name"`
Role *string `json:"role"`
}
func (s *Server) handleCreateUser(c *gin.Context) {
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to hash password"))
return
}
role := "viewer"
if req.Role != nil {
role = *req.Role
}
_, err = s.database.Exec("INSERT INTO users (username, password_hash, display_name, role, must_change_password) VALUES (?, ?, ?, ?, 1)",
req.Username, string(hash), req.DisplayName, role)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "User created"}))
}
type UpdateUserRequest struct {
DisplayName *string `json:"display_name"`
Role *string `json:"role"`
Password *string `json:"password"`
MustChangePassword *bool `json:"must_change_password"`
}
func (s *Server) handleUpdateUser(c *gin.Context) {
id := c.Param("id")
var req UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
return
}
if req.DisplayName != nil {
s.database.Exec("UPDATE users SET display_name = ? WHERE id = ?", req.DisplayName, id)
}
if req.Role != nil {
s.database.Exec("UPDATE users SET role = ? WHERE id = ?", req.Role, id)
}
if req.MustChangePassword != nil {
s.database.Exec("UPDATE users SET must_change_password = ? WHERE id = ?", req.MustChangePassword, id)
}
if req.Password != nil {
hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), 12)
s.database.Exec("UPDATE users SET password_hash = ? WHERE id = ?", string(hash), id)
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "User updated"}))
}
func (s *Server) handleDeleteUser(c *gin.Context) {
id := c.Param("id")
session, _ := c.Get("session")
if sess, ok := session.(*Session); ok {
var username string
s.database.Get(&username, "SELECT username FROM users WHERE id = ?", id)
if username == sess.Username {
c.JSON(http.StatusBadRequest, ErrorResponse("Cannot delete your own account"))
return
}
}
_, err := s.database.Exec("DELETE FROM users WHERE id = ?", id)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "User deleted"}))
}
func (s *Server) handleSystemHealth(c *gin.Context) {
c.JSON(http.StatusOK, SuccessResponse(gin.H{
"status": "ok",
"components": gin.H{
"database": "online",
"proxy": "online",
},
}))
}
func (s *Server) handleGetSettings(c *gin.Context) {
providerCount := 0
modelCount := 0
if s.registry != nil {
providerCount = len(s.registry.Providers)
for _, p := range s.registry.Providers {
modelCount += len(p.Models)
}
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{
"server": gin.H{
"version": "1.0.0-go",
"auth_tokens": s.cfg.Server.AuthTokens,
},
"database": gin.H{
"type": "sqlite",
"path": s.cfg.Database.Path,
},
"registry": gin.H{
"provider_count": providerCount,
"model_count": modelCount,
},
}))
}
func (s *Server) handleCreateBackup(c *gin.Context) {
// Simplified backup response
c.JSON(http.StatusOK, SuccessResponse(gin.H{
"backup_id": fmt.Sprintf("backup-%d.db", time.Now().Unix()),
"status": "created",
}))
}
func (s *Server) handleGetLogs(c *gin.Context) {
var logs []db.LLMRequest
err := s.database.Select(&logs, "SELECT * FROM llm_requests ORDER BY timestamp DESC LIMIT 100")
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
// Format for UI
type UILog struct {
Timestamp string `json:"timestamp"`
ClientID string `json:"client_id"`
Provider string `json:"provider"`
Model string `json:"model"`
Tokens int `json:"tokens"`
Status string `json:"status"`
}
uiLogs := make([]UILog, len(logs))
for i, l := range logs {
clientID := "unknown"
if l.ClientID != nil {
clientID = *l.ClientID
}
provider := "unknown"
if l.Provider != nil {
provider = *l.Provider
}
model := "unknown"
if l.Model != nil {
model = *l.Model
}
tokens := 0
if l.TotalTokens != nil {
tokens = *l.TotalTokens
}
uiLogs[i] = UILog{
Timestamp: l.Timestamp.Format(time.RFC3339),
ClientID: clientID,
Provider: provider,
Model: model,
Tokens: tokens,
Status: l.Status,
}
}
c.JSON(http.StatusOK, SuccessResponse(uiLogs))
}