| package proxy |
|
|
| import ( |
| "encoding/json" |
| "log" |
| "os" |
| "path/filepath" |
| "sort" |
| "sync" |
| "time" |
| ) |
|
|
| |
| |
| |
| |
| const usageStatsRetainDays = 30 |
|
|
| |
| |
| |
| |
| type usageBucket struct { |
| Input int64 `json:"input"` |
| Output int64 `json:"output"` |
| Count int64 `json:"count,omitempty"` |
| } |
|
|
| |
| |
| |
| |
| type UsageStats struct { |
| mu sync.RWMutex |
|
|
| |
| |
| TotalInput int64 `json:"total_input"` |
| TotalOutput int64 `json:"total_output"` |
| Requests int64 `json:"requests"` |
|
|
| |
| |
| ByDay map[string]*usageBucket `json:"by_day"` |
|
|
| |
| ByModel map[string]*usageBucket `json:"by_model"` |
| ByAccount map[string]*usageBucket `json:"by_account"` |
|
|
| |
| LastRecordAtMs int64 `json:"last_record_at_ms"` |
|
|
| |
| path string |
| dirty bool |
| } |
|
|
| |
| |
| |
| |
| |
| var ( |
| usageStatsOnce sync.Once |
| usageStatsSingleton *UsageStats |
| usageStatsFallback = &UsageStats{ |
| ByDay: map[string]*usageBucket{}, |
| ByModel: map[string]*usageBucket{}, |
| ByAccount: map[string]*usageBucket{}, |
| } |
| ) |
|
|
| |
| |
| |
| func GlobalUsageStats() *UsageStats { |
| if usageStatsSingleton != nil { |
| return usageStatsSingleton |
| } |
| return usageStatsFallback |
| } |
|
|
| |
| |
| |
| func InitUsageStats(path string) *UsageStats { |
| usageStatsOnce.Do(func() { |
| s := &UsageStats{ |
| ByDay: map[string]*usageBucket{}, |
| ByModel: map[string]*usageBucket{}, |
| ByAccount: map[string]*usageBucket{}, |
| path: path, |
| } |
| if err := s.Load(path); err != nil && !os.IsNotExist(err) { |
| log.Printf("[stats] load %s: %v (starting from zero)", path, err) |
| } |
| usageStatsSingleton = s |
| }) |
| return usageStatsSingleton |
| } |
|
|
| |
| |
| |
| func (s *UsageStats) Record(account, model string, prompt, completion int) { |
| if s == nil { |
| return |
| } |
| if prompt < 0 { |
| prompt = 0 |
| } |
| if completion < 0 { |
| completion = 0 |
| } |
| if prompt == 0 && completion == 0 { |
| |
| return |
| } |
|
|
| now := time.Now() |
| day := now.Format("2006-01-02") |
|
|
| s.mu.Lock() |
| defer s.mu.Unlock() |
|
|
| s.TotalInput += int64(prompt) |
| s.TotalOutput += int64(completion) |
| s.Requests++ |
| s.LastRecordAtMs = now.UnixMilli() |
|
|
| bumpBucket(s.ByDay, day, prompt, completion, false) |
| if model != "" { |
| bumpBucket(s.ByModel, model, prompt, completion, true) |
| } |
| if account != "" { |
| bumpBucket(s.ByAccount, account, prompt, completion, true) |
| } |
|
|
| s.trimByDayLocked() |
| s.dirty = true |
| } |
|
|
| func bumpBucket(m map[string]*usageBucket, key string, prompt, completion int, withCount bool) { |
| b, ok := m[key] |
| if !ok { |
| b = &usageBucket{} |
| m[key] = b |
| } |
| b.Input += int64(prompt) |
| b.Output += int64(completion) |
| if withCount { |
| b.Count++ |
| } |
| } |
|
|
| |
| |
| func (s *UsageStats) trimByDayLocked() { |
| if len(s.ByDay) <= usageStatsRetainDays { |
| return |
| } |
| cutoff := time.Now().AddDate(0, 0, -usageStatsRetainDays).Format("2006-01-02") |
| for k := range s.ByDay { |
| if k < cutoff { |
| delete(s.ByDay, k) |
| } |
| } |
| } |
|
|
| |
| type UsageBucketSnapshot struct { |
| Input int64 `json:"input"` |
| Output int64 `json:"output"` |
| Total int64 `json:"total"` |
| Requests int64 `json:"requests,omitempty"` |
| } |
|
|
| |
| type UsageDayPoint struct { |
| Date string `json:"date"` |
| Input int64 `json:"input"` |
| Output int64 `json:"output"` |
| Total int64 `json:"total"` |
| } |
|
|
| |
| type UsageRowSnapshot struct { |
| Key string `json:"-"` |
| Model string `json:"model,omitempty"` |
| Email string `json:"email,omitempty"` |
| Input int64 `json:"input"` |
| Output int64 `json:"output"` |
| Total int64 `json:"total"` |
| Count int64 `json:"count"` |
| } |
|
|
| |
| type UsageSnapshot struct { |
| Total UsageBucketSnapshot `json:"total"` |
| Today UsageBucketSnapshot `json:"today"` |
| Last24h UsageBucketSnapshot `json:"last_24h"` |
| ByDay []UsageDayPoint `json:"by_day"` |
| TopModels []UsageRowSnapshot `json:"top_models"` |
| TopAccounts []UsageRowSnapshot `json:"top_accounts"` |
| LastRecordAtMs int64 `json:"last_record_at"` |
| } |
|
|
| |
| |
| |
| func (s *UsageStats) Snapshot(topN int) UsageSnapshot { |
| if topN <= 0 { |
| topN = 5 |
| } |
|
|
| s.mu.RLock() |
| defer s.mu.RUnlock() |
|
|
| now := time.Now() |
| today := now.Format("2006-01-02") |
| yesterday := now.AddDate(0, 0, -1).Format("2006-01-02") |
|
|
| snap := UsageSnapshot{ |
| Total: UsageBucketSnapshot{ |
| Input: s.TotalInput, |
| Output: s.TotalOutput, |
| Total: s.TotalInput + s.TotalOutput, |
| Requests: s.Requests, |
| }, |
| LastRecordAtMs: s.LastRecordAtMs, |
| } |
|
|
| if b, ok := s.ByDay[today]; ok { |
| snap.Today = UsageBucketSnapshot{ |
| Input: b.Input, |
| Output: b.Output, |
| Total: b.Input + b.Output, |
| } |
| } |
|
|
| |
| |
| |
| last24 := snap.Today |
| if b, ok := s.ByDay[yesterday]; ok { |
| secondsSinceMidnight := float64(now.Hour()*3600 + now.Minute()*60 + now.Second()) |
| ratio := 1 - secondsSinceMidnight/86400 |
| if ratio < 0 { |
| ratio = 0 |
| } |
| last24.Input += int64(float64(b.Input) * ratio) |
| last24.Output += int64(float64(b.Output) * ratio) |
| last24.Total = last24.Input + last24.Output |
| } |
| snap.Last24h = last24 |
|
|
| |
| days := make([]string, 0, len(s.ByDay)) |
| for k := range s.ByDay { |
| days = append(days, k) |
| } |
| sort.Strings(days) |
| snap.ByDay = make([]UsageDayPoint, 0, len(days)) |
| for _, d := range days { |
| b := s.ByDay[d] |
| snap.ByDay = append(snap.ByDay, UsageDayPoint{ |
| Date: d, |
| Input: b.Input, |
| Output: b.Output, |
| Total: b.Input + b.Output, |
| }) |
| } |
|
|
| snap.TopModels = topRows(s.ByModel, topN, func(key string, row *UsageRowSnapshot) { |
| row.Model = key |
| }) |
| snap.TopAccounts = topRows(s.ByAccount, topN, func(key string, row *UsageRowSnapshot) { |
| row.Email = key |
| }) |
|
|
| return snap |
| } |
|
|
| func topRows(src map[string]*usageBucket, n int, label func(key string, row *UsageRowSnapshot)) []UsageRowSnapshot { |
| rows := make([]UsageRowSnapshot, 0, len(src)) |
| for k, b := range src { |
| row := UsageRowSnapshot{ |
| Key: k, |
| Input: b.Input, |
| Output: b.Output, |
| Total: b.Input + b.Output, |
| Count: b.Count, |
| } |
| label(k, &row) |
| rows = append(rows, row) |
| } |
| sort.Slice(rows, func(i, j int) bool { |
| if rows[i].Total != rows[j].Total { |
| return rows[i].Total > rows[j].Total |
| } |
| return rows[i].Key < rows[j].Key |
| }) |
| if len(rows) > n { |
| rows = rows[:n] |
| } |
| return rows |
| } |
|
|
| |
| |
| func (s *UsageStats) Load(path string) error { |
| data, err := os.ReadFile(path) |
| if err != nil { |
| return err |
| } |
| var raw struct { |
| TotalInput int64 `json:"total_input"` |
| TotalOutput int64 `json:"total_output"` |
| Requests int64 `json:"requests"` |
| ByDay map[string]*usageBucket `json:"by_day"` |
| ByModel map[string]*usageBucket `json:"by_model"` |
| ByAccount map[string]*usageBucket `json:"by_account"` |
| LastRecordAtMs int64 `json:"last_record_at_ms"` |
| } |
| if err := json.Unmarshal(data, &raw); err != nil { |
| return err |
| } |
| s.mu.Lock() |
| defer s.mu.Unlock() |
| s.TotalInput = raw.TotalInput |
| s.TotalOutput = raw.TotalOutput |
| s.Requests = raw.Requests |
| if raw.ByDay != nil { |
| s.ByDay = raw.ByDay |
| } |
| if raw.ByModel != nil { |
| s.ByModel = raw.ByModel |
| } |
| if raw.ByAccount != nil { |
| s.ByAccount = raw.ByAccount |
| } |
| s.LastRecordAtMs = raw.LastRecordAtMs |
| s.trimByDayLocked() |
| s.dirty = false |
| return nil |
| } |
|
|
| |
| |
| |
| func (s *UsageStats) Save(path string) error { |
| if path == "" { |
| return nil |
| } |
| s.mu.RLock() |
| data, err := json.MarshalIndent(s, "", " ") |
| s.mu.RUnlock() |
| if err != nil { |
| return err |
| } |
|
|
| if dir := filepath.Dir(path); dir != "." && dir != "" { |
| if err := os.MkdirAll(dir, 0o755); err != nil { |
| return err |
| } |
| } |
|
|
| tmp := path + ".tmp" |
| if err := os.WriteFile(tmp, data, 0o644); err != nil { |
| return err |
| } |
| return os.Rename(tmp, path) |
| } |
|
|
| |
| func (s *UsageStats) markClean() { |
| s.mu.Lock() |
| s.dirty = false |
| s.mu.Unlock() |
| } |
|
|
| |
| func (s *UsageStats) isDirty() bool { |
| s.mu.RLock() |
| defer s.mu.RUnlock() |
| return s.dirty |
| } |
|
|
| |
| |
| |
| func (s *UsageStats) StartFlushLoop(interval time.Duration) func() { |
| if s == nil || s.path == "" { |
| return func() {} |
| } |
| if interval <= 0 { |
| interval = 5 * time.Second |
| } |
| stop := make(chan struct{}) |
| done := make(chan struct{}) |
| go func() { |
| defer close(done) |
| t := time.NewTicker(interval) |
| defer t.Stop() |
| for { |
| select { |
| case <-t.C: |
| if s.isDirty() { |
| if err := s.Save(s.path); err != nil { |
| log.Printf("[stats] save %s: %v", s.path, err) |
| } else { |
| s.markClean() |
| } |
| } |
| case <-stop: |
| if s.isDirty() { |
| if err := s.Save(s.path); err != nil { |
| log.Printf("[stats] final save %s: %v", s.path, err) |
| } |
| } |
| return |
| } |
| } |
| }() |
| return func() { |
| close(stop) |
| <-done |
| } |
| } |
|
|