Skip to content
Closed
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
10 changes: 10 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ error-logs-max-files: 10
# When false, disable in-memory usage statistics aggregation
usage-statistics-enabled: false

# Usage statistics persistence (optional)
# When enabled, periodically saves aggregated usage snapshots to local file,
# including per-model hourly/daily buckets and recent request details.
# This helps keep frontend trend charts available after restart.
# usage-persistence:
# enabled: false
# file-path: "./usage_stats.json"
# interval-seconds: 300
# max-details-per-model: 300

# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:[email protected]:1080/
# Per-entry proxy-url also supports "direct" or "none" to bypass both the global proxy-url and environment proxies explicitly.
proxy-url: ""
Expand Down
57 changes: 57 additions & 0 deletions internal/api/handlers/management/config_basic.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,63 @@ func (h *Handler) PutUsageStatisticsEnabled(c *gin.Context) {
h.updateBoolField(c, func(v bool) { h.cfg.UsageStatisticsEnabled = v })
}

// UsagePersistence
func (h *Handler) GetUsagePersistence(c *gin.Context) {
if h == nil || h.cfg == nil {
c.JSON(200, gin.H{"usage-persistence": gin.H{}})
return
}
status := gin.H{}
if h.usagePersistence != nil {
persistStatus := h.usagePersistence.Status()
status["runtime"] = persistStatus
}
c.JSON(200, gin.H{
"usage-persistence": h.cfg.UsagePersistence,
"status": status,
})
}

func (h *Handler) PutUsagePersistence(c *gin.Context) {
if h == nil || h.cfg == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config state"})
return
}
var body struct {
Enabled *bool `json:"enabled"`
FilePath *string `json:"file-path"`
IntervalSeconds *int `json:"interval-seconds"`
MaxDetailsPerModel *int `json:"max-details-per-model"`
}
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
if body.Enabled != nil {
h.cfg.UsagePersistence.Enabled = *body.Enabled
}
if body.FilePath != nil {
h.cfg.UsagePersistence.FilePath = strings.TrimSpace(*body.FilePath)
if h.cfg.UsagePersistence.FilePath == "" {
h.cfg.UsagePersistence.FilePath = "usage-statistics.json"
}
}
if body.IntervalSeconds != nil {
if *body.IntervalSeconds <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "interval-seconds must be greater than 0"})
return
}
h.cfg.UsagePersistence.IntervalSeconds = *body.IntervalSeconds
}
if body.MaxDetailsPerModel != nil {
h.cfg.UsagePersistence.MaxDetailsPerModel = *body.MaxDetailsPerModel
}
if h.usagePersistence != nil {
h.usagePersistence.ApplyConfig(h.cfg.UsagePersistence)
}
h.persist(c)
}

// UsageStatisticsEnabled
func (h *Handler) GetLoggingToFile(c *gin.Context) {
c.JSON(200, gin.H{"logging-to-file": h.cfg.LoggingToFile})
Expand Down
83 changes: 61 additions & 22 deletions internal/api/handlers/management/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,35 +35,53 @@ const attemptMaxIdleTime = 2 * time.Hour

// Handler aggregates config reference, persistence path and helpers.
type Handler struct {
cfg *config.Config
configFilePath string
mu sync.Mutex
attemptsMu sync.Mutex
failedAttempts map[string]*attemptInfo // keyed by client IP
authManager *coreauth.Manager
usageStats *usage.RequestStatistics
tokenStore coreauth.Store
localPassword string
allowRemoteOverride bool
envSecret string
logDir string
postAuthHook coreauth.PostAuthHook
cfg *config.Config
configFilePath string
mu sync.Mutex
attemptsMu sync.Mutex
failedAttempts map[string]*attemptInfo // keyed by client IP
authManager *coreauth.Manager
usageStats *usage.RequestStatistics
usagePersistence *usage.PersistenceManager
ownsUsagePersistence bool
tokenStore coreauth.Store
localPassword string
allowRemoteOverride bool
envSecret string
logDir string
postAuthHook coreauth.PostAuthHook
}

// NewHandler creates a new management handler instance.
func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler {
return NewHandlerWithUsagePersistence(cfg, configFilePath, manager, nil)
}

// NewHandlerWithUsagePersistence creates a new management handler and optionally
// reuses an externally managed usage persistence manager.
func NewHandlerWithUsagePersistence(cfg *config.Config, configFilePath string, manager *coreauth.Manager, usagePersistence *usage.PersistenceManager) *Handler {
envSecret, _ := os.LookupEnv("MANAGEMENT_PASSWORD")
envSecret = strings.TrimSpace(envSecret)
owned := false
if usagePersistence == nil {
usagePersistence = usage.NewPersistenceManager(usage.GetRequestStatistics(), filepath.Dir(configFilePath))
owned = true
}

h := &Handler{
cfg: cfg,
configFilePath: configFilePath,
failedAttempts: make(map[string]*attemptInfo),
authManager: manager,
usageStats: usage.GetRequestStatistics(),
tokenStore: sdkAuth.GetTokenStore(),
allowRemoteOverride: envSecret != "",
envSecret: envSecret,
cfg: cfg,
configFilePath: configFilePath,
failedAttempts: make(map[string]*attemptInfo),
authManager: manager,
usageStats: usage.GetRequestStatistics(),
usagePersistence: usagePersistence,
ownsUsagePersistence: owned,
tokenStore: sdkAuth.GetTokenStore(),
allowRemoteOverride: envSecret != "",
envSecret: envSecret,
}
if cfg != nil && h.ownsUsagePersistence {
h.usagePersistence.ApplyConfig(cfg.UsagePersistence)
}
h.startAttemptCleanup()
return h
Expand Down Expand Up @@ -105,14 +123,25 @@ func NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manag
}

// SetConfig updates the in-memory config reference when the server hot-reloads.
func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg }
func (h *Handler) SetConfig(cfg *config.Config) {
h.cfg = cfg
if h != nil && h.ownsUsagePersistence && h.usagePersistence != nil && cfg != nil {
h.usagePersistence.ApplyConfig(cfg.UsagePersistence)
}
}
Comment on lines +126 to +131
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The ApplyConfig method for usagePersistence is called here within SetConfig, and also in Server.UpdateClients. This results in a redundant call during a hot reload. The Server should be the single source of truth for orchestrating configuration updates to its components.

