|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
} |
|
|
|