Skip to content

Commit 4fda8ca

Browse files
committed
fix: restore usage statistics support
1 parent 579ee74 commit 4fda8ca

18 files changed

Lines changed: 1823 additions & 41 deletions

File tree

cmd/server/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"github.com/router-for-me/CLIProxyAPI/v6/internal/store"
3131
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
3232
"github.com/router-for-me/CLIProxyAPI/v6/internal/tui"
33+
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
3334
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
3435
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
3536
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
@@ -469,6 +470,7 @@ func main() {
469470
configFileExists = true
470471
}
471472
}
473+
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
472474
redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled)
473475
coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
474476

internal/api/handlers/management/handler.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/gin-gonic/gin"
1616
"github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
1717
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
18+
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
1819
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
1920
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
2021
"golang.org/x/crypto/bcrypt"
@@ -40,6 +41,7 @@ type Handler struct {
4041
attemptsMu sync.Mutex
4142
failedAttempts map[string]*attemptInfo // keyed by client IP
4243
authManager *coreauth.Manager
44+
usageStats *usage.RequestStatistics
4345
tokenStore coreauth.Store
4446
localPassword string
4547
allowRemoteOverride bool
@@ -58,6 +60,7 @@ func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Man
5860
configFilePath: configFilePath,
5961
failedAttempts: make(map[string]*attemptInfo),
6062
authManager: manager,
63+
usageStats: usage.GetRequestStatistics(),
6164
tokenStore: sdkAuth.GetTokenStore(),
6265
allowRemoteOverride: envSecret != "",
6366
envSecret: envSecret,
@@ -121,6 +124,9 @@ func (h *Handler) SetAuthManager(manager *coreauth.Manager) {
121124
h.mu.Unlock()
122125
}
123126

127+
// SetUsageStatistics allows replacing the usage statistics reference.
128+
func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats }
129+
124130
// SetLocalPassword configures the runtime-local password accepted for localhost requests.
125131
func (h *Handler) SetLocalPassword(password string) { h.localPassword = password }
126132

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package management
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"io"
7+
"net/http"
8+
"time"
9+
10+
"github.com/gin-gonic/gin"
11+
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
12+
)
13+
14+
var usageImportMaxBytes int64 = 20 << 20
15+
16+
type usageExportPayload struct {
17+
Version int `json:"version"`
18+
ExportedAt time.Time `json:"exported_at"`
19+
Usage usage.StatisticsSnapshot `json:"usage"`
20+
}
21+
22+
type usageImportPayload struct {
23+
Version int `json:"version"`
24+
Usage usage.StatisticsSnapshot `json:"usage"`
25+
}
26+
27+
// GetUsageStatistics returns the in-memory request statistics snapshot.
28+
func (h *Handler) GetUsageStatistics(c *gin.Context) {
29+
var snapshot usage.StatisticsSnapshot
30+
if h != nil && h.usageStats != nil {
31+
snapshot = h.usageStats.Snapshot()
32+
}
33+
c.JSON(http.StatusOK, gin.H{
34+
"usage": snapshot,
35+
"failed_requests": snapshot.FailureCount,
36+
})
37+
}
38+
39+
// ExportUsageStatistics returns a complete usage snapshot for backup/migration.
40+
func (h *Handler) ExportUsageStatistics(c *gin.Context) {
41+
var snapshot usage.StatisticsSnapshot
42+
if h != nil && h.usageStats != nil {
43+
snapshot = h.usageStats.Snapshot()
44+
}
45+
c.JSON(http.StatusOK, usageExportPayload{
46+
Version: 1,
47+
ExportedAt: time.Now().UTC(),
48+
Usage: snapshot,
49+
})
50+
}
51+
52+
// ImportUsageStatistics restores a previously exported usage snapshot into memory.
53+
func (h *Handler) ImportUsageStatistics(c *gin.Context) {
54+
if h == nil || h.usageStats == nil {
55+
c.JSON(http.StatusBadRequest, gin.H{"error": "usage statistics unavailable"})
56+
return
57+
}
58+
59+
data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, usageImportMaxBytes))
60+
if err != nil {
61+
var maxBytesErr *http.MaxBytesError
62+
if errors.As(err, &maxBytesErr) {
63+
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "usage import payload too large"})
64+
return
65+
}
66+
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"})
67+
return
68+
}
69+
70+
var payload usageImportPayload
71+
if err := json.Unmarshal(data, &payload); err != nil {
72+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
73+
return
74+
}
75+
if payload.Version != 0 && payload.Version != 1 {
76+
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported version"})
77+
return
78+
}
79+
80+
result := h.usageStats.RestoreSnapshot(payload.Usage)
81+
snapshot := h.usageStats.Snapshot()
82+
c.JSON(http.StatusOK, gin.H{
83+
"added": result.Added,
84+
"skipped": result.Skipped,
85+
"total_requests": snapshot.TotalRequests,
86+
"failed_requests": snapshot.FailureCount,
87+
})
88+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package management
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"strings"
7+
"testing"
8+
9+
"github.com/gin-gonic/gin"
10+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
11+
)
12+
13+
func TestImportUsageStatisticsRejectsOversizedBody(t *testing.T) {
14+
t.Setenv("MANAGEMENT_PASSWORD", "")
15+
gin.SetMode(gin.TestMode)
16+
17+
prevMaxBytes := usageImportMaxBytes
18+
usageImportMaxBytes = 8
19+
t.Cleanup(func() {
20+
usageImportMaxBytes = prevMaxBytes
21+
})
22+
23+
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, nil)
24+
25+
rec := httptest.NewRecorder()
26+
ginCtx, _ := gin.CreateTestContext(rec)
27+
ginCtx.Request = httptest.NewRequest(
28+
http.MethodPost,
29+
"/v0/management/usage/import",
30+
strings.NewReader(`{"version":1,"usage":{"total_requests":1}}`),
31+
)
32+
33+
h.ImportUsageStatistics(ginCtx)
34+
35+
if rec.Code != http.StatusRequestEntityTooLarge {
36+
t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String())
37+
}
38+
}

