// Package usage provides usage tracking and logging functionality for the CLI Proxy API server. // It includes plugins for monitoring API usage, token consumption, and other metrics // to help with observability and billing purposes. package usage import ( "context" "fmt" "strings" "sync" "sync/atomic" "time" "github.com/gin-gonic/gin" coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" ) var statisticsEnabled atomic.Bool func init() { statisticsEnabled.Store(true) coreusage.RegisterPlugin(NewLoggerPlugin()) } // LoggerPlugin collects in-memory request statistics for usage analysis. // It implements coreusage.Plugin to receive usage records emitted by the runtime. type LoggerPlugin struct { stats *RequestStatistics } // NewLoggerPlugin constructs a new logger plugin instance. // // Returns: // - *LoggerPlugin: A new logger plugin instance wired to the shared statistics store. func NewLoggerPlugin() *LoggerPlugin { return &LoggerPlugin{stats: defaultRequestStatistics} } // HandleUsage implements coreusage.Plugin. // It updates the in-memory statistics store whenever a usage record is received. // // Parameters: // - ctx: The context for the usage record // - record: The usage record to aggregate func (p *LoggerPlugin) HandleUsage(ctx context.Context, record coreusage.Record) { if !statisticsEnabled.Load() { return } if p == nil || p.stats == nil { return } p.stats.Record(ctx, record) } // SetStatisticsEnabled toggles whether in-memory statistics are recorded. func SetStatisticsEnabled(enabled bool) { statisticsEnabled.Store(enabled) } // StatisticsEnabled reports the current recording state. func StatisticsEnabled() bool { return statisticsEnabled.Load() } // RequestStatistics maintains aggregated request metrics in memory. type RequestStatistics struct { mu sync.RWMutex totalRequests int64 successCount int64 failureCount int64 totalTokens int64 apis map[string]*apiStats requestsByDay map[string]int64 requestsByHour map[int]int64 tokensByDay map[string]int64 tokensByHour map[int]int64 } // apiStats holds aggregated metrics for a single API key. type apiStats struct { TotalRequests int64 TotalTokens int64 Models map[string]*modelStats } // modelStats holds aggregated metrics for a specific model within an API. type modelStats struct { TotalRequests int64 TotalTokens int64 Details []RequestDetail } // RequestDetail stores the timestamp and token usage for a single request. type RequestDetail struct { Timestamp time.Time `json:"timestamp"` Source string `json:"source"` AuthIndex string `json:"auth_index"` Tokens TokenStats `json:"tokens"` Failed bool `json:"failed"` } // TokenStats captures the token usage breakdown for a request. type TokenStats struct { InputTokens int64 `json:"input_tokens"` OutputTokens int64 `json:"output_tokens"` ReasoningTokens int64 `json:"reasoning_tokens"` CachedTokens int64 `json:"cached_tokens"` TotalTokens int64 `json:"total_tokens"` } // StatisticsSnapshot represents an immutable view of the aggregated metrics. type StatisticsSnapshot struct { TotalRequests int64 `json:"total_requests"` SuccessCount int64 `json:"success_count"` FailureCount int64 `json:"failure_count"` TotalTokens int64 `json:"total_tokens"` APIs map[string]APISnapshot `json:"apis"` RequestsByDay map[string]int64 `json:"requests_by_day"` RequestsByHour map[string]int64 `json:"requests_by_hour"` TokensByDay map[string]int64 `json:"tokens_by_day"` TokensByHour map[string]int64 `json:"tokens_by_hour"` } // APISnapshot summarises metrics for a single API key. type APISnapshot struct { TotalRequests int64 `json:"total_requests"` TotalTokens int64 `json:"total_tokens"` Models map[string]ModelSnapshot `json:"models"` } // ModelSnapshot summarises metrics for a specific model. type ModelSnapshot struct { TotalRequests int64 `json:"total_requests"` TotalTokens int64 `json:"total_tokens"` Details []RequestDetail `json:"details"` } var defaultRequestStatistics = NewRequestStatistics() // GetRequestStatistics returns the shared statistics store. func GetRequestStatistics() *RequestStatistics { return defaultRequestStatistics } // NewRequestStatistics constructs an empty statistics store. func NewRequestStatistics() *RequestStatistics { return &RequestStatistics{ apis: make(map[string]*apiStats), requestsByDay: make(map[string]int64), requestsByHour: make(map[int]int64), tokensByDay: make(map[string]int64), tokensByHour: make(map[int]int64), } } // Record ingests a new usage record and updates the aggregates. func (s *RequestStatistics) Record(ctx context.Context, record coreusage.Record) { if s == nil { return } if !statisticsEnabled.Load() { return } timestamp := record.RequestedAt if timestamp.IsZero() { timestamp = time.Now() } detail := normaliseDetail(record.Detail) totalTokens := detail.TotalTokens statsKey := record.APIKey if statsKey == "" { statsKey = resolveAPIIdentifier(ctx, record) } failed := record.Failed if !failed { failed = !resolveSuccess(ctx) } success := !failed modelName := record.Model if modelName == "" { modelName = "unknown" } dayKey := timestamp.Format("2006-01-02") hourKey := timestamp.Hour() s.mu.Lock() defer s.mu.Unlock() s.totalRequests++ if success { s.successCount++ } else { s.failureCount++ } s.totalTokens += totalTokens stats, ok := s.apis[statsKey] if !ok { stats = &apiStats{Models: make(map[string]*modelStats)} s.apis[statsKey] = stats } s.updateAPIStats(stats, modelName, RequestDetail{ Timestamp: timestamp, Source: record.Source, AuthIndex: record.AuthIndex, Tokens: detail, Failed: failed, }) s.requestsByDay[dayKey]++ s.requestsByHour[hourKey]++ s.tokensByDay[dayKey] += totalTokens s.tokensByHour[hourKey] += totalTokens } func (s *RequestStatistics) updateAPIStats(stats *apiStats, model string, detail RequestDetail) { stats.TotalRequests++ stats.TotalTokens += detail.Tokens.TotalTokens modelStatsValue, ok := stats.Models[model] if !ok { modelStatsValue = &modelStats{} stats.Models[model] = modelStatsValue } modelStatsValue.TotalRequests++ modelStatsValue.TotalTokens += detail.Tokens.TotalTokens modelStatsValue.Details = append(modelStatsValue.Details, detail) } // Snapshot returns a copy of the aggregated metrics for external consumption. func (s *RequestStatistics) Snapshot() StatisticsSnapshot { result := StatisticsSnapshot{} if s == nil { return result } s.mu.RLock() defer s.mu.RUnlock() result.TotalRequests = s.totalRequests result.SuccessCount = s.successCount result.FailureCount = s.failureCount result.TotalTokens = s.totalTokens result.APIs = make(map[string]APISnapshot, len(s.apis)) for apiName, stats := range s.apis { apiSnapshot := APISnapshot{ TotalRequests: stats.TotalRequests, TotalTokens: stats.TotalTokens, Models: make(map[string]ModelSnapshot, len(stats.Models)), } for modelName, modelStatsValue := range stats.Models { requestDetails := make([]RequestDetail, len(modelStatsValue.Details)) copy(requestDetails, modelStatsValue.Details) apiSnapshot.Models[modelName] = ModelSnapshot{ TotalRequests: modelStatsValue.TotalRequests, TotalTokens: modelStatsValue.TotalTokens, Details: requestDetails, } } result.APIs[apiName] = apiSnapshot } result.RequestsByDay = make(map[string]int64, len(s.requestsByDay)) for k, v := range s.requestsByDay { result.RequestsByDay[k] = v } result.RequestsByHour = make(map[string]int64, len(s.requestsByHour)) for hour, v := range s.requestsByHour { key := formatHour(hour) result.RequestsByHour[key] = v } result.TokensByDay = make(map[string]int64, len(s.tokensByDay)) for k, v := range s.tokensByDay { result.TokensByDay[k] = v } result.TokensByHour = make(map[string]int64, len(s.tokensByHour)) for hour, v := range s.tokensByHour { key := formatHour(hour) result.TokensByHour[key] = v } return result } type MergeResult struct { Added int64 `json:"added"` Skipped int64 `json:"skipped"` } // MergeSnapshot merges an exported statistics snapshot into the current store. // Existing data is preserved and duplicate request details are skipped. func (s *RequestStatistics) MergeSnapshot(snapshot StatisticsSnapshot) MergeResult { result := MergeResult{} if s == nil { return result } s.mu.Lock() defer s.mu.Unlock() seen := make(map[string]struct{}) for apiName, stats := range s.apis { if stats == nil { continue } for modelName, modelStatsValue := range stats.Models { if modelStatsValue == nil { continue } for _, detail := range modelStatsValue.Details { seen[dedupKey(apiName, modelName, detail)] = struct{}{} } } } for apiName, apiSnapshot := range snapshot.APIs { apiName = strings.TrimSpace(apiName) if apiName == "" { continue } stats, ok := s.apis[apiName] if !ok || stats == nil { stats = &apiStats{Models: make(map[string]*modelStats)} s.apis[apiName] = stats } else if stats.Models == nil { stats.Models = make(map[string]*modelStats) } for modelName, modelSnapshot := range apiSnapshot.Models { modelName = strings.TrimSpace(modelName) if modelName == "" { modelName = "unknown" } for _, detail := range modelSnapshot.Details { detail.Tokens = normaliseTokenStats(detail.Tokens) if detail.Timestamp.IsZero() { detail.Timestamp = time.Now() } key := dedupKey(apiName, modelName, detail) if _, exists := seen[key]; exists { result.Skipped++ continue } seen[key] = struct{}{} s.recordImported(apiName, modelName, stats, detail) result.Added++ } } } return result } func (s *RequestStatistics) recordImported(apiName, modelName string, stats *apiStats, detail RequestDetail) { totalTokens := detail.Tokens.TotalTokens if totalTokens < 0 { totalTokens = 0 } s.totalRequests++ if detail.Failed { s.failureCount++ } else { s.successCount++ } s.totalTokens += totalTokens s.updateAPIStats(stats, modelName, detail) dayKey := detail.Timestamp.Format("2006-01-02") hourKey := detail.Timestamp.Hour() s.requestsByDay[dayKey]++ s.requestsByHour[hourKey]++ s.tokensByDay[dayKey] += totalTokens s.tokensByHour[hourKey] += totalTokens } func dedupKey(apiName, modelName string, detail RequestDetail) string { timestamp := detail.Timestamp.UTC().Format(time.RFC3339Nano) tokens := normaliseTokenStats(detail.Tokens) return fmt.Sprintf( "%s|%s|%s|%s|%s|%t|%d|%d|%d|%d|%d", apiName, modelName, timestamp, detail.Source, detail.AuthIndex, detail.Failed, tokens.InputTokens, tokens.OutputTokens, tokens.ReasoningTokens, tokens.CachedTokens, tokens.TotalTokens, ) } 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 } } } if record.Provider != "" { return record.Provider } return "unknown" } func resolveSuccess(ctx context.Context) bool { if ctx == nil { return true } ginCtx, ok := ctx.Value("gin").(*gin.Context) if !ok || ginCtx == nil { return true } status := ginCtx.Writer.Status() if status == 0 { return true } return status < httpStatusBadRequest } const httpStatusBadRequest = 400 func normaliseDetail(detail coreusage.Detail) TokenStats { tokens := TokenStats{ InputTokens: detail.InputTokens, OutputTokens: detail.OutputTokens, ReasoningTokens: detail.ReasoningTokens, CachedTokens: detail.CachedTokens, TotalTokens: detail.TotalTokens, } if tokens.TotalTokens == 0 { tokens.TotalTokens = detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens } if tokens.TotalTokens == 0 { tokens.TotalTokens = detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens + detail.CachedTokens } return tokens } func normaliseTokenStats(tokens TokenStats) TokenStats { if tokens.TotalTokens == 0 { tokens.TotalTokens = tokens.InputTokens + tokens.OutputTokens + tokens.ReasoningTokens } if tokens.TotalTokens == 0 { tokens.TotalTokens = tokens.InputTokens + tokens.OutputTokens + tokens.ReasoningTokens + tokens.CachedTokens } return tokens } func formatHour(hour int) string { if hour < 0 { hour = 0 } hour = hour % 24 return fmt.Sprintf("%02d", hour) }