Refined CalculateCost to correctly handle cached token discounts. Added fuzzy matching to model lookup. Robustified SQL date extraction using SUBSTR and LIKE for better SQLite compatibility.
1257 lines
34 KiB
Go
1257 lines
34 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"`
|
|
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(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 {
|
|
fmt.Printf("[ERROR] Failed to fetch today stats: %v\n", err)
|
|
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_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
|
|
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
|
|
if err := rows.Scan(&provider, &requests); err == nil {
|
|
results = append(results, gin.H{"provider": provider, "requests": requests})
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// 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
|
|
}
|
|
|
|
var result []gin.H
|
|
if s.registry != nil {
|
|
for pID, pInfo := range s.registry.Providers {
|
|
for mID, mMeta := range pInfo.Models {
|
|
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": pID,
|
|
"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))
|
|
}
|