internal/api/server.go

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
3333
"github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
3434
"github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue"
35+
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
3536
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
3637
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
3738
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
@@ -546,6 +547,9 @@ func (s *Server) registerManagementRoutes() {
546547
mgmt := s.engine.Group("/v0/management")
547548
mgmt.Use(s.managementAvailabilityMiddleware(), s.mgmt.Middleware())
548549
{
550+
mgmt.GET("/usage", s.mgmt.GetUsageStatistics)
551+
mgmt.GET("/usage/export", s.mgmt.ExportUsageStatistics)
552+
mgmt.POST("/usage/import", s.mgmt.ImportUsageStatistics)
549553
mgmt.GET("/config", s.mgmt.GetConfig)
550554
mgmt.GET("/config.yaml", s.mgmt.GetConfigYAML)
551555
mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML)
@@ -695,18 +699,18 @@ func (s *Server) registerManagementRoutes() {
695699
mgmt.GET("/gitlab-auth-url", s.mgmt.RequestGitLabToken)
696700
mgmt.POST("/gitlab-auth-url", s.mgmt.RequestGitLabPATToken)
697701
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
698-
mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken)
699-
mgmt.GET("/kilo-auth-url", s.mgmt.RequestKiloToken)
700-
mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken)
701-
mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
702-
mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken)
703-
mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken)
704-
mgmt.GET("/cursor-auth-url", s.mgmt.RequestCursorToken)
705-
mgmt.GET("/github-auth-url", s.mgmt.RequestGitHubToken)
706-
mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback)
707-
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
708-
}
702+
mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken)
703+
mgmt.GET("/kilo-auth-url", s.mgmt.RequestKiloToken)
704+
mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken)
705+
mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
706+
mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken)
707+
mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken)
708+
mgmt.GET("/cursor-auth-url", s.mgmt.RequestCursorToken)
709+
mgmt.GET("/github-auth-url", s.mgmt.RequestGitHubToken)
710+
mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback)
711+
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
709712
}
713+
}
710714

711715
func (s *Server) managementAvailabilityMiddleware() gin.HandlerFunc {
712716
return func(c *gin.Context) {
@@ -1047,6 +1051,7 @@ func (s *Server) UpdateClients(cfg *config.Config) {
10471051
}
10481052

10491053
if oldCfg == nil || oldCfg.UsageStatisticsEnabled != cfg.UsageStatisticsEnabled {
1054+
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
10501055
redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled)
10511056
}
10521057

internal/config/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
)
2121

2222
const (
23-
DefaultPanelGitHubRepository = "https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
23+
DefaultPanelGitHubRepository = "https://github.com/kaitranntt/Cli-Proxy-API-Management-Center"
2424
DefaultPprofAddr = "127.0.0.1:8316"
2525
)
2626

internal/managementasset/updater.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import (
2525
)
2626

