diff --git a/internal/server/analytics.go b/internal/server/analytics.go new file mode 100644 index 00000000..a1dacdc4 --- /dev/null +++ b/internal/server/analytics.go @@ -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)) +} + diff --git a/internal/server/dashboard.go b/internal/server/dashboard.go index 63de8733..df8eda4e 100644 --- a/internal/server/dashboard.go +++ b/internal/server/dashboard.go @@ -175,360 +175,3 @@ func (s *Server) handleLogout(c *gin.Context) { 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, - }) - } diff --git a/internal/server/models_config.go b/internal/server/models_config.go new file mode 100644 index 00000000..3bd98600 --- /dev/null +++ b/internal/server/models_config.go @@ -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"})) +} +