func (h *Handler) SetConfig(cfg *config.Config) {
	h.cfg = cfg
}


// SetAuthManager updates the auth manager reference used by management endpoints.
func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = manager }

// SetUsageStatistics allows replacing the usage statistics reference.
func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats }

// SetUsagePersistenceManager replaces the usage persistence manager.
func (h *Handler) SetUsagePersistenceManager(manager *usage.PersistenceManager) {
h.usagePersistence = manager
h.ownsUsagePersistence = false
}

// SetLocalPassword configures the runtime-local password accepted for localhost requests.
func (h *Handler) SetLocalPassword(password string) { h.localPassword = password }

Expand All @@ -134,6 +163,16 @@ func (h *Handler) SetPostAuthHook(hook coreauth.PostAuthHook) {
h.postAuthHook = hook
}

// Stop releases background resources owned by management handler.
func (h *Handler) Stop() {
if h == nil {
return
}
if h.usagePersistence != nil {
h.usagePersistence.Stop(true)
}
}

// Middleware enforces access control for management endpoints.
// All requests (local and remote) require a valid management key.
// Additionally, remote access requires allow-remote-management=true.
Expand Down
46 changes: 44 additions & 2 deletions internal/api/handlers/management/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func (h *Handler) ExportUsageStatistics(c *gin.Context) {
snapshot = h.usageStats.Snapshot()
}
c.JSON(http.StatusOK, usageExportPayload{
Version: 1,
Version: 2,
ExportedAt: time.Now().UTC(),
Usage: snapshot,
})
Expand All @@ -63,7 +63,7 @@ func (h *Handler) ImportUsageStatistics(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
if payload.Version != 0 && payload.Version != 1 {
if payload.Version != 0 && payload.Version != 1 && payload.Version != 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported version"})
return
}
Expand All @@ -77,3 +77,45 @@ func (h *Handler) ImportUsageStatistics(c *gin.Context) {
"failed_requests": snapshot.FailureCount,
})
}

// GetUsagePersistenceStatus returns runtime usage persistence status.
func (h *Handler) GetUsagePersistenceStatus(c *gin.Context) {
if h == nil || h.usagePersistence == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "usage persistence unavailable"})
return
}
c.JSON(http.StatusOK, gin.H{"status": h.usagePersistence.Status()})
}

// SaveUsageStatistics persists current usage snapshot immediately.
func (h *Handler) SaveUsageStatistics(c *gin.Context) {
if h == nil || h.usagePersistence == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "usage persistence unavailable"})
return
}
status, err := h.usagePersistence.SaveNow()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "status": status})
return
}
c.JSON(http.StatusOK, gin.H{"status": status})
}

