| |
| |
| |
| 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()) |
| } |
|
|
| |
| |
| type LoggerPlugin struct { |
| stats *RequestStatistics |
| } |
|
|
| |
| |
| |
| |
| func NewLoggerPlugin() *LoggerPlugin { return &LoggerPlugin{stats: defaultRequestStatistics} } |
|
|
| |
| |
| |
| |
| |
| |
| 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) |
| } |
|
|
| |
| func SetStatisticsEnabled(enabled bool) { statisticsEnabled.Store(enabled) } |
|
|
| |
| func StatisticsEnabled() bool { return statisticsEnabled.Load() } |
|
|
| |
| 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 |
| } |
|
|
| |
| type apiStats struct { |
| TotalRequests int64 |
| TotalTokens int64 |
| Models map[string]*modelStats |
| } |
|
|
| |
| type modelStats struct { |
| TotalRequests int64 |
| TotalTokens int64 |
| Details []RequestDetail |
| } |
|
|
| |
| 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"` |
| } |
|
|
| |
| 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"` |
| } |
|
|
| |
| 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"` |
| } |
|
|
| |
| type APISnapshot struct { |
| TotalRequests int64 `json:"total_requests"` |
| TotalTokens int64 `json:"total_tokens"` |
| Models map[string]ModelSnapshot `json:"models"` |
| } |
|
|
| |
| type ModelSnapshot struct { |
| TotalRequests int64 `json:"total_requests"` |
| TotalTokens int64 `json:"total_tokens"` |
| Details []RequestDetail `json:"details"` |
| } |
|
|
| var defaultRequestStatistics = NewRequestStatistics() |
|
|
| |
| func GetRequestStatistics() *RequestStatistics { return defaultRequestStatistics } |
|
|
| |
| 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), |
| } |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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"` |
| } |
|
|
| |
| |
| 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) |
| } |
|
|