Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pgstore/*
gitstore/*
objectstore/*
static/*
metrics.json

# Authentication data
auths/*
Expand Down
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +387 to +390
Copy link

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
loopDelay := cfg.LoopDelay
if loopDelay == 0 {
loopDelay = 10 * time.Minute
}
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
}
🤖 Prompt for AI Agents
In cmd/server/main.go around lines 387 to 390 the code sets loopDelay directly
from cfg.LoopDelay and defaults to 10 minutes only when zero; add validation to
enforce a minimum threshold (e.g., 1 minute) so excessively small values aren't
used. After computing loopDelay (and the zero-default), if loopDelay <
time.Minute then set loopDelay = time.Minute and emit a warning log (or
fmt.Println) indicating the configured value was too small and has been raised
to the minimum. Ensure imports include time and the logging facility used in
this file.


// 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 {
Expand Down
15 changes: 15 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
152 changes: 152 additions & 0 deletions internal/api/handlers/metrics/handler.go
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)
}
3 changes: 3 additions & 0 deletions internal/api/middleware/request_logging.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 28 additions & 1 deletion internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Restore binding to all interfaces

Line 291 now forces the listener to localhost unless IN_DOCKER is set, so any bare-metal or VM deployment instantly becomes unreachable from other machines. Previously we bound to all interfaces (fmt.Sprintf(":%d", cfg.Port)), which worked in every environment, including containers. Please keep the old behaviour and only override when you explicitly need 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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,
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,
Handler: engine,
🤖 Prompt for AI Agents
In internal/api/server.go around lines 285 to 292, the server Addr is being
forced to "localhost" by default which prevents remote connections on
bare-metal/VMs; restore the prior behavior by binding to all interfaces by
default (use ":<port>" as the Addr) and only set an explicit host (e.g.
"0.0.0.0:<port>") when IN_DOCKER is set; update the Addr construction
accordingly so production deployments remain reachable from other machines.

}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
Expand Down
10 changes: 10 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"strings"
"syscall"
"time"

"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"golang.org/x/crypto/bcrypt"
Expand Down Expand Up @@ -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'.
Expand Down
5 changes: 5 additions & 0 deletions internal/logging/gin_logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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)
Expand Down
Loading