Skip to content

Commit 7292233

Browse files
author
Martin Leinweber
committed
feat(usage): added UI for metrics and metrics persistence
1 parent 97af785 commit 7292233

File tree

12 files changed

+544
-18
lines changed

12 files changed

+544
-18
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pgstore/*
1515
gitstore/*
1616
objectstore/*
1717
static/*
18+
metrics.json
1819

1920
# Authentication data
2021
auths/*

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@ RUN mkdir /CLIProxyAPI
2323
COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI
2424

2525
COPY config.example.yaml /CLIProxyAPI/config.example.yaml
26+
COPY ui /CLIProxyAPI/ui
2627

2728
WORKDIR /CLIProxyAPI
2829

30+
ENV IN_DOCKER=true
31+
2932
EXPOSE 8317
3033

3134
ENV TZ=Asia/Shanghai

cmd/server/main.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,21 @@ func main() {
378378
}
379379
}
380380
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
381+
382+
metricsFile := cfg.MetricsFile
383+
if metricsFile == "" {
384+
metricsFile = "metrics.json"
385+
}
386+
387+
loopDelay := cfg.LoopDelay
388+
if loopDelay == 0 {
389+
loopDelay = 10 * time.Minute
390+
}
391+
392+
// Load last saved metrics from file and start periodic save
393+
usage.LoadMetricsFromFile(metricsFile)
394+
usage.StartPeriodicSaving(metricsFile, loopDelay, cfg.CrashOnError)
395+
381396
coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
382397

383398
if err = logging.ConfigureLogOutput(cfg.LoggingToFile); err != nil {

config.example.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,18 @@ ws-auth: false
8585
# models: # The models supported by the provider.
8686
# - name: "moonshotai/kimi-k2:free" # The actual model name.
8787
# alias: "kimi-k2" # The alias used in the API.
88+
89+
# --- Metrics Persistence ---
90+
#
91+
# File path for storing metrics periodically.
92+
# If commented out or empty, defaults to "metrics.json" in the project root.
93+
# metrics-file: "metrics.json"
94+
#
95+
# How often to save metrics to the file.
96+
# If commented out or empty, defaults to 10m (10 minutes).
97+
# loop-delay: 10m
98+
#
99+
# If true, the application will crash if it fails to save metrics.
100+
# If false, it will print an error to stderr and continue.
101+
# Defaults to false.
102+
# crash-on-error: false
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Package metrics provides handlers for the metrics endpoints.
2+
package metrics
3+
4+
import (
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"sort"
9+
"time"
10+
11+
"github.com/gin-gonic/gin"
12+
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
13+
)
14+
15+
// Handler holds the dependencies for the metrics handlers.
16+
type Handler struct {
17+
Stats *usage.RequestStatistics
18+
}
19+
20+
// NewHandler creates a new metrics handler.
21+
func NewHandler(stats *usage.RequestStatistics) *Handler {
22+
return &Handler{Stats: stats}
23+
}
24+
25+
// MetricsResponse is the top-level struct for the metrics endpoint response.
26+
type MetricsResponse struct {
27+
Totals TotalsMetrics `json:"totals"`
28+
ByModel []ModelMetrics `json:"by_model"`
29+
Timeseries []TimeseriesBucket `json:"timeseries"`
30+
}
31+
32+
// TotalsMetrics holds the aggregated totals for the queried period.
33+
type TotalsMetrics struct {
34+
Tokens int64 `json:"tokens"`
35+
Requests int64 `json:"requests"`
36+
}
37+
38+
// ModelMetrics holds the aggregated metrics for a specific model.
39+
type ModelMetrics struct {
40+
Model string `json:"model"`
41+
Tokens int64 `json:"tokens"`
42+
Requests int64 `json:"requests"`
43+
}
44+
45+
// TimeseriesBucket holds the aggregated metrics for a specific time bucket.
46+
type TimeseriesBucket struct {
47+
BucketStart string `json:"bucket_start"` // ISO 8601 format
48+
Tokens int64 `json:"tokens"`
49+
Requests int64 `json:"requests"`
50+
}
51+
52+
// GetMetrics is the handler for the /_qs/metrics endpoint.
53+
func (h *Handler) GetMetrics(c *gin.Context) {
54+
fromStr := c.Query("from")
55+
toStr := c.Query("to")
56+
modelFilter := c.Query("model")
57+
58+
var fromTime, toTime time.Time
59+
var err error
60+
61+
// Default to last 24 hours if no time range is given
62+
if fromStr == "" && toStr == "" {
63+
toTime = time.Now()
64+
fromTime = toTime.Add(-24 * time.Hour)
65+
} else {
66+
if fromStr != "" {
67+
fromTime, err = time.Parse(time.RFC3339, fromStr)
68+
if err != nil {
69+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid 'from' timestamp format"})
70+
return
71+
}
72+
}
73+
if toStr != "" {
74+
toTime, err = time.Parse(time.RFC3339, toStr)
75+
if err != nil {
76+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid 'to' timestamp format"})
77+
return
78+
}
79+
}
80+
}
81+
82+
snapshot := h.Stats.Snapshot()
83+
84+
modelMetricsMap := make(map[string]*ModelMetrics)
85+
timeseriesMap := make(map[time.Time]*TimeseriesBucket)
86+
var totalTokens int64
87+
var totalRequests int64
88+
89+
for _, apiSnapshot := range snapshot.APIs {
90+
for modelName, modelSnapshot := range apiSnapshot.Models {
91+
if modelFilter != "" && modelFilter != modelName {
92+
continue
93+
}
94+
95+
for _, detail := range modelSnapshot.Details {
96+
if !fromTime.IsZero() && detail.Timestamp.Before(fromTime) {
97+
continue
98+
}
99+
if !toTime.IsZero() && detail.Timestamp.After(toTime) {
100+
continue
101+
}
102+
103+
totalRequests++
104+
totalTokens += detail.Tokens.TotalTokens
105+
106+
if _, ok := modelMetricsMap[modelName]; !ok {
107+
modelMetricsMap[modelName] = &ModelMetrics{Model: modelName}
108+
}
109+
modelMetricsMap[modelName].Requests++
110+
modelMetricsMap[modelName].Tokens += detail.Tokens.TotalTokens
111+
112+
bucket := detail.Timestamp.Truncate(time.Hour)
113+
if _, ok := timeseriesMap[bucket]; !ok {
114+
timeseriesMap[bucket] = &TimeseriesBucket{BucketStart: bucket.Format(time.RFC3339)}
115+
}
116+
timeseriesMap[bucket].Requests++
117+
timeseriesMap[bucket].Tokens += detail.Tokens.TotalTokens
118+
}
119+
}
120+
}
121+
122+
resp := MetricsResponse{
123+
Totals: TotalsMetrics{
124+
Tokens: totalTokens,
125+
Requests: totalRequests,
126+
},
127+
ByModel: make([]ModelMetrics, 0, len(modelMetricsMap)),
128+
Timeseries: make([]TimeseriesBucket, 0, len(timeseriesMap)),
129+
}
130+
131+
for _, mm := range modelMetricsMap {
132+
resp.ByModel = append(resp.ByModel, *mm)
133+
}
134+
135+
sort.Slice(resp.ByModel, func(i, j int) bool {
136+
return resp.ByModel[i].Model < resp.ByModel[j].Model
137+
})
138+
139+
for _, tb := range timeseriesMap {
140+
resp.Timeseries = append(resp.Timeseries, *tb)
141+
}
142+
143+
sort.Slice(resp.Timeseries, func(i, j int) bool {
144+
return resp.Timeseries[i].BucketStart < resp.Timeseries[j].BucketStart
145+
})
146+
147+
if jsonData, err := json.MarshalIndent(resp, "", " "); err == nil {
148+
fmt.Println(string(jsonData))
149+
}
150+
151+
c.JSON(http.StatusOK, resp)
152+
}

internal/api/middleware/request_logging.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ func captureRequestInfo(c *gin.Context) (*RequestInfo, error) {
8080
headers[key] = values
8181
}
8282

83+
delete(headers, "Authorization")
84+
delete(headers, "Cookie")
85+
8386
// Capture request body
8487
var body []byte
8588
if c.Request.Body != nil {

internal/api/server.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/gin-gonic/gin"
2121
"github.com/router-for-me/CLIProxyAPI/v6/internal/access"
2222
managementHandlers "github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management"
23+
metrics "github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/metrics"
2324
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware"
2425
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
2526
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
@@ -148,6 +149,9 @@ type Server struct {
148149
// management handler
149150
mgmt *managementHandlers.Handler
150151

152+
// metrics handler
153+
metricsHandler *metrics.Handler
154+
151155
// managementRoutesRegistered tracks whether the management routes have been attached to the engine.
152156
managementRoutesRegistered atomic.Bool
153157
// managementRoutesEnabled controls whether management endpoints serve real handlers.
@@ -249,6 +253,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
249253
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
250254
// Initialize management handler
251255
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
256+
s.metricsHandler = metrics.NewHandler(usage.GetRequestStatistics())
252257
if optionState.localPassword != "" {
253258
s.mgmt.SetLocalPassword(optionState.localPassword)
254259
}
@@ -277,8 +282,13 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
277282
}
278283

279284
// Create HTTP server
285+
bindAddr := "localhost"
286+
if os.Getenv("IN_DOCKER") == "true" {
287+
bindAddr = "0.0.0.0"
288+
}
289+
280290
s.server = &http.Server{
281-
Addr: fmt.Sprintf(":%d", cfg.Port),
291+
Addr: fmt.Sprintf("%s:%d", bindAddr, cfg.Port),
282292
Handler: engine,
283293
}
284294

@@ -324,9 +334,21 @@ func (s *Server) setupRoutes() {
324334
"POST /v1/chat/completions",
325335
"POST /v1/completions",
326336
"GET /v1/models",
337+
"GET /_qs/health",
338+
"GET /_qs/metrics",
327339
},
328340
})
329341
})
342+
343+
qs := s.engine.Group("/_qs")
344+
{
345+
qs.GET("/health", func(c *gin.Context) {
346+
c.JSON(http.StatusOK, gin.H{"ok": true})
347+
})
348+
qs.GET("/metrics", s.metricsHandler.GetMetrics)
349+
qs.GET("/metrics/ui", s.serveMetricsUI)
350+
}
351+
330352
s.engine.POST("/v1internal:method", geminiCLIHandlers.CLIHandler)
331353

332354
// OAuth callback endpoints (reuse main server port)
@@ -550,6 +572,11 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) {
550572
c.File(filePath)
551573
}
552574

575+
func (s *Server) serveMetricsUI(c *gin.Context) {
576+
filePath := filepath.Join("ui", "metrics.html")
577+
c.File(filePath)
578+
}
579+
553580
func (s *Server) enableKeepAlive(timeout time.Duration, onTimeout func()) {
554581
if timeout <= 0 || onTimeout == nil {
555582
return

internal/cmd/run.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
1414
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
15+
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
1516
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
1617
log "github.com/sirupsen/logrus"
1718
)
@@ -49,6 +50,9 @@ func StartService(cfg *config.Config, configPath string, localPassword string) {
4950
}
5051

5152
err = service.Run(runCtx)
53+
54+
usage.StopMetricsPersistence()
55+
5256
if err != nil && !errors.Is(err, context.Canceled) {
5357
log.Fatalf("proxy service exited with error: %v", err)
5458
}

internal/config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"os"
1111
"strings"
1212
"syscall"
13+
"time"
1314

1415
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
1516
"golang.org/x/crypto/bcrypt"
@@ -60,6 +61,15 @@ type Config struct {
6061

6162
// RemoteManagement nests management-related options under 'remote-management'.
6263
RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"`
64+
65+
// MetricsFile is the path to the JSON file where metrics will be stored.
66+
MetricsFile string `yaml:"metrics-file,omitempty" json:"metrics-file,omitempty"`
67+
68+
// LoopDelay is the interval at which metrics are saved to the file.
69+
LoopDelay time.Duration `yaml:"loop-delay,omitempty" json:"loop-delay,omitempty"`
70+
71+
// CrashOnError determines if the application should crash if saving metrics fails.
72+
CrashOnError bool `yaml:"crash-on-error,omitempty" json:"crash-on-error,omitempty"`
6373
}
6474

6575
// RemoteManagement holds management API configuration under 'remote-management'.

internal/logging/gin_logger.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"time"
1111

1212
"github.com/gin-gonic/gin"
13+
"github.com/google/uuid"
1314
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
1415
log "github.com/sirupsen/logrus"
1516
)
@@ -22,6 +23,10 @@ import (
2223
// - gin.HandlerFunc: A middleware handler for request logging
2324
func GinLogrusLogger() gin.HandlerFunc {
2425
return func(c *gin.Context) {
26+
requestID := uuid.New().String()
27+
c.Set("request_id", requestID)
28+
c.Header("X-Request-ID", requestID)
29+
2530
start := time.Now()
2631
path := c.Request.URL.Path
2732
raw := util.MaskSensitiveQuery(c.Request.URL.RawQuery)

0 commit comments

Comments
 (0)