2727
const (
28-
defaultManagementReleaseURL = "https://api.github.com/repos/router-for-me/Cli-Proxy-API-Management-Center/releases/latest"
28+
defaultManagementReleaseURL = "https://api.github.com/repos/kaitranntt/Cli-Proxy-API-Management-Center/releases/latest"
2929
defaultManagementFallbackURL = "https://cpamc.router-for.me/"
3030
managementAssetName = "management.html"
3131
httpUserAgent = "CLIProxyAPI-management-updater"

internal/tui/app.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const (
1818
tabAuthFiles
1919
tabAPIKeys
2020
tabOAuth
21+
tabUsage
2122
tabLogs
2223
)
2324

@@ -39,6 +40,7 @@ type App struct {
3940
auth authTabModel
4041
keys keysTabModel
4142
oauth oauthTabModel
43+
usage usageTabModel
4244
logs logsTabModel
4345

4446
client *Client
@@ -48,7 +50,7 @@ type App struct {
4850
ready bool
4951

5052
// Track which tabs have been initialized (fetched data)
51-
initialized [6]bool
53+
initialized [7]bool
5254
}
5355

5456
type authConnectMsg struct {
@@ -79,17 +81,18 @@ func NewApp(port int, secretKey string, hook *LogHook) App {
7981
auth: newAuthTabModel(client),
8082
keys: newKeysTabModel(client),
8183
oauth: newOAuthTabModel(client),
84+
usage: newUsageTabModel(client),
8285
logs: newLogsTabModel(client, hook),
8386
client: client,
84-
initialized: [6]bool{
87+
initialized: [7]bool{
8588
tabDashboard: true,
8689
tabLogs: true,
8790
},
8891
}
8992

9093
app.refreshTabs()
9194
if authRequired {
92-
app.initialized = [6]bool{}
95+
app.initialized = [7]bool{}
9396
}
9497
app.setAuthInputPrompt()
9598
return app
@@ -125,6 +128,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
125128
a.auth.SetSize(contentW, contentH)
126129
a.keys.SetSize(contentW, contentH)
127130
a.oauth.SetSize(contentW, contentH)
131+
a.usage.SetSize(contentW, contentH)
128132
a.logs.SetSize(contentW, contentH)
129133
return a, nil
130134

@@ -138,7 +142,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
138142
a.authenticated = true
139143
a.logsEnabled = a.standalone || isLogsEnabledFromConfig(msg.cfg)
140144
a.refreshTabs()
141-
a.initialized = [6]bool{}
145+
a.initialized = [7]bool{}
142146
a.initialized[tabDashboard] = true
143147
cmds := []tea.Cmd{a.dashboard.Init()}
144148
if a.logsEnabled {
@@ -254,6 +258,8 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
254258
a.keys, cmd = a.keys.Update(msg)
255259
case tabOAuth:
256260
a.oauth, cmd = a.oauth.Update(msg)
261+
case tabUsage:
262+
a.usage, cmd = a.usage.Update(msg)
257263
case tabLogs:
258264
a.logs, cmd = a.logs.Update(msg)
259265
}
@@ -316,6 +322,8 @@ func (a *App) initTabIfNeeded(_ int) tea.Cmd {
316322
return a.keys.Init()
317323
case tabOAuth:
318324
return a.oauth.Init()
325+
case tabUsage:
326+
return a.usage.Init()
319327
case tabLogs:
320328
if !a.logsEnabled {
321329
return nil
@@ -352,6 +360,8 @@ func (a App) View() string {
352360
sb.WriteString(a.keys.View())
353361
case tabOAuth:
354362
sb.WriteString(a.oauth.View())
363+
case tabUsage:
364+
sb.WriteString(a.usage.View())
355365
case tabLogs:
356366
if a.logsEnabled {
357367
sb.WriteString(a.logs.View())
@@ -519,6 +529,10 @@ func (a App) broadcastToAllTabs(msg tea.Msg) (tea.Model, tea.Cmd) {
519529
if cmd != nil {
520530
cmds = append(cmds, cmd)
521531
}
532+
a.usage, cmd = a.usage.Update(msg)
533+
if cmd != nil {
534+
cmds = append(cmds, cmd)
535+
}
522536
a.logs, cmd = a.logs.Update(msg)
523537
if cmd != nil {
524538
cmds = append(cmds, cmd)

internal/tui/client.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ func (c *Client) PutConfigYAML(yamlContent string) error {
140140
return err
141141
}
142142

143+
// GetUsage fetches usage statistics.
144+
func (c *Client) GetUsage() (map[string]any, error) {
145+
return c.getJSON("/v0/management/usage")
146+
}
147+
143148
// GetAuthFiles lists auth credential files.
144149
// API returns {"files": [...]}.
145150
func (c *Client) GetAuthFiles() ([]map[string]any, error) {

0 commit comments

Comments
 (0)