// LoadUsageStatistics loads usage snapshot from persistence and merges into memory.
func (h *Handler) LoadUsageStatistics(c *gin.Context) {
if h == nil || h.usagePersistence == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "usage persistence unavailable"})
return
}
result, err := h.usagePersistence.LoadNow()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "result": result})
return
}
snapshot := h.usageStats.Snapshot()
c.JSON(http.StatusOK, gin.H{
"result": result,
"total_requests": snapshot.TotalRequests,
"failed_requests": snapshot.FailureCount,
})
}
29 changes: 27 additions & 2 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ type Server struct {
keepAliveOnTimeout func()
keepAliveHeartbeat chan struct{}
keepAliveStop chan struct{}

usagePersistence *usage.PersistenceManager
}

// NewServer creates and initializes a new API server instance.
Expand Down Expand Up @@ -251,6 +253,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
currentPath: wd,
envManagementSecret: envManagementSecret,
wsRoutes: make(map[string]struct{}),
usagePersistence: usage.NewPersistenceManager(usage.GetRequestStatistics(), filepath.Dir(configFilePath)),
}
s.wsAuthEnabled.Store(cfg.WebsocketAuth)
// Save initial YAML snapshot
Expand All @@ -261,8 +264,12 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
}
managementasset.SetCurrentConfig(cfg)
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
if s.usagePersistence != nil {
s.usagePersistence.ApplyConfig(cfg.UsagePersistence)
}

// Initialize management handler
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
s.mgmt = managementHandlers.NewHandlerWithUsagePersistence(cfg, configFilePath, authManager, s.usagePersistence)
if optionState.localPassword != "" {
s.mgmt.SetLocalPassword(optionState.localPassword)
}
Expand Down Expand Up @@ -489,6 +496,9 @@ func (s *Server) registerManagementRoutes() {
mgmt.GET("/usage", s.mgmt.GetUsageStatistics)
mgmt.GET("/usage/export", s.mgmt.ExportUsageStatistics)
mgmt.POST("/usage/import", s.mgmt.ImportUsageStatistics)
mgmt.GET("/usage/persistence-status", s.mgmt.GetUsagePersistenceStatus)
mgmt.POST("/usage/save", s.mgmt.SaveUsageStatistics)
mgmt.POST("/usage/load", s.mgmt.LoadUsageStatistics)
mgmt.GET("/config", s.mgmt.GetConfig)
mgmt.GET("/config.yaml", s.mgmt.GetConfigYAML)
mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML)
Expand All @@ -514,6 +524,10 @@ func (s *Server) registerManagementRoutes() {
mgmt.PUT("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
mgmt.PATCH("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)

mgmt.GET("/usage-persistence", s.mgmt.GetUsagePersistence)
mgmt.PUT("/usage-persistence", s.mgmt.PutUsagePersistence)
mgmt.PATCH("/usage-persistence", s.mgmt.PutUsagePersistence)

mgmt.GET("/proxy-url", s.mgmt.GetProxyURL)
mgmt.PUT("/proxy-url", s.mgmt.PutProxyURL)
mgmt.PATCH("/proxy-url", s.mgmt.PutProxyURL)
Expand Down Expand Up @@ -831,7 +845,13 @@ func (s *Server) Stop(ctx context.Context) error {
}

// Shutdown the HTTP server.
if err := s.server.Shutdown(ctx); err != nil {
err := s.server.Shutdown(ctx)

if s.mgmt != nil {
s.mgmt.Stop()
}

if err != nil {
return fmt.Errorf("failed to shutdown HTTP server: %v", err)
}

Expand Down Expand Up @@ -904,6 +924,10 @@ func (s *Server) UpdateClients(cfg *config.Config) {
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
}

if s.usagePersistence != nil && (oldCfg == nil || !reflect.DeepEqual(oldCfg.UsagePersistence, cfg.UsagePersistence)) {
s.usagePersistence.ApplyConfig(cfg.UsagePersistence)
}

if s.requestLogger != nil && (oldCfg == nil || oldCfg.ErrorLogsMaxFiles != cfg.ErrorLogsMaxFiles) {
if setter, ok := s.requestLogger.(interface{ SetErrorLogsMaxFiles(int) }); ok {
setter.SetErrorLogsMaxFiles(cfg.ErrorLogsMaxFiles)
Expand Down Expand Up @@ -970,6 +994,7 @@ func (s *Server) UpdateClients(cfg *config.Config) {
if s.mgmt != nil {
s.mgmt.SetConfig(cfg)
s.mgmt.SetAuthManager(s.handlers.AuthManager)
s.mgmt.SetUsagePersistenceManager(s.usagePersistence)
}

// Notify Amp module only when Amp config has changed.
Expand Down
Loading
Loading