new-api/service/rankings.go
2026-05-06 18:20:02 +08:00

600 lines
17 KiB
Go

package service
import (
"fmt"
"math"
"sort"
"sync"
"time"
"github.com/QuantumNous/new-api/model"
)
const (
rankingCacheTTL = 5 * time.Minute
rankingLeaderboardLimit = 20
rankingHistoryLimit = 10
rankingVendorLimit = 5
rankingMoverLimit = 6
rankingOthersLabel = "Others"
rankingUnknownVendor = "Unknown"
)
type RankingsResponse struct {
Models []RankedModel `json:"models"`
Vendors []RankedVendor `json:"vendors"`
TopMovers []RankingMover `json:"top_movers"`
TopDroppers []RankingMover `json:"top_droppers"`
ModelsHistory ModelHistorySeries `json:"models_history"`
VendorShareHistory VendorShareSeries `json:"vendor_share_history"`
}
type RankedModel struct {
Rank int `json:"rank"`
PreviousRank *int `json:"previous_rank,omitempty"`
ModelName string `json:"model_name"`
Vendor string `json:"vendor"`
VendorIcon string `json:"vendor_icon,omitempty"`
Category string `json:"category"`
TotalTokens int64 `json:"total_tokens"`
Share float64 `json:"share"`
GrowthPct float64 `json:"growth_pct"`
}
type RankedVendor struct {
Rank int `json:"rank"`
Vendor string `json:"vendor"`
VendorIcon string `json:"vendor_icon,omitempty"`
TotalTokens int64 `json:"total_tokens"`
Share float64 `json:"share"`
GrowthPct float64 `json:"growth_pct"`
ModelsCount int `json:"models_count"`
TopModel string `json:"top_model"`
}
type RankingMover struct {
ModelName string `json:"model_name"`
Vendor string `json:"vendor"`
VendorIcon string `json:"vendor_icon,omitempty"`
RankDelta int `json:"rank_delta"`
CurrentRank int `json:"current_rank"`
GrowthPct float64 `json:"growth_pct"`
}
type ModelHistoryPoint struct {
Ts string `json:"ts"`
Label string `json:"label"`
Model string `json:"model"`
Vendor string `json:"vendor"`
Tokens int64 `json:"tokens"`
}
type ModelHistoryModel struct {
Name string `json:"name"`
Vendor string `json:"vendor"`
Total int64 `json:"total"`
}
type ModelHistorySeries struct {
Points []ModelHistoryPoint `json:"points"`
Models []ModelHistoryModel `json:"models"`
Buckets int `json:"buckets"`
}
type VendorSharePoint struct {
Ts string `json:"ts"`
Label string `json:"label"`
Vendor string `json:"vendor"`
Share float64 `json:"share"`
Tokens int64 `json:"tokens"`
}
type VendorShareVendor struct {
Name string `json:"name"`
Total int64 `json:"total"`
Share float64 `json:"share"`
}
type VendorShareSeries struct {
Points []VendorSharePoint `json:"points"`
Vendors []VendorShareVendor `json:"vendors"`
Buckets int `json:"buckets"`
}
type rankingPeriodConfig struct {
id string
duration time.Duration
bucketSize int64
labelLayout string
hasPrevious bool
}
type rankingCacheItem struct {
expiresAt time.Time
data *RankingsResponse
}
type rankingModelMeta struct {
vendor string
vendorIcon string
}
type vendorAggregate struct {
name string
icon string
totalTokens int64
previousTokens int64
models map[string]struct{}
topModel string
topModelTokens int64
}
var (
rankingCacheMu sync.Mutex
rankingCache = map[string]rankingCacheItem{}
)
func GetRankingsSnapshot(period string) (*RankingsResponse, error) {
config, err := rankingConfig(period)
if err != nil {
return nil, err
}
now := time.Now()
rankingCacheMu.Lock()
if item, ok := rankingCache[config.id]; ok && now.Before(item.expiresAt) {
rankingCacheMu.Unlock()
return item.data, nil
}
rankingCacheMu.Unlock()
data, err := buildRankingsSnapshot(config, now)
if err != nil {
return nil, err
}
rankingCacheMu.Lock()
rankingCache[config.id] = rankingCacheItem{
expiresAt: now.Add(rankingCacheTTL),
data: data,
}
rankingCacheMu.Unlock()
return data, nil
}
func rankingConfig(period string) (rankingPeriodConfig, error) {
switch period {
case "", "week":
return rankingPeriodConfig{id: "week", duration: 7 * 24 * time.Hour, bucketSize: 24 * 3600, labelLayout: "Jan 2", hasPrevious: true}, nil
case "today":
return rankingPeriodConfig{id: "today", duration: 24 * time.Hour, bucketSize: 3600, labelLayout: "15:04", hasPrevious: true}, nil
case "month":
return rankingPeriodConfig{id: "month", duration: 30 * 24 * time.Hour, bucketSize: 24 * 3600, labelLayout: "Jan 2", hasPrevious: true}, nil
case "year":
return rankingPeriodConfig{id: "year", duration: 365 * 24 * time.Hour, bucketSize: 7 * 24 * 3600, labelLayout: "Jan 2", hasPrevious: true}, nil
case "all":
return rankingPeriodConfig{id: "all", bucketSize: 30 * 24 * 3600, labelLayout: "Jan 2006"}, nil
default:
return rankingPeriodConfig{}, fmt.Errorf("invalid ranking period: %s", period)
}
}
func buildRankingsSnapshot(config rankingPeriodConfig, now time.Time) (*RankingsResponse, error) {
startTime, endTime := rankingTimeRange(config, now)
currentTotals, err := model.GetRankingQuotaTotals(startTime, endTime)
if err != nil {
return nil, err
}
currentBuckets, err := model.GetRankingQuotaBuckets(startTime, endTime, config.bucketSize)
if err != nil {
return nil, err
}
var previousTotals []model.RankingQuotaTotal
if config.hasPrevious {
previousStart, previousEnd := previousRankingTimeRange(config, startTime)
previousTotals, err = model.GetRankingQuotaTotals(previousStart, previousEnd)
if err != nil {
return nil, err
}
}
meta := buildRankingModelMeta()
totalTokens := sumRankingTokens(currentTotals)
previousRankByModel := rankingRankMap(previousTotals)
previousTokensByModel := rankingTokenMap(previousTotals)
rankedModels := buildRankedModels(currentTotals, totalTokens, previousRankByModel, previousTokensByModel, meta, config.hasPrevious)
vendors := buildRankedVendors(currentTotals, previousTotals, totalTokens, meta, config.hasPrevious)
modelHistory := buildModelHistory(currentBuckets, currentTotals, meta, config)
vendorHistory := buildVendorShareHistory(currentBuckets, vendors, totalTokens, meta, config)
movers, droppers := buildRankingMovers(rankedModels)
return &RankingsResponse{
Models: limitRankedModels(rankedModels, rankingLeaderboardLimit),
Vendors: vendors,
TopMovers: movers,
TopDroppers: droppers,
ModelsHistory: modelHistory,
VendorShareHistory: vendorHistory,
}, nil
}
func rankingTimeRange(config rankingPeriodConfig, now time.Time) (int64, int64) {
endTime := now.Unix()
if config.duration <= 0 {
return 0, endTime
}
return now.Add(-config.duration).Unix(), endTime
}
func previousRankingTimeRange(config rankingPeriodConfig, currentStart int64) (int64, int64) {
previousEnd := currentStart - 1
previousStart := time.Unix(currentStart, 0).Add(-config.duration).Unix()
return previousStart, previousEnd
}
func buildRankingModelMeta() map[string]rankingModelMeta {
vendorByID := make(map[int]model.PricingVendor)
for _, vendor := range model.GetVendors() {
vendorByID[vendor.ID] = vendor
}
meta := make(map[string]rankingModelMeta)
for _, pricing := range model.GetPricing() {
item := rankingModelMeta{vendor: rankingUnknownVendor}
if vendor, ok := vendorByID[pricing.VendorID]; ok {
item.vendor = vendor.Name
item.vendorIcon = vendor.Icon
} else if pricing.OwnerBy != "" {
item.vendor = pricing.OwnerBy
}
meta[pricing.ModelName] = item
}
return meta
}
func modelMeta(modelName string, meta map[string]rankingModelMeta) rankingModelMeta {
if item, ok := meta[modelName]; ok && item.vendor != "" {
return item
}
return rankingModelMeta{vendor: rankingUnknownVendor}
}
func buildRankedModels(totals []model.RankingQuotaTotal, totalTokens int64, previousRanks map[string]int, previousTokens map[string]int64, meta map[string]rankingModelMeta, showGrowth bool) []RankedModel {
rows := make([]RankedModel, 0, len(totals))
for idx, item := range totals {
modelMeta := modelMeta(item.ModelName, meta)
var previousRank *int
if rank, ok := previousRanks[item.ModelName]; ok {
rankCopy := rank
previousRank = &rankCopy
}
growth := 0.0
if showGrowth {
growth = rankingGrowthPct(item.TotalTokens, previousTokens[item.ModelName])
}
rows = append(rows, RankedModel{
Rank: idx + 1,
PreviousRank: previousRank,
ModelName: item.ModelName,
Vendor: modelMeta.vendor,
VendorIcon: modelMeta.vendorIcon,
Category: "all",
TotalTokens: item.TotalTokens,
Share: rankingShare(item.TotalTokens, totalTokens),
GrowthPct: growth,
})
}
return rows
}
func buildRankedVendors(currentTotals []model.RankingQuotaTotal, previousTotals []model.RankingQuotaTotal, totalTokens int64, meta map[string]rankingModelMeta, showGrowth bool) []RankedVendor {
aggregates := make(map[string]*vendorAggregate)
for _, item := range currentTotals {
modelMeta := modelMeta(item.ModelName, meta)
agg := ensureVendorAggregate(aggregates, modelMeta)
agg.totalTokens += item.TotalTokens
agg.models[item.ModelName] = struct{}{}
if item.TotalTokens > agg.topModelTokens {
agg.topModel = item.ModelName
agg.topModelTokens = item.TotalTokens
}
}
for _, item := range previousTotals {
modelMeta := modelMeta(item.ModelName, meta)
agg := ensureVendorAggregate(aggregates, modelMeta)
agg.previousTokens += item.TotalTokens
}
rows := make([]RankedVendor, 0, len(aggregates))
for _, agg := range aggregates {
if agg.totalTokens <= 0 {
continue
}
growth := 0.0
if showGrowth {
growth = rankingGrowthPct(agg.totalTokens, agg.previousTokens)
}
rows = append(rows, RankedVendor{
Vendor: agg.name,
VendorIcon: agg.icon,
TotalTokens: agg.totalTokens,
Share: rankingShare(agg.totalTokens, totalTokens),
GrowthPct: growth,
ModelsCount: len(agg.models),
TopModel: agg.topModel,
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].TotalTokens == rows[j].TotalTokens {
return rows[i].Vendor < rows[j].Vendor
}
return rows[i].TotalTokens > rows[j].TotalTokens
})
for idx := range rows {
rows[idx].Rank = idx + 1
}
return rows
}
func ensureVendorAggregate(aggregates map[string]*vendorAggregate, meta rankingModelMeta) *vendorAggregate {
name := meta.vendor
if name == "" {
name = rankingUnknownVendor
}
agg, ok := aggregates[name]
if !ok {
agg = &vendorAggregate{
name: name,
icon: meta.vendorIcon,
models: make(map[string]struct{}),
}
aggregates[name] = agg
}
if agg.icon == "" && meta.vendorIcon != "" {
agg.icon = meta.vendorIcon
}
return agg
}
func buildModelHistory(buckets []model.RankingQuotaBucket, totals []model.RankingQuotaTotal, meta map[string]rankingModelMeta, config rankingPeriodConfig) ModelHistorySeries {
topModels := make(map[string]struct{})
models := make([]ModelHistoryModel, 0, minInt(len(totals), rankingHistoryLimit)+1)
otherTotal := int64(0)
for idx, item := range totals {
if idx < rankingHistoryLimit {
topModels[item.ModelName] = struct{}{}
modelMeta := modelMeta(item.ModelName, meta)
models = append(models, ModelHistoryModel{Name: item.ModelName, Vendor: modelMeta.vendor, Total: item.TotalTokens})
continue
}
otherTotal += item.TotalTokens
}
if otherTotal > 0 {
models = append(models, ModelHistoryModel{Name: rankingOthersLabel, Vendor: "Various", Total: otherTotal})
}
bucketSet := make(map[int64]struct{})
tokensByBucketAndModel := make(map[int64]map[string]int64)
for _, item := range buckets {
modelName := item.ModelName
if _, ok := topModels[modelName]; !ok {
modelName = rankingOthersLabel
}
bucketSet[item.Bucket] = struct{}{}
if _, ok := tokensByBucketAndModel[item.Bucket]; !ok {
tokensByBucketAndModel[item.Bucket] = make(map[string]int64)
}
tokensByBucketAndModel[item.Bucket][modelName] += item.Tokens
}
sortedBuckets := sortedRankingBuckets(bucketSet)
points := make([]ModelHistoryPoint, 0, len(sortedBuckets)*len(models))
for _, bucket := range sortedBuckets {
for _, historyModel := range models {
tokens := tokensByBucketAndModel[bucket][historyModel.Name]
if tokens <= 0 {
continue
}
points = append(points, ModelHistoryPoint{
Ts: rankingBucketTs(bucket),
Label: rankingBucketLabel(bucket, config),
Model: historyModel.Name,
Vendor: historyModel.Vendor,
Tokens: tokens,
})
}
}
return ModelHistorySeries{
Points: points,
Models: models,
Buckets: len(sortedBuckets),
}
}
func buildVendorShareHistory(buckets []model.RankingQuotaBucket, vendors []RankedVendor, totalTokens int64, meta map[string]rankingModelMeta, config rankingPeriodConfig) VendorShareSeries {
topVendors := make(map[string]struct{})
vendorRows := make([]VendorShareVendor, 0, minInt(len(vendors), rankingVendorLimit)+1)
otherTotal := int64(0)
for idx, vendor := range vendors {
if idx < rankingVendorLimit {
topVendors[vendor.Vendor] = struct{}{}
vendorRows = append(vendorRows, VendorShareVendor{Name: vendor.Vendor, Total: vendor.TotalTokens, Share: vendor.Share})
continue
}
otherTotal += vendor.TotalTokens
}
if otherTotal > 0 {
vendorRows = append(vendorRows, VendorShareVendor{Name: rankingOthersLabel, Total: otherTotal, Share: rankingShare(otherTotal, totalTokens)})
}
bucketSet := make(map[int64]struct{})
tokensByBucketAndVendor := make(map[int64]map[string]int64)
totalsByBucket := make(map[int64]int64)
for _, item := range buckets {
modelMeta := modelMeta(item.ModelName, meta)
vendorName := modelMeta.vendor
if _, ok := topVendors[vendorName]; !ok {
vendorName = rankingOthersLabel
}
bucketSet[item.Bucket] = struct{}{}
if _, ok := tokensByBucketAndVendor[item.Bucket]; !ok {
tokensByBucketAndVendor[item.Bucket] = make(map[string]int64)
}
tokensByBucketAndVendor[item.Bucket][vendorName] += item.Tokens
totalsByBucket[item.Bucket] += item.Tokens
}
sortedBuckets := sortedRankingBuckets(bucketSet)
points := make([]VendorSharePoint, 0, len(sortedBuckets)*len(vendorRows))
for _, bucket := range sortedBuckets {
for _, vendor := range vendorRows {
tokens := tokensByBucketAndVendor[bucket][vendor.Name]
if tokens <= 0 {
continue
}
points = append(points, VendorSharePoint{
Ts: rankingBucketTs(bucket),
Label: rankingBucketLabel(bucket, config),
Vendor: vendor.Name,
Share: rankingShare(tokens, totalsByBucket[bucket]),
Tokens: tokens,
})
}
}
return VendorShareSeries{
Points: points,
Vendors: vendorRows,
Buckets: len(sortedBuckets),
}
}
func buildRankingMovers(models []RankedModel) ([]RankingMover, []RankingMover) {
movers := make([]RankingMover, 0)
droppers := make([]RankingMover, 0)
for _, item := range models {
if item.PreviousRank == nil {
continue
}
delta := *item.PreviousRank - item.Rank
if delta == 0 {
continue
}
row := RankingMover{
ModelName: item.ModelName,
Vendor: item.Vendor,
VendorIcon: item.VendorIcon,
RankDelta: delta,
CurrentRank: item.Rank,
GrowthPct: item.GrowthPct,
}
if delta > 0 {
movers = append(movers, row)
} else {
droppers = append(droppers, row)
}
}
sort.Slice(movers, func(i, j int) bool {
if movers[i].RankDelta == movers[j].RankDelta {
return movers[i].GrowthPct > movers[j].GrowthPct
}
return movers[i].RankDelta > movers[j].RankDelta
})
sort.Slice(droppers, func(i, j int) bool {
if droppers[i].RankDelta == droppers[j].RankDelta {
return droppers[i].GrowthPct < droppers[j].GrowthPct
}
return droppers[i].RankDelta < droppers[j].RankDelta
})
return limitRankingMovers(movers, rankingMoverLimit), limitRankingMovers(droppers, rankingMoverLimit)
}
func sortedRankingBuckets(bucketSet map[int64]struct{}) []int64 {
buckets := make([]int64, 0, len(bucketSet))
for bucket := range bucketSet {
buckets = append(buckets, bucket)
}
sort.Slice(buckets, func(i, j int) bool {
return buckets[i] < buckets[j]
})
return buckets
}
func rankingBucketTs(bucket int64) string {
return time.Unix(bucket, 0).UTC().Format(time.RFC3339)
}
func rankingBucketLabel(bucket int64, config rankingPeriodConfig) string {
return time.Unix(bucket, 0).Format(config.labelLayout)
}
func rankingRankMap(totals []model.RankingQuotaTotal) map[string]int {
ranks := make(map[string]int, len(totals))
for idx, item := range totals {
ranks[item.ModelName] = idx + 1
}
return ranks
}
func rankingTokenMap(totals []model.RankingQuotaTotal) map[string]int64 {
tokens := make(map[string]int64, len(totals))
for _, item := range totals {
tokens[item.ModelName] = item.TotalTokens
}
return tokens
}
func sumRankingTokens(totals []model.RankingQuotaTotal) int64 {
total := int64(0)
for _, item := range totals {
total += item.TotalTokens
}
return total
}
func rankingShare(value int64, total int64) float64 {
if total <= 0 || value <= 0 {
return 0
}
return roundRankingFloat(float64(value) / float64(total))
}
func rankingGrowthPct(current int64, previous int64) float64 {
if previous <= 0 {
if current > 0 {
return 100
}
return 0
}
return roundRankingFloat((float64(current-previous) / float64(previous)) * 100)
}
func roundRankingFloat(value float64) float64 {
return math.Round(value*10000) / 10000
}
func limitRankedModels(rows []RankedModel, limit int) []RankedModel {
if limit <= 0 || len(rows) <= limit {
return rows
}
return rows[:limit]
}
func limitRankingMovers(rows []RankingMover, limit int) []RankingMover {
if limit <= 0 || len(rows) <= limit {
return rows
}
return rows[:limit]
}
func minInt(a int, b int) int {
if a < b {
return a
}
return b
}