-
Notifications
You must be signed in to change notification settings - Fork 0
feat(usage): added UI for metrics and metrics persistence #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,7 @@ pgstore/* | |
| gitstore/* | ||
| objectstore/* | ||
| static/* | ||
| metrics.json | ||
|
|
||
| # Authentication data | ||
| auths/* | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| // Package metrics provides handlers for the metrics endpoints. | ||
| package metrics | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "fmt" | ||
| "net/http" | ||
| "sort" | ||
| "time" | ||
|
|
||
| "github.com/gin-gonic/gin" | ||
| "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" | ||
| ) | ||
|
|
||
| // Handler holds the dependencies for the metrics handlers. | ||
| type Handler struct { | ||
| Stats *usage.RequestStatistics | ||
| } | ||
|
|
||
| // NewHandler creates a new metrics handler. | ||
| func NewHandler(stats *usage.RequestStatistics) *Handler { | ||
| return &Handler{Stats: stats} | ||
| } | ||
|
|
||
| // MetricsResponse is the top-level struct for the metrics endpoint response. | ||
| type MetricsResponse struct { | ||
| Totals TotalsMetrics `json:"totals"` | ||
| ByModel []ModelMetrics `json:"by_model"` | ||
| Timeseries []TimeseriesBucket `json:"timeseries"` | ||
| } | ||
|
|
||
| // TotalsMetrics holds the aggregated totals for the queried period. | ||
| type TotalsMetrics struct { | ||
| Tokens int64 `json:"tokens"` | ||
| Requests int64 `json:"requests"` | ||
| } | ||
|
|
||
| // ModelMetrics holds the aggregated metrics for a specific model. | ||
| type ModelMetrics struct { | ||
| Model string `json:"model"` | ||
| Tokens int64 `json:"tokens"` | ||
| Requests int64 `json:"requests"` | ||
| } | ||
|
|
||
| // TimeseriesBucket holds the aggregated metrics for a specific time bucket. | ||
| type TimeseriesBucket struct { | ||
| BucketStart string `json:"bucket_start"` // ISO 8601 format | ||
| Tokens int64 `json:"tokens"` | ||
| Requests int64 `json:"requests"` | ||
| } | ||
|
|
||
| // GetMetrics is the handler for the /_qs/metrics endpoint. | ||
| func (h *Handler) GetMetrics(c *gin.Context) { | ||
| fromStr := c.Query("from") | ||
| toStr := c.Query("to") | ||
| modelFilter := c.Query("model") | ||
|
|
||
| var fromTime, toTime time.Time | ||
| var err error | ||
|
|
||
| // Default to last 24 hours if no time range is given | ||
| if fromStr == "" && toStr == "" { | ||
| toTime = time.Now() | ||
| fromTime = toTime.Add(-24 * time.Hour) | ||
| } else { | ||
| if fromStr != "" { | ||
| fromTime, err = time.Parse(time.RFC3339, fromStr) | ||
| if err != nil { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": "invalid 'from' timestamp format"}) | ||
| return | ||
| } | ||
| } | ||
| if toStr != "" { | ||
| toTime, err = time.Parse(time.RFC3339, toStr) | ||
| if err != nil { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": "invalid 'to' timestamp format"}) | ||
| return | ||
| } | ||
| } | ||
| } | ||
|
|
||
| snapshot := h.Stats.Snapshot() | ||
|
|
||
| modelMetricsMap := make(map[string]*ModelMetrics) | ||
| timeseriesMap := make(map[time.Time]*TimeseriesBucket) | ||
| var totalTokens int64 | ||
| var totalRequests int64 | ||
|
|
||
| for _, apiSnapshot := range snapshot.APIs { | ||
| for modelName, modelSnapshot := range apiSnapshot.Models { | ||
| if modelFilter != "" && modelFilter != modelName { | ||
| continue | ||
| } | ||
|
|
||
| for _, detail := range modelSnapshot.Details { | ||
| if !fromTime.IsZero() && detail.Timestamp.Before(fromTime) { | ||
| continue | ||
| } | ||
| if !toTime.IsZero() && detail.Timestamp.After(toTime) { | ||
| continue | ||
| } | ||
|
|
||
| totalRequests++ | ||
| totalTokens += detail.Tokens.TotalTokens | ||
|
|
||
| if _, ok := modelMetricsMap[modelName]; !ok { | ||
| modelMetricsMap[modelName] = &ModelMetrics{Model: modelName} | ||
| } | ||
| modelMetricsMap[modelName].Requests++ | ||
| modelMetricsMap[modelName].Tokens += detail.Tokens.TotalTokens | ||
|
|
||
| bucket := detail.Timestamp.Truncate(time.Hour) | ||
| if _, ok := timeseriesMap[bucket]; !ok { | ||
| timeseriesMap[bucket] = &TimeseriesBucket{BucketStart: bucket.Format(time.RFC3339)} | ||
| } | ||
| timeseriesMap[bucket].Requests++ | ||
| timeseriesMap[bucket].Tokens += detail.Tokens.TotalTokens | ||
| } | ||
| } | ||
| } | ||
|
|
||
| resp := MetricsResponse{ | ||
| Totals: TotalsMetrics{ | ||
| Tokens: totalTokens, | ||
| Requests: totalRequests, | ||
| }, | ||
| ByModel: make([]ModelMetrics, 0, len(modelMetricsMap)), | ||
| Timeseries: make([]TimeseriesBucket, 0, len(timeseriesMap)), | ||
| } | ||
|
|
||
| for _, mm := range modelMetricsMap { | ||
| resp.ByModel = append(resp.ByModel, *mm) | ||
| } | ||
|
|
||
| sort.Slice(resp.ByModel, func(i, j int) bool { | ||
| return resp.ByModel[i].Model < resp.ByModel[j].Model | ||
| }) | ||
|
|
||
| for _, tb := range timeseriesMap { | ||
| resp.Timeseries = append(resp.Timeseries, *tb) | ||
| } | ||
|
|
||
| sort.Slice(resp.Timeseries, func(i, j int) bool { | ||
| return resp.Timeseries[i].BucketStart < resp.Timeseries[j].BucketStart | ||
| }) | ||
|
|
||
| if jsonData, err := json.MarshalIndent(resp, "", " "); err == nil { | ||
| fmt.Println(string(jsonData)) | ||
| } | ||
|
|
||
| c.JSON(http.StatusOK, resp) | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -20,6 +20,7 @@ import ( | |||||||||||||||||||||||||||||||||||
| "github.com/gin-gonic/gin" | ||||||||||||||||||||||||||||||||||||
| "github.com/router-for-me/CLIProxyAPI/v6/internal/access" | ||||||||||||||||||||||||||||||||||||
| managementHandlers "github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management" | ||||||||||||||||||||||||||||||||||||
| metrics "github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/metrics" | ||||||||||||||||||||||||||||||||||||
| "github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware" | ||||||||||||||||||||||||||||||||||||
| "github.com/router-for-me/CLIProxyAPI/v6/internal/config" | ||||||||||||||||||||||||||||||||||||
| "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" | ||||||||||||||||||||||||||||||||||||
|
|
@@ -148,6 +149,9 @@ type Server struct { | |||||||||||||||||||||||||||||||||||
| // management handler | ||||||||||||||||||||||||||||||||||||
| mgmt *managementHandlers.Handler | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // metrics handler | ||||||||||||||||||||||||||||||||||||
| metricsHandler *metrics.Handler | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // managementRoutesRegistered tracks whether the management routes have been attached to the engine. | ||||||||||||||||||||||||||||||||||||
| managementRoutesRegistered atomic.Bool | ||||||||||||||||||||||||||||||||||||
| // managementRoutesEnabled controls whether management endpoints serve real handlers. | ||||||||||||||||||||||||||||||||||||
|
|
@@ -249,6 +253,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk | |||||||||||||||||||||||||||||||||||
| auth.SetQuotaCooldownDisabled(cfg.DisableCooling) | ||||||||||||||||||||||||||||||||||||
| // Initialize management handler | ||||||||||||||||||||||||||||||||||||
| s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager) | ||||||||||||||||||||||||||||||||||||
| s.metricsHandler = metrics.NewHandler(usage.GetRequestStatistics()) | ||||||||||||||||||||||||||||||||||||
| if optionState.localPassword != "" { | ||||||||||||||||||||||||||||||||||||
| s.mgmt.SetLocalPassword(optionState.localPassword) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
@@ -277,8 +282,13 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk | |||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Create HTTP server | ||||||||||||||||||||||||||||||||||||
| bindAddr := "localhost" | ||||||||||||||||||||||||||||||||||||
| if os.Getenv("IN_DOCKER") == "true" { | ||||||||||||||||||||||||||||||||||||
| bindAddr = "0.0.0.0" | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| s.server = &http.Server{ | ||||||||||||||||||||||||||||||||||||
| Addr: fmt.Sprintf(":%d", cfg.Port), | ||||||||||||||||||||||||||||||||||||
| Addr: fmt.Sprintf("%s:%d", bindAddr, cfg.Port), | ||||||||||||||||||||||||||||||||||||
| Handler: engine, | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+285
to
292
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Restore binding to all interfaces Line 291 now forces the listener to - bindAddr := "localhost"
- if os.Getenv("IN_DOCKER") == "true" {
- bindAddr = "0.0.0.0"
- }
-
- s.server = &http.Server{
- Addr: fmt.Sprintf("%s:%d", bindAddr, cfg.Port),
+ addr := fmt.Sprintf(":%d", cfg.Port)
+ if os.Getenv("IN_DOCKER") == "true" {
+ addr = fmt.Sprintf("0.0.0.0:%d", cfg.Port)
+ }
+
+ s.server = &http.Server{
+ Addr: addr,📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
@@ -324,9 +334,21 @@ func (s *Server) setupRoutes() { | |||||||||||||||||||||||||||||||||||
| "POST /v1/chat/completions", | ||||||||||||||||||||||||||||||||||||
| "POST /v1/completions", | ||||||||||||||||||||||||||||||||||||
| "GET /v1/models", | ||||||||||||||||||||||||||||||||||||
| "GET /_qs/health", | ||||||||||||||||||||||||||||||||||||
| "GET /_qs/metrics", | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| qs := s.engine.Group("/_qs") | ||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||
| qs.GET("/health", func(c *gin.Context) { | ||||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusOK, gin.H{"ok": true}) | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
| qs.GET("/metrics", s.metricsHandler.GetMetrics) | ||||||||||||||||||||||||||||||||||||
| qs.GET("/metrics/ui", s.serveMetricsUI) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| s.engine.POST("/v1internal:method", geminiCLIHandlers.CLIHandler) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // OAuth callback endpoints (reuse main server port) | ||||||||||||||||||||||||||||||||||||
|
|
@@ -550,6 +572,11 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) { | |||||||||||||||||||||||||||||||||||
| c.File(filePath) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| func (s *Server) serveMetricsUI(c *gin.Context) { | ||||||||||||||||||||||||||||||||||||
| filePath := filepath.Join("ui", "metrics.html") | ||||||||||||||||||||||||||||||||||||
| c.File(filePath) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| func (s *Server) enableKeepAlive(timeout time.Duration, onTimeout func()) { | ||||||||||||||||||||||||||||||||||||
| if timeout <= 0 || onTimeout == nil { | ||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Consider adding a minimum threshold for LoopDelay.
Very small LoopDelay values (e.g., 1 second) could cause excessive file I/O. Consider validating a minimum interval, such as 1 minute, to prevent performance issues.
Apply this diff to add validation:
loopDelay := cfg.LoopDelay if loopDelay == 0 { loopDelay = 10 * time.Minute + } else if loopDelay < time.Minute { + log.Warnf("LoopDelay %v is too small, using minimum of 1 minute", loopDelay) + loopDelay = time.Minute }📝 Committable suggestion
🤖 Prompt for AI Agents