diff --git a/.gitignore b/.gitignore index ef2d935ae..83abd4420 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ pgstore/* gitstore/* objectstore/* static/* +metrics.json # Authentication data auths/* diff --git a/Dockerfile b/Dockerfile index 8623dc5e4..1116f7e8e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,9 +23,12 @@ RUN mkdir /CLIProxyAPI COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI COPY config.example.yaml /CLIProxyAPI/config.example.yaml +COPY ui /CLIProxyAPI/ui WORKDIR /CLIProxyAPI +ENV IN_DOCKER=true + EXPOSE 8317 ENV TZ=Asia/Shanghai diff --git a/cmd/server/main.go b/cmd/server/main.go index 78259928b..79b56be74 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -378,6 +378,21 @@ func main() { } } usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled) + + metricsFile := cfg.MetricsFile + if metricsFile == "" { + metricsFile = "metrics.json" + } + + loopDelay := cfg.LoopDelay + if loopDelay == 0 { + loopDelay = 10 * time.Minute + } + + // Load last saved metrics from file and start periodic save + usage.LoadMetricsFromFile(metricsFile) + usage.StartPeriodicSaving(metricsFile, loopDelay, cfg.CrashOnError) + coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling) if err = logging.ConfigureLogOutput(cfg.LoggingToFile); err != nil { diff --git a/config.example.yaml b/config.example.yaml index 26c8335bf..ebb6e43f5 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -85,3 +85,18 @@ ws-auth: false # models: # The models supported by the provider. # - name: "moonshotai/kimi-k2:free" # The actual model name. # alias: "kimi-k2" # The alias used in the API. + +# --- Metrics Persistence --- +# +# File path for storing metrics periodically. +# If commented out or empty, defaults to "metrics.json" in the project root. +# metrics-file: "metrics.json" +# +# How often to save metrics to the file. +# If commented out or empty, defaults to 10m (10 minutes). +# loop-delay: 10m +# +# If true, the application will crash if it fails to save metrics. +# If false, it will print an error to stderr and continue. +# Defaults to false. +# crash-on-error: false diff --git a/internal/api/handlers/metrics/handler.go b/internal/api/handlers/metrics/handler.go new file mode 100644 index 000000000..b2511cf9e --- /dev/null +++ b/internal/api/handlers/metrics/handler.go @@ -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) +} diff --git a/internal/api/middleware/request_logging.go b/internal/api/middleware/request_logging.go index d4ea65102..a0a6cd042 100644 --- a/internal/api/middleware/request_logging.go +++ b/internal/api/middleware/request_logging.go @@ -80,6 +80,9 @@ func captureRequestInfo(c *gin.Context) (*RequestInfo, error) { headers[key] = values } + delete(headers, "Authorization") + delete(headers, "Cookie") + // Capture request body var body []byte if c.Request.Body != nil { diff --git a/internal/api/server.go b/internal/api/server.go index d834abc37..776b20c4c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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, } @@ -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 diff --git a/internal/cmd/run.go b/internal/cmd/run.go index e2f6ee802..bf0a8699a 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -12,6 +12,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/api" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy" log "github.com/sirupsen/logrus" ) @@ -49,6 +50,9 @@ func StartService(cfg *config.Config, configPath string, localPassword string) { } err = service.Run(runCtx) + + usage.StopMetricsPersistence() + if err != nil && !errors.Is(err, context.Canceled) { log.Fatalf("proxy service exited with error: %v", err) } diff --git a/internal/config/config.go b/internal/config/config.go index 9fd338d37..fda0a630e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,7 @@ import ( "os" "strings" "syscall" + "time" "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" "golang.org/x/crypto/bcrypt" @@ -60,6 +61,15 @@ type Config struct { // RemoteManagement nests management-related options under 'remote-management'. RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"` + + // MetricsFile is the path to the JSON file where metrics will be stored. + MetricsFile string `yaml:"metrics-file,omitempty" json:"metrics-file,omitempty"` + + // LoopDelay is the interval at which metrics are saved to the file. + LoopDelay time.Duration `yaml:"loop-delay,omitempty" json:"loop-delay,omitempty"` + + // CrashOnError determines if the application should crash if saving metrics fails. + CrashOnError bool `yaml:"crash-on-error,omitempty" json:"crash-on-error,omitempty"` } // RemoteManagement holds management API configuration under 'remote-management'. diff --git a/internal/logging/gin_logger.go b/internal/logging/gin_logger.go index 2933a0bbd..24968d381 100644 --- a/internal/logging/gin_logger.go +++ b/internal/logging/gin_logger.go @@ -10,6 +10,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" log "github.com/sirupsen/logrus" ) @@ -22,6 +23,10 @@ import ( // - gin.HandlerFunc: A middleware handler for request logging func GinLogrusLogger() gin.HandlerFunc { return func(c *gin.Context) { + requestID := uuid.New().String() + c.Set("request_id", requestID) + c.Header("X-Request-ID", requestID) + start := time.Now() path := c.Request.URL.Path raw := util.MaskSensitiveQuery(c.Request.URL.RawQuery) diff --git a/internal/usage/logger_plugin.go b/internal/usage/logger_plugin.go index 64c61d87c..b796232dc 100644 --- a/internal/usage/logger_plugin.go +++ b/internal/usage/logger_plugin.go @@ -5,7 +5,10 @@ package usage import ( "context" + "encoding/json" "fmt" + "os" + "strconv" "sync" "sync/atomic" "time" @@ -92,6 +95,8 @@ type RequestDetail struct { Source string `json:"source"` Tokens TokenStats `json:"tokens"` Failed bool `json:"failed"` + RequestID string `json:"request_id,omitempty"` + LatencyMS int64 `json:"latency_ms,omitempty"` } // TokenStats captures the token usage breakdown for a request. @@ -162,9 +167,24 @@ func (s *RequestStatistics) Record(ctx context.Context, record coreusage.Record) } detail := normaliseDetail(record.Detail) totalTokens := detail.TotalTokens + latency := time.Since(record.RequestedAt) + statsKey := record.APIKey + var requestID string + if ctx != nil { + if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil { + if statsKey == "" { + statsKey = resolveAPIIdentifier(ginCtx, record) + } + if id, exists := ginCtx.Get("request_id"); exists { + if rid, ok := id.(string); ok { + requestID = rid + } + } + } + } if statsKey == "" { - statsKey = resolveAPIIdentifier(ctx, record) + statsKey = resolveAPIIdentifier(nil, record) } failed := record.Failed if !failed { @@ -199,6 +219,8 @@ func (s *RequestStatistics) Record(ctx context.Context, record coreusage.Record) Source: record.Source, Tokens: detail, Failed: failed, + RequestID: requestID, + LatencyMS: latency.Milliseconds(), }) s.requestsByDay[dayKey]++ @@ -279,23 +301,21 @@ func (s *RequestStatistics) Snapshot() StatisticsSnapshot { return result } -func resolveAPIIdentifier(ctx context.Context, record coreusage.Record) string { - if ctx != nil { - if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil { - path := ginCtx.FullPath() - if path == "" && ginCtx.Request != nil { - path = ginCtx.Request.URL.Path - } - method := "" - if ginCtx.Request != nil { - method = ginCtx.Request.Method - } - if path != "" { - if method != "" { - return method + " " + path - } - return path +func resolveAPIIdentifier(ginCtx *gin.Context, record coreusage.Record) string { + if ginCtx != nil { + path := ginCtx.FullPath() + if path == "" && ginCtx.Request != nil { + path = ginCtx.Request.URL.Path + } + method := "" + if ginCtx.Request != nil { + method = ginCtx.Request.Method + } + if path != "" { + if method != "" { + return method + " " + path } + return path } } if record.Provider != "" { @@ -345,3 +365,130 @@ func formatHour(hour int) string { hour = hour % 24 return fmt.Sprintf("%02d", hour) } + +// LoadMetricsFromFile reads the specified file and loads the metrics into the default statistics store. +func LoadMetricsFromFile(filePath string) { + data, err := os.ReadFile(filePath) + if err != nil { + return + } + + var snapshot StatisticsSnapshot + if err := json.Unmarshal(data, &snapshot); err != nil { + return + } + + defaultRequestStatistics.LoadFromSnapshot(&snapshot) +} + +// LoadFromSnapshot populates the in-memory statistics from a snapshot. +func (s *RequestStatistics) LoadFromSnapshot(snapshot *StatisticsSnapshot) { + if s == nil || snapshot == nil { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.totalRequests = snapshot.TotalRequests + s.successCount = snapshot.SuccessCount + s.failureCount = snapshot.FailureCount + s.totalTokens = snapshot.TotalTokens + s.requestsByDay = snapshot.RequestsByDay + s.tokensByDay = snapshot.TokensByDay + s.requestsByHour = make(map[int]int64) + s.tokensByHour = make(map[int]int64) + + for hourStr, count := range snapshot.RequestsByHour { + hour, _ := strconv.Atoi(hourStr) + s.requestsByHour[hour] = count + } + + for hourStr, count := range snapshot.TokensByHour { + hour, _ := strconv.Atoi(hourStr) + s.tokensByHour[hour] = count + } + + s.apis = make(map[string]*apiStats) + for apiKey, apiSnap := range snapshot.APIs { + apiStat := &apiStats{ + TotalRequests: apiSnap.TotalRequests, + TotalTokens: apiSnap.TotalTokens, + Models: make(map[string]*modelStats), + } + for modelName, modelSnap := range apiSnap.Models { + modelStat := &modelStats{ + TotalRequests: modelSnap.TotalRequests, + TotalTokens: modelSnap.TotalTokens, + Details: make([]RequestDetail, len(modelSnap.Details)), + } + copy(modelStat.Details, modelSnap.Details) + apiStat.Models[modelName] = modelStat + } + s.apis[apiKey] = apiStat + } +} + +func (s *RequestStatistics) saveSnapshotToFile(filePath string) error { + if s == nil { + return fmt.Errorf("statistics store is nil") + } + snapshot := s.Snapshot() + data, err := json.MarshalIndent(snapshot, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal metrics snapshot: %w", err) + } + return os.WriteFile(filePath, data, 0644) +} + +var ( + shutdownChan = make(chan struct{}) + wg sync.WaitGroup +) + +// StartPeriodicSaving starts a background goroutine that periodically saves the +// current metrics snapshot to the specified file. It also listens for a shutdown +// signal from StopMetricsPersistence to perform a final save before exiting. +func StartPeriodicSaving(filePath string, interval time.Duration, crashOnError bool) { + if filePath == "" || interval <= 0 { + return + } + + wg.Add(1) + go func() { + defer wg.Done() + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := defaultRequestStatistics.saveSnapshotToFile(filePath); err != nil { + if crashOnError { + panic(fmt.Sprintf("failed to save metrics: %v", err)) + } else { + _, _ = fmt.Fprintf(os.Stderr, "failed to save metrics: %v\n", err) + } + } + case <-shutdownChan: + fmt.Println("Shutdown signal received, performing final metrics save...") + if err := defaultRequestStatistics.saveSnapshotToFile(filePath); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "failed to save final metrics: %v\n", err) + } + fmt.Println("Final metrics save complete. Exiting persistence goroutine.") + return + } + } + }() +} + +// StopMetricsPersistence signals the persistence goroutine to stop and waits for it to finish. +func StopMetricsPersistence() { + select { + case <-shutdownChan: + return + default: + close(shutdownChan) + } + wg.Wait() +} diff --git a/ui/metrics.html b/ui/metrics.html new file mode 100644 index 000000000..73299c0b3 --- /dev/null +++ b/ui/metrics.html @@ -0,0 +1,144 @@ + + + + + QuantumSpring - Metrics UI + + + + + +
+
+ +
+
+ +
+
+ +
+
+ +