| package app |
|
|
| import ( |
| "context" |
| "net/http" |
| "strconv" |
| "sync" |
| "time" |
|
|
| "ccLoad/internal/model" |
| "ccLoad/internal/util" |
| "ccLoad/internal/version" |
|
|
| "github.com/gin-gonic/gin" |
| ) |
|
|
| |
| |
|
|
| |
| |
| func (s *Server) HandleErrors(c *gin.Context) { |
| params := ParsePaginationParams(c) |
| lf := BuildLogFilter(c) |
| since, until := params.GetTimeRange() |
|
|
| logs, total, err := s.store.ListLogsRangeWithCount(c.Request.Context(), since, until, params.Limit, params.Offset, &lf) |
| if err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| RespondJSONWithCount(c, http.StatusOK, logs, total) |
| } |
|
|
| |
| |
| func (s *Server) HandleMetrics(c *gin.Context) { |
| params := ParsePaginationParams(c) |
| bucketMin, _ := strconv.Atoi(c.DefaultQuery("bucket_min", "5")) |
| if bucketMin <= 0 { |
| bucketMin = 5 |
| } |
|
|
| |
| lf := BuildLogFilter(c) |
| lf.LogSource = model.LogSourceProxy |
|
|
| since, until := params.GetTimeRange() |
| pts, err := s.store.AggregateRangeWithFilter(c.Request.Context(), since, until, time.Duration(bucketMin)*time.Minute, &lf) |
|
|
| if err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| RespondJSON(c, http.StatusOK, pts) |
| } |
|
|
| |
| |
| func (s *Server) HandleStats(c *gin.Context) { |
| params := ParsePaginationParams(c) |
| lf := BuildLogFilter(c) |
| lf.LogSource = model.LogSourceProxy |
|
|
| startTime, endTime := params.GetTimeRange() |
|
|
| |
| isToday := params.Range == "today" || params.Range == "" |
|
|
| stats, err := s.statsCache.GetStats(c.Request.Context(), startTime, endTime, &lf, isToday) |
| if err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| |
| durationSeconds := endTime.Sub(startTime).Seconds() |
| if durationSeconds < 1 { |
| durationSeconds = 1 |
| } |
|
|
| |
| rpmStats, err := s.statsCache.GetRPMStats(c.Request.Context(), startTime, endTime, &lf, isToday) |
| if err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| |
| channelHealth := s.fillHealthTimeline(c.Request.Context(), stats, startTime, endTime, &lf, isToday) |
|
|
| RespondJSON(c, http.StatusOK, gin.H{ |
| "stats": stats, |
| "channel_health": channelHealth, |
| "duration_seconds": durationSeconds, |
| "rpm_stats": rpmStats, |
| "is_today": isToday, |
| }) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| func (s *Server) HandlePublicSummary(c *gin.Context) { |
| params := ParsePaginationParams(c) |
| startTime, endTime := params.GetTimeRange() |
|
|
| |
| isToday := params.Range == "today" || params.Range == "" |
| ctx := c.Request.Context() |
|
|
| |
| var ( |
| stats []model.StatsEntry |
| rpmStats *model.RPMStats |
| channelTypes map[int64]string |
| statsErr error |
| rpmErr error |
| typesErr error |
| wg sync.WaitGroup |
| ) |
|
|
| wg.Add(3) |
|
|
| |
| go func() { |
| defer wg.Done() |
| stats, statsErr = s.statsCache.GetStatsLite(ctx, startTime, endTime, nil) |
| }() |
|
|
| |
| go func() { |
| defer wg.Done() |
| rpmStats, rpmErr = s.statsCache.GetRPMStats(ctx, startTime, endTime, nil, isToday) |
| }() |
|
|
| |
| go func() { |
| defer wg.Done() |
| channelTypes, typesErr = s.getChannelTypesMapCached(ctx) |
| }() |
|
|
| wg.Wait() |
|
|
| |
| if statsErr != nil { |
| RespondError(c, http.StatusInternalServerError, statsErr) |
| return |
| } |
| if rpmErr != nil { |
| RespondError(c, http.StatusInternalServerError, rpmErr) |
| return |
| } |
| if typesErr != nil { |
| RespondError(c, http.StatusInternalServerError, typesErr) |
| return |
| } |
|
|
| |
| durationSeconds := endTime.Sub(startTime).Seconds() |
| if durationSeconds < 1 { |
| durationSeconds = 1 |
| } |
|
|
| |
| typeStats := make(map[string]*TypeSummary) |
| totalSuccess := 0 |
| totalError := 0 |
|
|
| for _, stat := range stats { |
| |
| var channelType string |
| if stat.ChannelID != nil { |
| if ct, ok := channelTypes[int64(*stat.ChannelID)]; ok { |
| channelType = ct |
| } |
| } |
| if channelType == "" { |
| |
| continue |
| } |
|
|
| totalSuccess += stat.Success |
| totalError += stat.Error |
|
|
| |
| if _, exists := typeStats[channelType]; !exists { |
| typeStats[channelType] = &TypeSummary{ |
| ChannelType: channelType, |
| TotalRequests: 0, |
| SuccessRequests: 0, |
| ErrorRequests: 0, |
| } |
| } |
|
|
| ts := typeStats[channelType] |
| ts.TotalRequests += stat.Success + stat.Error |
| ts.SuccessRequests += stat.Success |
| ts.ErrorRequests += stat.Error |
|
|
| |
| if stat.TotalInputTokens != nil { |
| ts.TotalInputTokens += *stat.TotalInputTokens |
| } |
| if stat.TotalOutputTokens != nil { |
| ts.TotalOutputTokens += *stat.TotalOutputTokens |
| } |
| if stat.TotalCost != nil { |
| ts.TotalCost += *stat.TotalCost |
| } |
| if stat.EffectiveCost != nil { |
| if ts.EffectiveCost == nil { |
| ts.EffectiveCost = new(float64) |
| } |
| *ts.EffectiveCost += *stat.EffectiveCost |
| } else if stat.TotalCost != nil { |
| if ts.EffectiveCost == nil { |
| ts.EffectiveCost = new(float64) |
| } |
| *ts.EffectiveCost += *stat.TotalCost |
| } |
|
|
| |
| if channelType == "anthropic" || channelType == "codex" { |
| if stat.TotalCacheReadInputTokens != nil { |
| ts.TotalCacheReadTokens += *stat.TotalCacheReadInputTokens |
| } |
| if stat.TotalCacheCreationInputTokens != nil { |
| ts.TotalCacheCreationTokens += *stat.TotalCacheCreationInputTokens |
| } |
| } |
| } |
|
|
| response := gin.H{ |
| "total_requests": totalSuccess + totalError, |
| "success_requests": totalSuccess, |
| "error_requests": totalError, |
| "range": params.Range, |
| "duration_seconds": durationSeconds, |
| "rpm_stats": rpmStats, |
| "is_today": isToday, |
| "by_type": typeStats, |
| } |
|
|
| RespondJSON(c, http.StatusOK, response) |
| } |
|
|
| |
| type TypeSummary struct { |
| ChannelType string `json:"channel_type"` |
| TotalRequests int `json:"total_requests"` |
| SuccessRequests int `json:"success_requests"` |
| ErrorRequests int `json:"error_requests"` |
| TotalInputTokens int64 `json:"total_input_tokens,omitempty"` |
| TotalOutputTokens int64 `json:"total_output_tokens,omitempty"` |
| TotalCacheReadTokens int64 `json:"total_cache_read_tokens,omitempty"` |
| TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens,omitempty"` |
| TotalCost float64 `json:"total_cost,omitempty"` |
| EffectiveCost *float64 `json:"effective_cost,omitempty"` |
| } |
|
|
| |
| func (s *Server) fetchChannelTypesMap(ctx context.Context) (map[int64]string, error) { |
| configs, err := s.store.ListConfigs(ctx) |
| if err != nil { |
| return nil, err |
| } |
|
|
| channelTypes := make(map[int64]string, len(configs)) |
| for _, cfg := range configs { |
| channelTypes[cfg.ID] = cfg.ChannelType |
| } |
| return channelTypes, nil |
| } |
|
|
| |
| |
| const channelTypesCacheTTL = 60 * time.Second |
|
|
| func (s *Server) getChannelTypesMapCached(ctx context.Context) (map[int64]string, error) { |
| |
| s.channelTypesCacheMu.RLock() |
| if s.channelTypesCache != nil && time.Since(s.channelTypesCacheTime) < channelTypesCacheTTL { |
| result := s.channelTypesCache |
| s.channelTypesCacheMu.RUnlock() |
| return result, nil |
| } |
| s.channelTypesCacheMu.RUnlock() |
|
|
| |
| s.channelTypesCacheMu.Lock() |
| defer s.channelTypesCacheMu.Unlock() |
|
|
| |
| if s.channelTypesCache != nil && time.Since(s.channelTypesCacheTime) < channelTypesCacheTTL { |
| return s.channelTypesCache, nil |
| } |
|
|
| channelTypes, err := s.fetchChannelTypesMap(ctx) |
| if err != nil { |
| return nil, err |
| } |
|
|
| s.channelTypesCache = channelTypes |
| s.channelTypesCacheTime = time.Now() |
| return channelTypes, nil |
| } |
|
|
| |
| |
| func (s *Server) HandleCooldownStats(c *gin.Context) { |
| |
| channelCooldowns, _ := s.getAllChannelCooldowns(c.Request.Context()) |
| keyCooldowns, _ := s.getAllKeyCooldowns(c.Request.Context()) |
|
|
| var keyCount int |
| for _, m := range keyCooldowns { |
| keyCount += len(m) |
| } |
|
|
| response := gin.H{ |
| "channel_cooldowns": len(channelCooldowns), |
| "key_cooldowns": keyCount, |
| } |
| RespondJSON(c, http.StatusOK, response) |
| } |
|
|
| |
| |
| |
| func (s *Server) HandleGetChannelTypes(c *gin.Context) { |
| c.Header("Cache-Control", "public, max-age=86400") |
| RespondJSON(c, http.StatusOK, util.ChannelTypes) |
| } |
|
|
| |
| |
| |
| func (s *Server) HandlePublicVersion(c *gin.Context) { |
| c.Header("Cache-Control", "public, max-age=300") |
| hasUpdate, latestVersion, releaseURL := version.GetUpdateInfo() |
| RespondJSON(c, http.StatusOK, gin.H{ |
| "version": version.Version, |
| "has_update": hasUpdate, |
| "latest_version": latestVersion, |
| "release_url": releaseURL, |
| }) |
| } |
|
|
| |
| type ModelsChannelsResponse struct { |
| Models []string `json:"models"` |
| Channels []model.ChannelNameID `json:"channels"` |
| } |
|
|
| |
| |
| |
| func (s *Server) HandleGetModels(c *gin.Context) { |
| rangeParam := c.DefaultQuery("range", "this_month") |
| params := ParsePaginationParams(c) |
| params.Range = rangeParam |
| since, until := params.GetTimeRange() |
|
|
| channelType := c.Query("channel_type") |
| logFilter := &model.LogFilter{LogSource: model.LogSourceProxy} |
|
|
| models, err := s.store.GetDistinctModels(c.Request.Context(), since, until, channelType, logFilter) |
| if err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| channels, err := s.store.GetDistinctChannels(c.Request.Context(), since, until, channelType, logFilter) |
| if err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| RespondJSON(c, http.StatusOK, ModelsChannelsResponse{Models: models, Channels: channels}) |
| } |
|
|
| |
| |
| |
| func (s *Server) HandleHealth(c *gin.Context) { |
| |
| ctx, cancel := context.WithTimeout(c.Request.Context(), 100*time.Millisecond) |
| defer cancel() |
|
|
| if err := s.store.Ping(ctx); err != nil { |
| RespondError(c, http.StatusServiceUnavailable, err) |
| return |
| } |
|
|
| RespondJSON(c, http.StatusOK, gin.H{"status": "ok"}) |
| } |
|
|
| |
| |
| |
| func (s *Server) fillHealthTimeline(ctx context.Context, stats []model.StatsEntry, startTime, endTime time.Time, filter *model.LogFilter, isToday bool) map[int][]model.HealthPoint { |
| if len(stats) == 0 { |
| return nil |
| } |
|
|
| const numBuckets = 48 |
|
|
| |
| var healthStart time.Time |
| var bucketSeconds int64 |
|
|
| if isToday { |
| |
| bucketSeconds = 5 * 60 |
| healthStart = endTime.Add(-4 * time.Hour) |
| |
| if healthStart.Before(startTime) { |
| healthStart = startTime |
| } |
| } else { |
| |
| duration := endTime.Sub(startTime) |
| bucketSeconds = int64(duration.Seconds() / numBuckets) |
| if bucketSeconds < 1 { |
| bucketSeconds = 1 |
| } |
| healthStart = startTime |
| } |
|
|
| |
| sinceMs := healthStart.UnixMilli() |
| untilMs := endTime.UnixMilli() |
| bucketMs := bucketSeconds * 1000 |
|
|
| |
| params := model.HealthTimelineParams{ |
| SinceMs: sinceMs, |
| UntilMs: untilMs, |
| BucketMs: bucketMs, |
| Filter: filter, |
| } |
|
|
| rows, err := s.store.GetHealthTimeline(ctx, params) |
| if err != nil { |
| |
| return nil |
| } |
|
|
| |
| type channelModelKey struct { |
| channelID int |
| model string |
| } |
| statsMap := make(map[channelModelKey]int) |
| for i := range stats { |
| if stats[i].ChannelID != nil { |
| key := channelModelKey{ |
| channelID: *stats[i].ChannelID, |
| model: stats[i].Model, |
| } |
| statsMap[key] = i |
| } |
| } |
|
|
| |
| timeline := make(map[channelModelKey][]model.HealthPoint) |
|
|
| sinceUnix := healthStart.Unix() |
|
|
| |
| for key := range statsMap { |
| points := make([]model.HealthPoint, numBuckets) |
| for i := 0; i < numBuckets; i++ { |
| points[i] = model.HealthPoint{ |
| Ts: time.Unix(sinceUnix+int64(i)*bucketSeconds, 0), |
| SuccessRate: -1, |
| } |
| } |
| timeline[key] = points |
| } |
|
|
| for _, row := range rows { |
| key := channelModelKey{channelID: row.ChannelID, model: row.Model} |
|
|
| |
| if _, exists := statsMap[key]; !exists { |
| continue |
| } |
|
|
| |
| bucketIndex := int((row.BucketTs/1000 - sinceUnix) / bucketSeconds) |
| if bucketIndex < 0 || bucketIndex >= numBuckets { |
| continue |
| } |
|
|
| total := row.Success + row.ErrorCount |
| successRate := 0.0 |
| if total > 0 { |
| successRate = float64(row.Success) / float64(total) |
| } |
|
|
| |
| |
| |
| p := &timeline[key][bucketIndex] |
| p.SuccessRate = successRate |
| p.SuccessCount = row.Success |
| p.ErrorCount = row.ErrorCount |
| p.AvgFirstByteTime = row.AvgFirstByteTime |
| p.AvgDuration = row.AvgDuration |
| p.TotalInputTokens = row.InputTokens |
| p.TotalOutputTokens = row.OutputTokens |
| p.TotalCacheReadTokens = row.CacheReadTokens |
| p.TotalCacheCreationTokens = row.CacheCreationTokens |
| p.TotalCost = row.TotalCost |
| p.EffectiveCost = row.EffectiveCost |
| } |
|
|
| |
| for key, idx := range statsMap { |
| if points, exists := timeline[key]; exists { |
| stats[idx].HealthTimeline = points |
| } |
| } |
|
|
| |
| |
| channelHealth := make(map[int][]model.HealthPoint) |
| for key, points := range timeline { |
| ch, exists := channelHealth[key.channelID] |
| if !exists { |
| ch = make([]model.HealthPoint, numBuckets) |
| for i := range ch { |
| ch[i] = model.HealthPoint{ |
| Ts: points[i].Ts, |
| SuccessRate: -1, |
| } |
| } |
| channelHealth[key.channelID] = ch |
| } |
| for i, pt := range points { |
| if pt.SuccessRate < 0 { |
| continue |
| } |
| if ch[i].SuccessRate < 0 { |
| ch[i] = pt |
| continue |
| } |
| |
| oldSucc := ch[i].SuccessCount |
| newSucc := pt.SuccessCount |
| if totalSucc := oldSucc + newSucc; totalSucc > 0 { |
| w := float64(totalSucc) |
| ch[i].AvgFirstByteTime = (ch[i].AvgFirstByteTime*float64(oldSucc) + pt.AvgFirstByteTime*float64(newSucc)) / w |
| ch[i].AvgDuration = (ch[i].AvgDuration*float64(oldSucc) + pt.AvgDuration*float64(newSucc)) / w |
| } |
| ch[i].SuccessCount += pt.SuccessCount |
| ch[i].ErrorCount += pt.ErrorCount |
| if total := ch[i].SuccessCount + ch[i].ErrorCount; total > 0 { |
| ch[i].SuccessRate = float64(ch[i].SuccessCount) / float64(total) |
| } |
| ch[i].TotalInputTokens += pt.TotalInputTokens |
| ch[i].TotalOutputTokens += pt.TotalOutputTokens |
| ch[i].TotalCacheReadTokens += pt.TotalCacheReadTokens |
| ch[i].TotalCacheCreationTokens += pt.TotalCacheCreationTokens |
| ch[i].TotalCost += pt.TotalCost |
| ch[i].EffectiveCost += pt.EffectiveCost |
| } |
| } |
| return channelHealth |
| } |
|
|
| |
| |
| |
| func (s *Server) HandleStatsFilterOptions(c *gin.Context) { |
| params := ParsePaginationParams(c) |
| startTime, endTime := params.GetTimeRange() |
|
|
| lf := BuildLogFilter(c) |
| lf.LogSource = model.LogSourceProxy |
|
|
| channelType := c.Query("channel_type") |
| if channelType == "all" { |
| channelType = "" |
| } |
|
|
| channels, err := s.store.GetDistinctChannels(c.Request.Context(), startTime, endTime, channelType, &lf) |
| if err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| models, err := s.store.GetDistinctModels(c.Request.Context(), startTime, endTime, channelType, &lf) |
| if err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| channelNames := make([]string, 0, len(channels)) |
| for _, ch := range channels { |
| if ch.Name != "" { |
| channelNames = append(channelNames, ch.Name) |
| } |
| } |
|
|
| RespondJSON(c, http.StatusOK, gin.H{ |
| "channel_names": channelNames, |
| "models": models, |
| }) |
| } |
|
|