diff --git a/controller/rankings.go b/controller/rankings.go new file mode 100644 index 00000000..5a7fdaae --- /dev/null +++ b/controller/rankings.go @@ -0,0 +1,24 @@ +package controller + +import ( + "net/http" + + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" +) + +func GetRankings(c *gin.Context) { + result, err := service.GetRankingsSnapshot(c.DefaultQuery("period", "week")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": result, + }) +} diff --git a/controller/relay.go b/controller/relay.go index e0f7dc3e..5e2db44c 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -242,7 +242,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { } if newAPIError != nil { gopool.Go(func() { - perfmetrics.RecordRelaySample(relayInfo, false) + perfmetrics.RecordRelaySample(relayInfo, false, 0) }) } } diff --git a/model/perf_metric.go b/model/perf_metric.go index 07c36917..8ef85438 100644 --- a/model/perf_metric.go +++ b/model/perf_metric.go @@ -13,11 +13,13 @@ type PerfMetric struct { ModelName string `json:"model_name" gorm:"size:128;uniqueIndex:idx_perf_model_group_bucket,priority:1"` Group string `json:"group" gorm:"column:group;size:64;uniqueIndex:idx_perf_model_group_bucket,priority:2"` BucketTs int64 `json:"bucket_ts" gorm:"uniqueIndex:idx_perf_model_group_bucket,priority:3;index:idx_perf_bucket_ts"` - RequestCount int64 `json:"request_count" gorm:"default:0"` - SuccessCount int64 `json:"success_count" gorm:"default:0"` - TotalLatencyMs int64 `json:"total_latency_ms" gorm:"default:0"` - TtftSumMs int64 `json:"ttft_sum_ms" gorm:"default:0"` - TtftCount int64 `json:"ttft_count" gorm:"default:0"` + RequestCount int64 `json:"-" gorm:"default:0"` + SuccessCount int64 `json:"-" gorm:"default:0"` + TotalLatencyMs int64 `json:"-" gorm:"default:0"` + TtftSumMs int64 `json:"-" gorm:"default:0"` + TtftCount int64 `json:"-" gorm:"default:0"` + OutputTokens int64 `json:"-" gorm:"default:0"` + GenerationMs int64 `json:"-" gorm:"default:0"` } func (PerfMetric) TableName() string { @@ -40,6 +42,8 @@ func UpsertPerfMetric(metric *PerfMetric) error { "total_latency_ms": gorm.Expr("total_latency_ms + ?", metric.TotalLatencyMs), "ttft_sum_ms": gorm.Expr("ttft_sum_ms + ?", metric.TtftSumMs), "ttft_count": gorm.Expr("ttft_count + ?", metric.TtftCount), + "output_tokens": gorm.Expr("output_tokens + ?", metric.OutputTokens), + "generation_ms": gorm.Expr("generation_ms + ?", metric.GenerationMs), }), }).Create(metric).Error } diff --git a/model/usedata_rankings.go b/model/usedata_rankings.go new file mode 100644 index 00000000..af133101 --- /dev/null +++ b/model/usedata_rankings.go @@ -0,0 +1,66 @@ +package model + +import ( + "fmt" + + "github.com/QuantumNous/new-api/common" + "gorm.io/gorm" +) + +type RankingQuotaTotal struct { + ModelName string `json:"model_name"` + TotalTokens int64 `json:"total_tokens"` +} + +type RankingQuotaBucket struct { + ModelName string `json:"model_name"` + Bucket int64 `json:"bucket"` + Tokens int64 `json:"tokens"` +} + +func GetRankingQuotaTotals(startTime int64, endTime int64) ([]RankingQuotaTotal, error) { + var rows []RankingQuotaTotal + query := DB.Table("quota_data"). + Select("model_name, sum(token_used) as total_tokens"). + Where("model_name <> ''"). + Group("model_name"). + Having("sum(token_used) > 0"). + Order("total_tokens DESC") + query = applyRankingQuotaTimeRange(query, startTime, endTime) + err := query.Find(&rows).Error + return rows, err +} + +func GetRankingQuotaBuckets(startTime int64, endTime int64, bucketSize int64) ([]RankingQuotaBucket, error) { + if bucketSize <= 0 { + bucketSize = 3600 + } + bucketExpr := rankingBucketExpr(bucketSize) + var rows []RankingQuotaBucket + query := DB.Table("quota_data"). + Select(fmt.Sprintf("model_name, %s as bucket, sum(token_used) as tokens", bucketExpr)). + Where("model_name <> ''"). + Group(fmt.Sprintf("model_name, %s", bucketExpr)). + Having("sum(token_used) > 0"). + Order("bucket ASC") + query = applyRankingQuotaTimeRange(query, startTime, endTime) + err := query.Find(&rows).Error + return rows, err +} + +func rankingBucketExpr(bucketSize int64) string { + if common.UsingMySQL { + return fmt.Sprintf("FLOOR(created_at / %d) * %d", bucketSize, bucketSize) + } + return fmt.Sprintf("(created_at / %d) * %d", bucketSize, bucketSize) +} + +func applyRankingQuotaTimeRange(query *gorm.DB, startTime int64, endTime int64) *gorm.DB { + if startTime > 0 { + query = query.Where("created_at >= ?", startTime) + } + if endTime > 0 { + query = query.Where("created_at <= ?", endTime) + } + return query +} diff --git a/pkg/perf_metrics/flush.go b/pkg/perf_metrics/flush.go index 9dbfce15..dddc2472 100644 --- a/pkg/perf_metrics/flush.go +++ b/pkg/perf_metrics/flush.go @@ -47,6 +47,8 @@ func flushCompletedBuckets() { TotalLatencyMs: drained.totalLatencyMs, TtftSumMs: drained.ttftSumMs, TtftCount: drained.ttftCount, + OutputTokens: drained.outputTokens, + GenerationMs: drained.generationMs, }) if err != nil { bucket.addCounters(drained) @@ -82,6 +84,8 @@ func redisCounters(values map[string]string) counters { totalLatencyMs: parseRedisInt(values["lat"]), ttftSumMs: parseRedisInt(values["ttft"]), ttftCount: parseRedisInt(values["ttft_n"]), + outputTokens: parseRedisInt(values["out"]), + generationMs: parseRedisInt(values["gen_ms"]), } } diff --git a/pkg/perf_metrics/metrics.go b/pkg/perf_metrics/metrics.go index 8062fda2..7e8648af 100644 --- a/pkg/perf_metrics/metrics.go +++ b/pkg/perf_metrics/metrics.go @@ -15,13 +15,15 @@ import ( var hotBuckets sync.Map +// seriesSchema is a stable client cache/schema marker. Do not change it when +// hiding fields or making response-only privacy hardening changes. const seriesSchema = "dbcd0a3c01b55203" func Init() { go flushLoop() } -func RecordRelaySample(info *relaycommon.RelayInfo, success bool) { +func RecordRelaySample(info *relaycommon.RelayInfo, success bool, outputTokens int64) { if info == nil { return } @@ -31,13 +33,23 @@ func RecordRelaySample(info *relaycommon.RelayInfo, success bool) { if hasTtft { ttftMs = info.FirstResponseTime.Sub(info.StartTime).Milliseconds() } + latencyMs := now.Sub(info.StartTime).Milliseconds() + generationMs := latencyMs + if hasTtft { + generationMs = now.Sub(info.FirstResponseTime).Milliseconds() + } + if generationMs <= 0 { + generationMs = latencyMs + } Record(Sample{ - Model: info.OriginModelName, - Group: info.UsingGroup, - LatencyMs: now.Sub(info.StartTime).Milliseconds(), - TtftMs: ttftMs, - HasTtft: hasTtft, - Success: success, + Model: info.OriginModelName, + Group: info.UsingGroup, + LatencyMs: latencyMs, + TtftMs: ttftMs, + HasTtft: hasTtft, + Success: success, + OutputTokens: outputTokens, + GenerationMs: generationMs, }) } @@ -89,6 +101,8 @@ func Query(params QueryParams) (QueryResult, error) { totalLatencyMs: row.TotalLatencyMs, ttftSumMs: row.TtftSumMs, ttftCount: row.TtftCount, + outputTokens: row.OutputTokens, + generationMs: row.GenerationMs, }) } @@ -125,6 +139,8 @@ func mergeCounters(merged map[bucketKey]counters, key bucketKey, value counters) current.totalLatencyMs += value.totalLatencyMs current.ttftSumMs += value.ttftSumMs current.ttftCount += value.ttftCount + current.outputTokens += value.outputTokens + current.generationMs += value.generationMs merged[key] = current } @@ -166,6 +182,8 @@ func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResu total.totalLatencyMs += value.totalLatencyMs total.ttftSumMs += value.ttftSumMs total.ttftCount += value.ttftCount + total.outputTokens += value.outputTokens + total.generationMs += value.generationMs series = append(series, bucketPoint(ts, value)) } @@ -174,9 +192,7 @@ func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResu AvgTtftMs: avg(total.ttftSumMs, total.ttftCount), AvgLatencyMs: avg(total.totalLatencyMs, total.requestCount), SuccessRate: successRate(total), - RequestCount: total.requestCount, - SuccessCount: total.successCount, - TtftCount: total.ttftCount, + AvgTps: avgTps(total), Series: series, }) } @@ -194,9 +210,7 @@ func bucketPoint(ts int64, value counters) BucketPoint { AvgTtftMs: avg(value.ttftSumMs, value.ttftCount), AvgLatencyMs: avg(value.totalLatencyMs, value.requestCount), SuccessRate: successRate(value), - Count: value.requestCount, - SuccessCount: value.successCount, - TtftCount: value.ttftCount, + AvgTps: avgTps(value), } } @@ -214,6 +228,13 @@ func successRate(value counters) float64 { return float64(value.successCount) / float64(value.requestCount) * 100 } +func avgTps(value counters) float64 { + if value.outputTokens <= 0 || value.generationMs <= 0 { + return 0 + } + return float64(value.outputTokens) / (float64(value.generationMs) / 1000) +} + func recordRedis(key bucketKey, sample Sample) { if !common.RedisEnabled || common.RDB == nil { return @@ -234,6 +255,10 @@ func recordRedis(key bucketKey, sample Sample) { pipe.HIncrBy(ctx, redisKey, "ttft", sample.TtftMs) pipe.HIncrBy(ctx, redisKey, "ttft_n", 1) } + if sample.OutputTokens > 0 && sample.GenerationMs > 0 { + pipe.HIncrBy(ctx, redisKey, "out", sample.OutputTokens) + pipe.HIncrBy(ctx, redisKey, "gen_ms", sample.GenerationMs) + } pipe.Expire(ctx, redisKey, time.Hour) _, _ = pipe.Exec(ctx) } diff --git a/pkg/perf_metrics/types.go b/pkg/perf_metrics/types.go index 449a7377..25d07868 100644 --- a/pkg/perf_metrics/types.go +++ b/pkg/perf_metrics/types.go @@ -8,12 +8,14 @@ type Store interface { } type Sample struct { - Model string - Group string - LatencyMs int64 - TtftMs int64 - HasTtft bool - Success bool + Model string + Group string + LatencyMs int64 + TtftMs int64 + HasTtft bool + Success bool + OutputTokens int64 + GenerationMs int64 } type QueryParams struct { @@ -27,9 +29,7 @@ type BucketPoint struct { AvgTtftMs int64 `json:"avg_ttft_ms"` AvgLatencyMs int64 `json:"avg_latency_ms"` SuccessRate float64 `json:"success_rate"` - Count int64 `json:"count"` - SuccessCount int64 `json:"success_count"` - TtftCount int64 `json:"ttft_count"` + AvgTps float64 `json:"avg_tps"` } type GroupResult struct { @@ -37,9 +37,7 @@ type GroupResult struct { AvgTtftMs int64 `json:"avg_ttft_ms"` AvgLatencyMs int64 `json:"avg_latency_ms"` SuccessRate float64 `json:"success_rate"` - RequestCount int64 `json:"request_count"` - SuccessCount int64 `json:"success_count"` - TtftCount int64 `json:"ttft_count"` + AvgTps float64 `json:"avg_tps"` Series []BucketPoint `json:"series"` } @@ -61,6 +59,8 @@ type counters struct { totalLatencyMs int64 ttftSumMs int64 ttftCount int64 + outputTokens int64 + generationMs int64 } type atomicBucket struct { @@ -69,6 +69,8 @@ type atomicBucket struct { totalLatencyMs atomic.Int64 ttftSumMs atomic.Int64 ttftCount atomic.Int64 + outputTokens atomic.Int64 + generationMs atomic.Int64 } func (b *atomicBucket) add(sample Sample) { @@ -83,6 +85,10 @@ func (b *atomicBucket) add(sample Sample) { b.ttftSumMs.Add(sample.TtftMs) b.ttftCount.Add(1) } + if sample.OutputTokens > 0 && sample.GenerationMs > 0 { + b.outputTokens.Add(sample.OutputTokens) + b.generationMs.Add(sample.GenerationMs) + } } func (b *atomicBucket) snapshot() counters { @@ -92,6 +98,8 @@ func (b *atomicBucket) snapshot() counters { totalLatencyMs: b.totalLatencyMs.Load(), ttftSumMs: b.ttftSumMs.Load(), ttftCount: b.ttftCount.Load(), + outputTokens: b.outputTokens.Load(), + generationMs: b.generationMs.Load(), } } @@ -102,6 +110,8 @@ func (b *atomicBucket) drain() counters { totalLatencyMs: b.totalLatencyMs.Swap(0), ttftSumMs: b.ttftSumMs.Swap(0), ttftCount: b.ttftCount.Swap(0), + outputTokens: b.outputTokens.Swap(0), + generationMs: b.generationMs.Swap(0), } } @@ -121,4 +131,10 @@ func (b *atomicBucket) addCounters(c counters) { if c.ttftCount != 0 { b.ttftCount.Add(c.ttftCount) } + if c.outputTokens != 0 { + b.outputTokens.Add(c.outputTokens) + } + if c.generationMs != 0 { + b.generationMs.Add(c.generationMs) + } } diff --git a/router/api-router.go b/router/api-router.go index 57b9edbf..d756185e 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -32,6 +32,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/home_page_content", controller.GetHomePageContent) apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing) apiRouter.GET("/perf-metrics", middleware.TryUserAuth(), controller.GetPerfMetrics) + apiRouter.GET("/rankings", controller.GetRankings) apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification) apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword) diff --git a/service/quota.go b/service/quota.go index 9e45ae01..7364598a 100644 --- a/service/quota.go +++ b/service/quota.go @@ -377,7 +377,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u Other: other, }) gopool.Go(func() { - perfmetrics.RecordRelaySample(relayInfo, true) + perfmetrics.RecordRelaySample(relayInfo, true, int64(usage.CompletionTokens)) }) } diff --git a/service/rankings.go b/service/rankings.go new file mode 100644 index 00000000..01a096dd --- /dev/null +++ b/service/rankings.go @@ -0,0 +1,599 @@ +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 +} diff --git a/service/text_quota.go b/service/text_quota.go index 7e0821ac..3f344dc3 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -474,6 +474,6 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us Other: other, }) gopool.Go(func() { - perfmetrics.RecordRelaySample(relayInfo, true) + perfmetrics.RecordRelaySample(relayInfo, true, int64(summary.CompletionTokens)) }) } diff --git a/web/default/src/components/data-table/toolbar.tsx b/web/default/src/components/data-table/toolbar.tsx index 5c6d77bf..938e7677 100644 --- a/web/default/src/components/data-table/toolbar.tsx +++ b/web/default/src/components/data-table/toolbar.tsx @@ -87,6 +87,14 @@ export type DataTableToolbarProps = { * Hide the View Options (column visibility) dropdown. */ hideViewOptions?: boolean + /** + * Content rendered on the LEFT side of the secondary action row. When + * provided the toolbar splits into two visual rows: + * Row 1: search inputs / filter chips …… Expand + * Row 2: expanded filters + * Row 3: leftActions …… Reset / Search / ViewOptions + */ + leftActions?: ReactNode /** * Outer wrapper className override. */ @@ -216,6 +224,39 @@ export function DataTableToolbar(props: DataTableToolbarProps) { ) : null + const hasLeftActions = props.leftActions != null + + if (hasLeftActions) { + return ( +
+
+ {props.customSearch !== undefined ? props.customSearch : searchInput} + {props.additionalSearch} + {filterChips} +
+ {expandToggle} +
+
+ + {expanded && hasExpandable && ( +
+ {props.expandable} +
+ )} + +
+ {props.leftActions} +
+ {props.preActions} + {resetButton} + {searchButton} + {viewOptionsNode} +
+
+
+ ) + } + return (
d.time }, content: [ { - key: (d: { group: string }) => d.group, + key: t('Average TTFT'), value: (d: { ttft: number }) => `${Math.round(d.ttft)} ms`, }, ], @@ -83,7 +89,7 @@ export function LatencyTrendChart(props: { }, ], } - }, [props.series]) + }, [props.series, t]) if (props.series.length === 0) { return ( @@ -116,10 +122,10 @@ export function LatencyTrendChart(props: { } // --------------------------------------------------------------------------- -// Uptime bar chart (30 days) +// Uptime trend chart (24h, point-line chart) // --------------------------------------------------------------------------- -export function UptimeBarChart(props: { +export function UptimeTrendChart(props: { series: UptimeDayPoint[] className?: string }) { @@ -137,18 +143,25 @@ export function UptimeBarChart(props: { })) return { - type: 'bar' as const, + type: 'line' as const, data: [{ id: 'uptime', values: data }], xField: 'date', yField: 'uptime', - bar: { + smooth: true, + line: { + style: { stroke: '#10b981', lineWidth: 2 }, + }, + point: { + visible: true, style: { + size: 5, + stroke: '#ffffff', + lineWidth: 1.5, fill: (datum: { uptime: number }) => { if (datum.uptime >= 99.9) return '#10b981' if (datum.uptime >= 99.0) return '#f59e0b' return '#ef4444' }, - cornerRadius: 2, }, }, tooltip: { @@ -210,7 +223,7 @@ export function UptimeBarChart(props: {
{themeReady && spec && ( label: string @@ -71,39 +61,55 @@ type PerformanceRow = { avg_ttft_ms: number avg_latency_ms: number success_rate: number - request_count: number + avg_tps: number } function toLatencySeries(groups: PerformanceGroup[]) { - return groups.flatMap((group) => - group.series - .filter((point) => point.ttft_count > 0 && point.avg_ttft_ms > 0) - .map((point) => ({ - timestamp: new Date(point.ts * 1000).toISOString(), - group: group.group, - ttft_ms: point.avg_ttft_ms, - })) - ) + const byTs = new Map() + for (const group of groups) { + for (const point of group.series) { + if (point.avg_ttft_ms <= 0) continue + const current = byTs.get(point.ts) ?? [] + current.push(point.avg_ttft_ms) + byTs.set(point.ts, current) + } + } + + return Array.from(byTs.entries()) + .sort(([a], [b]) => a - b) + .map(([ts, values]) => ({ + timestamp: new Date(ts * 1000).toISOString(), + group: 'latency', + ttft_ms: Math.round( + values.reduce((sum, value) => sum + value, 0) / values.length + ), + })) } function toUptimeSeries(groups: PerformanceGroup[]): UptimeDayPoint[] { - const byTs = new Map() + const byTs = new Map() for (const group of groups) { for (const point of group.series) { - const current = byTs.get(point.ts) ?? { count: 0, success: 0 } - current.count += point.count - current.success += point.success_count + const current = byTs.get(point.ts) ?? { rates: [], incidents: 0 } + if (Number.isFinite(point.success_rate)) { + current.rates.push(point.success_rate) + if (point.success_rate < 100) current.incidents += 1 + } byTs.set(point.ts, current) } } return Array.from(byTs.entries()) .sort(([a], [b]) => a - b) .map(([ts, value]) => { - const uptime = value.count > 0 ? (value.success / value.count) * 100 : 0 + const uptime = + value.rates.length > 0 + ? value.rates.reduce((sum, rate) => sum + rate, 0) / + value.rates.length + : 0 return { date: new Date(ts * 1000).toISOString(), uptime_pct: Math.round(uptime * 100) / 100, - incidents: value.success < value.count ? 1 : 0, + incidents: value.incidents, outage_minutes: 0, } }) @@ -113,23 +119,20 @@ function toGroupUptimeSeries(group: PerformanceGroup): UptimeDayPoint[] { return group.series.map((point) => ({ date: new Date(point.ts * 1000).toISOString(), uptime_pct: Math.round(point.success_rate * 100) / 100, - incidents: point.success_count < point.count ? 1 : 0, + incidents: point.success_rate < 100 ? 1 : 0, outage_minutes: 0, })) } -function weightedAverage( +function average( rows: PerformanceRow[], field: 'avg_ttft_ms' | 'avg_latency_ms' -): number { - let total = 0 - let count = 0 - for (const row of rows) { - if (row[field] <= 0 || row.request_count <= 0) continue - total += row[field] * row.request_count - count += row.request_count - } - return count > 0 ? Math.round(total / count) : 0 +) { + const values = rows.map((row) => row[field]).filter((value) => value > 0) + if (values.length === 0) return 0 + return Math.round( + values.reduce((sum, value) => sum + value, 0) / values.length + ) } export function ModelDetailsPerformance(props: { model: PricingModel }) { @@ -147,7 +150,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) { avg_ttft_ms: group.avg_ttft_ms, avg_latency_ms: group.avg_latency_ms, success_rate: group.success_rate, - request_count: group.request_count, + avg_tps: group.avg_tps, })), [groups] ) @@ -169,15 +172,22 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) { ) } - const ttftValues = performances - .map((p) => p.avg_ttft_ms) + const tpsValues = performances + .map((p) => p.avg_tps) .filter((value) => value > 0) - const bestTtft = ttftValues.length > 0 ? Math.min(...ttftValues) : 0 - const avgLatency = weightedAverage(performances, 'avg_latency_ms') - const totalRequests = performances.reduce((s, p) => s + p.request_count, 0) - const totalSuccess = groups.reduce((s, p) => s + p.success_count, 0) + const avgTps = + tpsValues.length > 0 + ? tpsValues.reduce((sum, value) => sum + value, 0) / tpsValues.length + : 0 + const avgLatency = average(performances, 'avg_latency_ms') + const successRates = performances + .map((perf) => perf.success_rate) + .filter((value) => Number.isFinite(value)) const successRate = - totalRequests > 0 ? (totalSuccess / totalRequests) * 100 : 0 + successRates.length > 0 + ? successRates.reduce((sum, value) => sum + value, 0) / + successRates.length + : 0 const incidentCount = uptimeSeries.reduce((s, p) => s + p.incidents, 0) let intent: 'default' | 'warning' | 'success' = 'warning' if (successRate >= 99.9) { @@ -191,18 +201,17 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) { return (
-
+
-
{t('Group')} + + TPS + {t('Average TTFT')} @@ -243,46 +249,35 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) { {t('Average latency')} {t('Success rate')} - - {t('Request Count')} - - {performances.map((perf) => { - const isBestTtft = perf.avg_ttft_ms === bestTtft - return ( - - - - - - {formatLatency(perf.avg_ttft_ms)} - - - {formatLatency(perf.avg_latency_ms)} - - - - - - {COMPACT_NUMBER.format(perf.request_count)} - - - ) - })} + {performances.map((perf) => ( + + + + + + {formatThroughput(perf.avg_tps)} + + + {formatLatency(perf.avg_ttft_ms)} + + + {formatLatency(perf.avg_latency_ms)} + + + + + + ))}
@@ -292,7 +287,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
@@ -322,7 +317,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) { ) : null } /> - +
) diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index 177d1c13..054eed35 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -1,16 +1,10 @@ import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' import { useNavigate, useParams, useSearch } from '@tanstack/react-router' -import { - ArrowLeft, - Boxes, - Code2, - HeartPulse, - Info, - ReceiptText, - Rocket, -} from 'lucide-react' +import { ArrowLeft, Code2, HeartPulse, Info, Timer } from 'lucide-react' import { useTranslation } from 'react-i18next' import { getLobeIcon } from '@/lib/lobe-icon' +import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Sheet, @@ -32,6 +26,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { CopyButton } from '@/components/copy-button' import { GroupBadge } from '@/components/group-badge' import { PublicLayout } from '@/components/layout' +import { getPerfMetrics } from '../api' import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants' import { usePricingData } from '../hooks/use-pricing-data' import { @@ -42,18 +37,23 @@ import { } from '../lib/dynamic-price' import { parseTags } from '../lib/filters' import { - getAvailableGroups, - isTokenBasedModel, - replaceModelInPath, -} from '../lib/model-helpers' + formatLatency, + formatThroughput, + formatUptimePct, +} from '../lib/mock-stats' +import { getAvailableGroups, isTokenBasedModel } from '../lib/model-helpers' import { inferModelMetadata } from '../lib/model-metadata' import { formatFixedPrice, formatGroupPrice } from '../lib/price' -import type { PriceType, PricingModel, TokenUnit } from '../types' +import type { + Modality, + ModelCapability, + PriceType, + PricingModel, + TokenUnit, +} from '../types' import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown' import { ModelDetailsApi, ModelDetailsProviderInfo } from './model-details-api' -import { ModelDetailsApps } from './model-details-apps' -import { ModelDetailsCapabilities } from './model-details-capabilities' -import { ModalitiesMatrix } from './model-details-modalities' +import { ModalityIcons } from './model-details-modalities' import { ModelDetailsPerformance } from './model-details-performance' import { ModelDetailsQuickStats } from './model-details-quick-stats' @@ -69,8 +69,181 @@ function SectionTitle(props: { children: React.ReactNode }) { ) } +const CAPABILITY_LABEL_KEYS: Record = { + function_calling: 'Function calling', + streaming: 'Streaming', + vision: 'Vision', + json_mode: 'JSON mode', + structured_output: 'Structured output', + reasoning: 'Reasoning', + tools: 'Tools', + system_prompt: 'System prompt', + web_search: 'Web search', + code_interpreter: 'Code interpreter', + caching: 'Prompt caching', + embeddings: 'Embeddings', +} + +function CompactCapabilityList(props: { capabilities: ModelCapability[] }) { + const { t } = useTranslation() + + if (props.capabilities.length === 0) { + return ( + + {t('No capabilities reported for this model.')} + + ) + } + + return ( +
+ {props.capabilities.map((capability) => ( + + {t(CAPABILITY_LABEL_KEYS[capability] ?? capability)} + + ))} +
+ ) +} + +function CompactModalities(props: { input: Modality[]; output: Modality[] }) { + const { t } = useTranslation() + + return ( +
+
+ + {t('Input')} + + +
+
+ + {t('Output')} + + +
+
+ ) +} + +function ModelSignalsSection(props: { + capabilities: ModelCapability[] + input: Modality[] + output: Modality[] +}) { + const { t } = useTranslation() + + return ( +
+ + {t('Capabilities')} / {t('Supported modalities')} + +
+ + +
+
+ ) +} + +function OverviewMetric(props: { + icon: React.ComponentType<{ className?: string }> + label: string + value: React.ReactNode + intent?: 'default' | 'warning' | 'success' +}) { + const Icon = props.icon + const intent = props.intent ?? 'default' + + return ( +
+ +
+
+ {props.label} +
+
+ {props.value} +
+
+
+ ) +} + +function OverviewSummaryGrid(props: { model: PricingModel }) { + const { t } = useTranslation() + const metricsQuery = useQuery({ + queryKey: ['perf-metrics', props.model.model_name], + queryFn: () => getPerfMetrics(props.model.model_name, 24), + staleTime: 60 * 1000, + }) + + const groups = metricsQuery.data?.data.groups ?? [] + const successRates = groups + .map((group) => group.success_rate) + .filter((rate) => Number.isFinite(rate)) + const successRate = + successRates.length > 0 + ? successRates.reduce((sum, rate) => sum + rate, 0) / successRates.length + : Number.NaN + let successIntent: 'default' | 'warning' | 'success' = 'warning' + if (successRate >= 99.9) { + successIntent = 'success' + } else if (successRate >= 99) { + successIntent = 'default' + } + const tpsValues = groups + .map((group) => group.avg_tps) + .filter((value) => value > 0) + const avgTps = + tpsValues.length > 0 + ? tpsValues.reduce((sum, value) => sum + value, 0) / tpsValues.length + : 0 + const latencyValues = groups + .map((group) => group.avg_latency_ms) + .filter((value) => value > 0) + const avgLatency = + latencyValues.length > 0 + ? Math.round( + latencyValues.reduce((sum, value) => sum + value, 0) / + latencyValues.length + ) + : 0 + + return ( +
+ + + +
+ ) +} + // ---------------------------------------------------------------------------- -// Model header (always visible above the tabs) +// Model header (always visible above the detail sections) // ---------------------------------------------------------------------------- function ModelHeader(props: { model: PricingModel }) { @@ -362,55 +535,6 @@ function PriceSection(props: { ) } -// ---------------------------------------------------------------------------- -// API endpoints list -// ---------------------------------------------------------------------------- - -function EndpointsSection(props: { - model: PricingModel - endpointMap: Record -}) { - const { t } = useTranslation() - const endpoints = useMemo(() => { - const types = props.model.supported_endpoint_types || [] - return types.map((type) => { - const info = props.endpointMap[type] || {} - let path = info.path || '' - if (path.includes('{model}')) { - path = replaceModelInPath(path, props.model.model_name || '') - } - return { type, path, method: info.method || 'POST' } - }) - }, [props.model, props.endpointMap]) - - if (endpoints.length === 0) return null - - return ( -
- {t('API Endpoints')} -
- {endpoints.map(({ type, path, method }) => ( -
-
- {type} - {path && ( - - {path} - - )} -
- {path && ( - - {method} - - )} -
- ))} -
-
- ) -} - // ---------------------------------------------------------------------------- // Auto group chain (used inside group pricing section) // ---------------------------------------------------------------------------- @@ -740,17 +864,7 @@ function GroupPricingSection(props: { ) } -// ---------------------------------------------------------------------------- -// Tabbed details content -// ---------------------------------------------------------------------------- - -const TAB_VALUES = [ - 'overview', - 'pricing', - 'performance', - 'api', - 'apps', -] as const +const TAB_VALUES = ['overview', 'performance', 'api'] as const type TabValue = (typeof TAB_VALUES)[number] const TAB_META: Record< @@ -758,10 +872,8 @@ const TAB_META: Record< { icon: React.ComponentType<{ className?: string }>; labelKey: string } > = { overview: { icon: Info, labelKey: 'Overview' }, - pricing: { icon: ReceiptText, labelKey: 'Pricing' }, performance: { icon: HeartPulse, labelKey: 'Performance' }, api: { icon: Code2, labelKey: 'API' }, - apps: { icon: Rocket, labelKey: 'Apps' }, } export interface ModelDetailsContentProps { @@ -789,8 +901,6 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
- - {TAB_VALUES.map((value) => { @@ -808,59 +918,42 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) { })} - -
-
- -

- {t('Capabilities')} -

-
- -
+ + -
-
-

- {t('Supported modalities')} -

-
- + {t('Pricing')} + + {isDynamic && ( + + )} +
+ + + + - - - - -
- - - {isDynamic && ( - - )} - @@ -873,10 +966,6 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) { endpointMap={props.endpointMap} /> - - - -
) diff --git a/web/default/src/features/rankings/api.ts b/web/default/src/features/rankings/api.ts new file mode 100644 index 00000000..eecfb0b1 --- /dev/null +++ b/web/default/src/features/rankings/api.ts @@ -0,0 +1,15 @@ +import { api } from '@/lib/api' +import type { RankingPeriod, RankingsSnapshot } from './types' + +type RankingsResponse = { + success: boolean + message?: string + data: RankingsSnapshot +} + +export async function getRankings( + period: RankingPeriod +): Promise { + const res = await api.get('/api/rankings', { params: { period } }) + return res.data +} diff --git a/web/default/src/features/rankings/components/apps-section.tsx b/web/default/src/features/rankings/components/apps-section.tsx deleted file mode 100644 index 1624b67d..00000000 --- a/web/default/src/features/rankings/components/apps-section.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { ExternalLink, Rocket } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import { Badge } from '@/components/ui/badge' -import { formatTokens } from '../lib/format' -import type { AppListing } from '../types' -import { GrowthText } from './growth-text' - -type AppsSectionProps = { - rows: AppListing[] -} - -/** - * "Top Apps" card — clean two-column listing of the apps consuming the - * most tokens through new-api in the active period. Apps don't get a - * dedicated chart (each app has too much variance to plot meaningfully); - * instead we keep the focus on the leaderboard itself. - */ -export function AppsSection(props: AppsSectionProps) { - const { t } = useTranslation() - - const half = Math.ceil(props.rows.length / 2) - const left = props.rows.slice(0, half) - const right = props.rows.slice(half) - - return ( -
-
-

- - {t('Top Apps')} -

-

- {t('Apps using the most tokens through new-api')} -

-
- {props.rows.length === 0 ? ( -
- {t('No apps match the selected filters')} -
- ) : ( -
- - {right.length > 0 && } -
- )} -
- ) -} - -function AppList(props: { rows: AppListing[] }) { - return ( -
    - {props.rows.map((row) => ( -
  • - - {row.rank}. - - - {row.initial} - -
    -
    - {row.url ? ( - - {row.name} - - - ) : ( - {row.name} - )} - - {row.category} - -
    -

    - {row.description} -

    -
    -
    -
    - {formatTokens(row.total_tokens)} -
    - -
    -
  • - ))} -
- ) -} diff --git a/web/default/src/features/rankings/components/category-section.tsx b/web/default/src/features/rankings/components/category-section.tsx deleted file mode 100644 index c3f29d3c..00000000 --- a/web/default/src/features/rankings/components/category-section.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { useMemo } from 'react' -import { VChart } from '@visactor/react-vchart' -import { useTranslation } from 'react-i18next' -import { useChartTheme } from '@/lib/use-chart-theme' -import { VCHART_OPTION } from '@/lib/vchart' -import { formatTokens } from '../lib/format' -import type { CategorySection as CategorySectionData } from '../types' -import { ModelLeaderboard } from './model-leaderboard' - -const TOOLTIP_MAX_ROWS = 8 -const MAX_LEADERBOARD_ROWS = 8 - -type CategorySectionProps = { - section: CategorySectionData -} - -/** - * Per-category ranking unit: a compact stacked-bar chart of token usage - * over time paired with a 2-column leaderboard of the top models in that - * category. Renders as a self-contained card; the rankings page stacks - * one of these per category for quick browsing. - */ -export function CategorySection(props: CategorySectionProps) { - const { t } = useTranslation() - const { resolvedTheme, themeReady } = useChartTheme() - - const orderedPoints = useMemo(() => { - const order = new Map( - props.section.models_history.models.map( - (m, idx) => [m.name, idx] as const - ) - ) - return [...props.section.models_history.points].sort((a, b) => { - const tsCmp = a.ts.localeCompare(b.ts) - if (tsCmp !== 0) return tsCmp - return (order.get(a.model) ?? 999) - (order.get(b.model) ?? 999) - }) - }, [props.section.models_history]) - - const spec = useMemo(() => { - if (orderedPoints.length === 0) return null - return { - type: 'bar' as const, - data: [{ id: 'category-history', values: orderedPoints }], - xField: 'label', - yField: 'tokens', - seriesField: 'model', - stack: true, - bar: { style: { cornerRadius: 1 } }, - legends: { visible: false }, - axes: [ - { - orient: 'bottom', - label: { - style: { fill: 'currentColor', fontSize: 9 }, - autoHide: true, - autoLimit: true, - }, - tick: { visible: false }, - }, - { - orient: 'left', - label: { - formatMethod: (val: number | string) => formatTokens(Number(val)), - style: { fill: 'currentColor', fontSize: 9 }, - }, - grid: { visible: true, style: { lineDash: [3, 3] } }, - }, - ], - tooltip: { - mark: { - content: [ - { - key: (datum: Record) => - String(datum?.model ?? ''), - value: (datum: Record) => - formatTokens(Number(datum?.tokens) || 0), - }, - ], - }, - dimension: { - title: { - value: (datum: Record) => - String(datum?.label ?? ''), - }, - content: [ - { - key: (datum: Record) => - String(datum?.model ?? ''), - value: (datum: Record) => - Number(datum?.tokens) || 0, - }, - ], - updateContent: ( - array: Array<{ key: string; value: string | number }> - ) => { - array.sort((a, b) => Number(b.value) - Number(a.value)) - const visible = array.slice(0, TOOLTIP_MAX_ROWS) - return visible.map((item) => ({ - key: item.key, - value: formatTokens(Number(item.value) || 0), - })) - }, - }, - }, - animationAppear: { duration: 400 }, - } - }, [orderedPoints]) - - return ( -
-
-
-

- {t(props.section.label)} -

-

- {t(props.section.description)} -

-
-
-
- {formatTokens(props.section.total_tokens)} -
-
- {t('tokens')} -
-
-
- -
-
- {themeReady && spec ? ( - - ) : ( -
- {t('No history data available')} -
- )} -
-
- - {props.section.models.length === 0 ? ( -
- {t('No models match the selected filters')} -
- ) : ( -
- -
- )} -
- ) -} - -type CategorySectionsProps = { - sections: CategorySectionData[] -} - -/** - * Renders the per-category rankings strip (one card per category). - * Includes a strip header so users understand the page structure shifts - * from the global view to category drill-downs. - */ -export function CategorySections(props: CategorySectionsProps) { - const { t } = useTranslation() - - if (props.sections.length === 0) return null - - return ( -
-
-

- {t('By category')} -

-

- {t('Browse rankings by category')} -

-

- {t('Discover the leading models in each domain')} -

-
-
- {props.sections.map((section) => ( - - ))} -
-
- ) -} diff --git a/web/default/src/features/rankings/components/index.ts b/web/default/src/features/rankings/components/index.ts index 17d6a263..9fe216fd 100644 --- a/web/default/src/features/rankings/components/index.ts +++ b/web/default/src/features/rankings/components/index.ts @@ -1,5 +1,3 @@ -export * from './apps-section' -export * from './category-section' export * from './entity-links' export * from './growth-text' export * from './market-share-section' diff --git a/web/default/src/features/rankings/components/market-share-section.tsx b/web/default/src/features/rankings/components/market-share-section.tsx index 8a925d13..6f44d171 100644 --- a/web/default/src/features/rankings/components/market-share-section.tsx +++ b/web/default/src/features/rankings/components/market-share-section.tsx @@ -16,7 +16,7 @@ const PERIOD_DESCRIPTIONS: Record = { all: 'Token share by model author since launch', } -/** Stable colour palette for vendors, used in both the area chart and the +/** Stable colour palette for vendors, used in both the share chart and the * legend dots. Falls back to a neutral palette for unknown vendors so that * future additions still render. */ const VENDOR_COLOURS: Record = { @@ -77,7 +77,7 @@ type MarketShareSectionProps = { } /** - * Combined "Market Share" card: a 100%-stacked area chart showing each + * Combined "Market Share" card: a 100%-stacked bar chart showing each * vendor's slice of total token volume, paired below with a two-column * vendor list. */ @@ -104,18 +104,15 @@ export function MarketShareSection(props: MarketShareSectionProps) { const spec = useMemo(() => { if (orderedPoints.length === 0) return null return { - type: 'area' as const, + type: 'bar' as const, data: [{ id: 'vendor-share', values: orderedPoints }], xField: 'label', yField: 'share', seriesField: 'vendor', stack: true, + paddingInner: 0.12, legends: { visible: false }, - area: { - style: { fillOpacity: 0.85, curveType: 'monotone' }, - }, - line: { style: { lineWidth: 0, curveType: 'monotone' } }, - point: { visible: false }, + bar: { style: { cornerRadius: 1 } }, color: { specified: colourMap }, axes: [ { diff --git a/web/default/src/features/rankings/components/pulse-section.tsx b/web/default/src/features/rankings/components/pulse-section.tsx index a2937d91..5503888c 100644 --- a/web/default/src/features/rankings/components/pulse-section.tsx +++ b/web/default/src/features/rankings/components/pulse-section.tsx @@ -1,33 +1,28 @@ import { ArrowDownRight, ArrowUpRight, - Sparkles, TrendingDown, TrendingUp, } from 'lucide-react' import { useTranslation } from 'react-i18next' import { getLobeIcon } from '@/lib/lobe-icon' import { cn } from '@/lib/utils' -import { formatReleaseDate, formatTokens } from '../lib/format' -import type { NewModelEntry, RankingMover } from '../types' +import type { RankingMover } from '../types' import { ModelLink, VendorLink } from './entity-links' type PulseSectionProps = { movers: RankingMover[] droppers: RankingMover[] - newModels: NewModelEntry[] } /** - * Three-up "Pulse" panel: rank gainers, rank losers, and recently released - * models — the "what's changing" footer of the rankings page. Each card - * is intentionally compact so the trio fits in one row on desktop. + * Rank movement panel: gainers and losers calculated from the previous period. */ export function PulseSection(props: PulseSectionProps) { const { t } = useTranslation() return ( -
+
)} - - } - > - {props.newModels.length === 0 ? ( - - ) : ( -
    - {props.newModels.slice(0, 6).map((row) => ( - - ))} -
- )} -
) } @@ -145,28 +124,3 @@ function MoverRow(props: { row: RankingMover; intent: 'up' | 'down' }) { ) } - -function NewModelRow(props: { row: NewModelEntry }) { - return ( -
  • - {getLobeIcon(props.row.vendor_icon, 20)} -
    - - {props.row.model_name} - -

    - {formatReleaseDate(props.row.release_date)} ·{' '} - - {props.row.vendor.toLowerCase()} - -

    -
    - - {formatTokens(props.row.total_tokens)} - -
  • - ) -} diff --git a/web/default/src/features/rankings/components/rankings-hero.tsx b/web/default/src/features/rankings/components/rankings-hero.tsx index 1c2ccc5a..3f994f1c 100644 --- a/web/default/src/features/rankings/components/rankings-hero.tsx +++ b/web/default/src/features/rankings/components/rankings-hero.tsx @@ -17,9 +17,7 @@ type RankingsHeroProps = { /** * Hero strip for the rankings page. Intentionally minimal — title + - * subtitle + period tabs only. Category filtering is no longer needed - * because every category is rendered inline as its own section further - * down the page. + * subtitle + period tabs only. */ export function RankingsHero(props: RankingsHeroProps) { const { t } = useTranslation() @@ -35,7 +33,7 @@ export function RankingsHero(props: RankingsHeroProps) {

    {t( - 'Discover the most-used models, top apps, and rising vendors on the platform — updated continuously across every category.' + 'Discover the most-used models and rising vendors on the platform, updated from live usage data.' )}

    diff --git a/web/default/src/features/rankings/hooks/use-rankings.ts b/web/default/src/features/rankings/hooks/use-rankings.ts index a42de1af..e258b2af 100644 --- a/web/default/src/features/rankings/hooks/use-rankings.ts +++ b/web/default/src/features/rankings/hooks/use-rankings.ts @@ -1,15 +1,11 @@ -import { useMemo } from 'react' -import { buildRankingsSnapshot } from '../lib/mock-rankings' -import type { RankingPeriod, RankingsSnapshot } from '../types' +import { useQuery } from '@tanstack/react-query' +import { getRankings } from '../api' +import type { RankingPeriod } from '../types' -/** - * Memoised rankings snapshot for a period. - * - * Currently this synchronously builds deterministic mock data. When the - * backend ships real analytics endpoints, swap the body to a - * `useQuery`-based fetch — the consuming components don't care which side - * produced the data as long as it conforms to {@link RankingsSnapshot}. - */ -export function useRankings(period: RankingPeriod): RankingsSnapshot { - return useMemo(() => buildRankingsSnapshot(period), [period]) +export function useRankings(period: RankingPeriod) { + return useQuery({ + queryKey: ['rankings', period], + queryFn: () => getRankings(period), + staleTime: 5 * 60 * 1000, + }) } diff --git a/web/default/src/features/rankings/index.tsx b/web/default/src/features/rankings/index.tsx index f06af33f..ada7e909 100644 --- a/web/default/src/features/rankings/index.tsx +++ b/web/default/src/features/rankings/index.tsx @@ -1,10 +1,9 @@ import { useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' +import { Skeleton } from '@/components/ui/skeleton' import { PublicLayout } from '@/components/layout' import { PageTransition } from '@/components/page-transition' import { - AppsSection, - CategorySections, MarketShareSection, ModelsSection, PulseSection, @@ -26,7 +25,8 @@ export function Rankings() { ? (search.period as RankingPeriod) : 'week' - const snapshot = useRankings(period) + const rankingsQuery = useRankings(period) + const snapshot = rankingsQuery.data?.data const handlePeriodChange = (next: RankingPeriod) => { navigate({ @@ -56,37 +56,62 @@ export function Rankings() { - {/* Overall (all-categories) view ----------------------------- */} - + {rankingsQuery.isLoading ? ( + + ) : !snapshot ? ( + + ) : ( + <> + - + - - - - - {/* Per-category drill-downs --------------------------------- */} - - -

    - {t( - 'Ranking data is currently simulated for preview purposes and will be replaced with live analytics once the backend integration ships.' - )} -

    + + + )}
    ) } + +function RankingsLoading() { + return ( +
    + + + +
    + ) +} + +function RankingsError(props: { message: string }) { + const { t } = useTranslation() + return ( +
    +

    + {t('Unable to load rankings')} +

    +

    + {props.message} +

    +
    + ) +} diff --git a/web/default/src/features/rankings/lib/index.ts b/web/default/src/features/rankings/lib/index.ts index dadc7ca8..d1884f92 100644 --- a/web/default/src/features/rankings/lib/index.ts +++ b/web/default/src/features/rankings/lib/index.ts @@ -1,2 +1 @@ export * from './format' -export * from './mock-rankings' diff --git a/web/default/src/features/rankings/lib/mock-rankings.ts b/web/default/src/features/rankings/lib/mock-rankings.ts deleted file mode 100644 index 52e3c76a..00000000 --- a/web/default/src/features/rankings/lib/mock-rankings.ts +++ /dev/null @@ -1,1048 +0,0 @@ -import { - hashStringToSeed, - randomInRange, - randomIntInRange, - seededRandom, -} from '@/features/pricing/lib/seed' -import type { - AppCategory, - AppListing, - CategorySection, - ModelHistoryPoint, - ModelHistorySeries, - ModelRanking, - NewModelEntry, - RankingCategory, - RankingCategoryId, - RankingMover, - RankingPeriod, - RankingsSnapshot, - VendorRanking, - VendorSharePoint, - VendorShareSeries, -} from '../types' - -// ---------------------------------------------------------------------------- -// Catalogue: categories + canonical model & app fixtures -// ---------------------------------------------------------------------------- -// -// All ranking data is derived from these fixtures plus a deterministic PRNG -// seeded by `${period}:${category}`. Every call with the same arguments -// returns the same numbers, while different (period, category) pairs render -// visibly distinct data. When the backend ships real analytics, these -// fixtures stay only as fallbacks. - -export const RANKING_CATEGORIES: RankingCategory[] = [ - { - id: 'all', - label: 'All categories', - description: 'Aggregate traffic across every category', - }, - { - id: 'programming', - label: 'Programming', - description: 'Code generation, refactoring, autocomplete', - }, - { - id: 'roleplay', - label: 'Roleplay', - description: 'Character chat, storytelling, persona', - }, - { - id: 'marketing', - label: 'Marketing', - description: 'Copywriting, ad creative, SEO', - }, - { - id: 'translation', - label: 'Translation', - description: 'Multilingual translation and localisation', - }, - { - id: 'science', - label: 'Science', - description: 'Research, analysis, scientific reasoning', - }, - { - id: 'finance', - label: 'Finance', - description: 'Trading insights, accounting, advisory', - }, - { - id: 'health', - label: 'Health', - description: 'Medical Q&A, mental health support', - }, - { - id: 'legal', - label: 'Legal', - description: 'Contract review, compliance, summarisation', - }, - { - id: 'education', - label: 'Education', - description: 'Tutoring, learning aids, assessment', - }, - { - id: 'productivity', - label: 'Productivity', - description: 'Email, summarisation, knowledge work', - }, - { - id: 'multimodal', - label: 'Multimodal', - description: 'Vision, image / video, document chat', - }, -] - -type ModelFixture = { - name: string - vendor: string - vendor_icon: string - release_date: string - /** Categories this model commonly serves. First entry is the primary. */ - categories: RankingCategoryId[] - /** Relative popularity weight (0..1). */ - weight: number -} - -const MODEL_FIXTURES: ModelFixture[] = [ - { - name: 'gpt-5', - vendor: 'OpenAI', - vendor_icon: 'OpenAI.Color', - release_date: '2025-10-12', - categories: ['programming', 'productivity', 'science'], - weight: 1.0, - }, - { - name: 'claude-sonnet-4-5', - vendor: 'Anthropic', - vendor_icon: 'Claude.Color', - release_date: '2025-09-08', - categories: ['programming', 'productivity', 'legal'], - weight: 0.96, - }, - { - name: 'gemini-2.5-pro', - vendor: 'Google', - vendor_icon: 'Gemini.Color', - release_date: '2025-06-15', - categories: ['multimodal', 'science', 'education'], - weight: 0.88, - }, - { - name: 'deepseek-v3.2', - vendor: 'DeepSeek', - vendor_icon: 'DeepSeek.Color', - release_date: '2025-08-22', - categories: ['programming', 'science'], - weight: 0.84, - }, - { - name: 'gpt-5-mini', - vendor: 'OpenAI', - vendor_icon: 'OpenAI.Color', - release_date: '2025-10-12', - categories: ['productivity', 'roleplay', 'translation'], - weight: 0.78, - }, - { - name: 'claude-opus-4-5', - vendor: 'Anthropic', - vendor_icon: 'Claude.Color', - release_date: '2025-08-04', - categories: ['legal', 'science', 'finance'], - weight: 0.7, - }, - { - name: 'qwen3-235b-a22b', - vendor: 'Alibaba', - vendor_icon: 'Qwen.Color', - release_date: '2025-05-30', - categories: ['programming', 'translation', 'science'], - weight: 0.66, - }, - { - name: 'grok-4', - vendor: 'xAI', - vendor_icon: 'XAI', - release_date: '2025-04-18', - categories: ['roleplay', 'science', 'marketing'], - weight: 0.62, - }, - { - name: 'llama-4-maverick', - vendor: 'Meta', - vendor_icon: 'Meta.Color', - release_date: '2025-04-05', - categories: ['programming', 'productivity'], - weight: 0.58, - }, - { - name: 'kimi-k2', - vendor: 'Moonshot', - vendor_icon: 'Moonshot', - release_date: '2025-07-19', - categories: ['productivity', 'translation'], - weight: 0.55, - }, - { - name: 'glm-4.6', - vendor: 'Zhipu', - vendor_icon: 'Zhipu.Color', - release_date: '2025-09-26', - categories: ['programming', 'productivity'], - weight: 0.52, - }, - { - name: 'gemini-2.5-flash', - vendor: 'Google', - vendor_icon: 'Gemini.Color', - release_date: '2025-06-15', - categories: ['productivity', 'translation', 'multimodal'], - weight: 0.49, - }, - { - name: 'mistral-large-3', - vendor: 'Mistral', - vendor_icon: 'Mistral.Color', - release_date: '2025-03-12', - categories: ['programming', 'finance'], - weight: 0.46, - }, - { - name: 'doubao-1.6-pro', - vendor: 'ByteDance', - vendor_icon: 'Doubao.Color', - release_date: '2025-07-02', - categories: ['marketing', 'roleplay'], - weight: 0.44, - }, - { - name: 'hunyuan-turbos', - vendor: 'Tencent', - vendor_icon: 'Hunyuan.Color', - release_date: '2025-05-08', - categories: ['productivity', 'translation'], - weight: 0.4, - }, - { - name: 'gpt-image-2', - vendor: 'OpenAI', - vendor_icon: 'OpenAI.Color', - release_date: '2025-06-04', - categories: ['multimodal', 'marketing'], - weight: 0.38, - }, - { - name: 'sora-2', - vendor: 'OpenAI', - vendor_icon: 'OpenAI.Color', - release_date: '2025-09-30', - categories: ['multimodal', 'marketing'], - weight: 0.34, - }, - { - name: 'veo-3', - vendor: 'Google', - vendor_icon: 'Gemini.Color', - release_date: '2025-08-15', - categories: ['multimodal', 'marketing'], - weight: 0.31, - }, - { - name: 'qwen3-vl-plus', - vendor: 'Alibaba', - vendor_icon: 'Qwen.Color', - release_date: '2025-06-20', - categories: ['multimodal', 'education'], - weight: 0.3, - }, - { - name: 'minimax-m2', - vendor: 'MiniMax', - vendor_icon: 'Minimax.Color', - release_date: '2025-07-25', - categories: ['roleplay', 'translation'], - weight: 0.28, - }, - { - name: 'cohere-command-r-plus', - vendor: 'Cohere', - vendor_icon: 'Cohere.Color', - release_date: '2024-11-10', - categories: ['marketing', 'productivity'], - weight: 0.26, - }, - { - name: 'ernie-x1-turbo', - vendor: 'Baidu', - vendor_icon: 'Baidu.Color', - release_date: '2025-04-30', - categories: ['translation', 'productivity'], - weight: 0.22, - }, -] - -type AppFixture = { - name: string - description: string - category: AppCategory - url?: string - weight: number - /** Bias toward these models (model_name). */ - prefers: string[] -} - -const APP_FIXTURES: AppFixture[] = [ - { - name: 'Cline', - description: 'Autonomous coding agent inside the IDE', - category: 'Coding', - url: 'https://cline.bot', - weight: 1.0, - prefers: ['claude-sonnet-4-5', 'gpt-5'], - }, - { - name: 'Roo Code', - description: 'AI agent for VS Code with multi-step planning', - category: 'Coding', - url: 'https://roocode.com', - weight: 0.9, - prefers: ['claude-sonnet-4-5', 'deepseek-v3.2'], - }, - { - name: 'Cursor', - description: 'Editor with built-in AI for code generation', - category: 'Coding', - url: 'https://cursor.com', - weight: 0.85, - prefers: ['gpt-5', 'claude-sonnet-4-5'], - }, - { - name: 'Continue', - description: 'Open-source AI code assistant for editors', - category: 'Coding', - url: 'https://continue.dev', - weight: 0.62, - prefers: ['deepseek-v3.2', 'qwen3-235b-a22b'], - }, - { - name: 'Aider', - description: 'Pair-programming in your terminal', - category: 'Coding', - url: 'https://aider.chat', - weight: 0.46, - prefers: ['claude-sonnet-4-5', 'gpt-5'], - }, - { - name: 'Open WebUI', - description: 'Self-hosted ChatGPT-like web interface', - category: 'Chat', - url: 'https://openwebui.com', - weight: 0.74, - prefers: ['gpt-5-mini', 'qwen3-235b-a22b'], - }, - { - name: 'LibreChat', - description: 'Open-source multi-model chat platform', - category: 'Chat', - url: 'https://librechat.ai', - weight: 0.6, - prefers: ['gpt-5-mini', 'gemini-2.5-flash'], - }, - { - name: 'Lobe Chat', - description: 'Modern open-source chat UI with plugins', - category: 'Chat', - url: 'https://lobehub.com', - weight: 0.58, - prefers: ['gpt-5-mini', 'claude-sonnet-4-5'], - }, - { - name: 'NextChat', - description: 'Cross-platform private ChatGPT client', - category: 'Chat', - url: 'https://nextchat.dev', - weight: 0.4, - prefers: ['gpt-5-mini', 'gemini-2.5-flash'], - }, - { - name: 'TypingMind', - description: 'Better UI for ChatGPT and Claude', - category: 'Chat', - url: 'https://typingmind.com', - weight: 0.34, - prefers: ['gpt-5', 'claude-sonnet-4-5'], - }, - { - name: 'SillyTavern', - description: 'Roleplay frontend for chat models', - category: 'Roleplay', - url: 'https://sillytavernai.com', - weight: 0.7, - prefers: ['claude-opus-4-5', 'minimax-m2'], - }, - { - name: 'Janitor AI', - description: 'Roleplay chat with custom characters', - category: 'Roleplay', - url: 'https://janitorai.com', - weight: 0.55, - prefers: ['minimax-m2', 'doubao-1.6-pro'], - }, - { - name: 'Notion AI', - description: 'AI features inside Notion docs', - category: 'Productivity', - url: 'https://notion.so', - weight: 0.62, - prefers: ['gpt-5', 'claude-sonnet-4-5'], - }, - { - name: 'Reflect', - description: 'Personal AI knowledge assistant', - category: 'Productivity', - url: 'https://reflect.app', - weight: 0.36, - prefers: ['gpt-5-mini', 'claude-sonnet-4-5'], - }, - { - name: 'Mem', - description: 'AI-first note-taking app', - category: 'Productivity', - url: 'https://mem.ai', - weight: 0.32, - prefers: ['gpt-5-mini'], - }, - { - name: 'Khanmigo', - description: 'Tutor for Khan Academy learners', - category: 'Education', - url: 'https://khanmigo.ai', - weight: 0.48, - prefers: ['gpt-5', 'claude-sonnet-4-5'], - }, - { - name: 'Quizlet AI', - description: 'Personalised study & flashcards', - category: 'Education', - url: 'https://quizlet.com', - weight: 0.36, - prefers: ['gemini-2.5-flash'], - }, - { - name: 'Perplexity', - description: 'Conversational answer engine', - category: 'Research', - url: 'https://perplexity.ai', - weight: 0.78, - prefers: ['gpt-5', 'claude-sonnet-4-5'], - }, - { - name: 'Elicit', - description: 'AI research assistant for papers', - category: 'Research', - url: 'https://elicit.com', - weight: 0.42, - prefers: ['claude-opus-4-5', 'gemini-2.5-pro'], - }, - { - name: 'Jasper', - description: 'Marketing copywriting platform', - category: 'Marketing', - url: 'https://jasper.ai', - weight: 0.5, - prefers: ['gpt-5', 'claude-sonnet-4-5'], - }, - { - name: 'Copy.ai', - description: 'AI sales & marketing automation', - category: 'Marketing', - url: 'https://copy.ai', - weight: 0.4, - prefers: ['gpt-5-mini'], - }, - { - name: 'DeepL Write', - description: 'AI rewriting & translation', - category: 'Translation', - url: 'https://deepl.com', - weight: 0.36, - prefers: ['gpt-5-mini', 'qwen3-235b-a22b'], - }, - { - name: 'Wordtune', - description: 'AI rewriting & paraphrasing', - category: 'Translation', - url: 'https://wordtune.com', - weight: 0.3, - prefers: ['gpt-5-mini'], - }, - { - name: 'Harvey', - description: 'AI assistant for law firms', - category: 'Other', - url: 'https://harvey.ai', - weight: 0.42, - prefers: ['claude-opus-4-5', 'gpt-5'], - }, - { - name: 'Hippocratic', - description: 'Healthcare-focused AI agents', - category: 'Health', - url: 'https://hippocratic.ai', - weight: 0.32, - prefers: ['claude-opus-4-5'], - }, - { - name: 'Cleo', - description: 'AI personal finance assistant', - category: 'Finance', - url: 'https://meetcleo.com', - weight: 0.28, - prefers: ['gpt-5-mini'], - }, -] - -// ---------------------------------------------------------------------------- -// PRNG seeding helpers -// ---------------------------------------------------------------------------- - -const PERIOD_FACTOR: Record = { - today: 0.04, - week: 0.25, - month: 1.0, - year: 11.5, - all: 38.0, -} - -function periodSeed( - period: RankingPeriod, - category: RankingCategoryId -): number { - return hashStringToSeed(`rankings:${period}:${category}`) -} - -/** Pick a previous_rank for a model that's currently at `rank`. */ -function makePreviousRank(rand: () => number, rank: number, total: number) { - if (rand() < 0.08) return undefined - const delta = randomIntInRange(rand, -3, 3) - const prev = Math.max(1, Math.min(total + 4, rank + delta)) - if (prev === rank) return undefined - return prev -} - -// ---------------------------------------------------------------------------- -// Leaderboard builders -// ---------------------------------------------------------------------------- - -function buildModelRankings( - period: RankingPeriod, - category: RankingCategoryId -): ModelRanking[] { - const seed = periodSeed(period, category) - const periodFactor = PERIOD_FACTOR[period] - - const filtered = - category === 'all' - ? MODEL_FIXTURES - : MODEL_FIXTURES.filter((m) => m.categories.includes(category)) - - // Slight per-call jitter on weights so the leaderboard re-orders a bit - // between periods/categories. - const ranked = filtered - .map((m) => ({ - fixture: m, - score: - m.weight * - (0.85 + seededRandom(seed ^ hashStringToSeed(m.name))() * 0.4), - })) - .sort((a, b) => b.score - a.score) - .slice(0, 20) - - if (ranked.length === 0) return [] - - const totalScore = ranked.reduce((s, r) => s + r.score, 0) - const baseTokens = 240_000_000 * periodFactor - - return ranked.map(({ fixture, score }, idx) => { - const rowSeed = seed ^ hashStringToSeed(fixture.name) - const rowRand = seededRandom(rowSeed) - const share = score / totalScore - const totalTokens = Math.round(baseTokens * share * (0.9 + rowRand() * 0.2)) - const growth = randomInRange(seededRandom(rowSeed ^ 0x11), -22, 96) - return { - rank: idx + 1, - previous_rank: makePreviousRank( - seededRandom(rowSeed ^ 0x22), - idx + 1, - ranked.length - ), - model_name: fixture.name, - vendor: fixture.vendor, - vendor_icon: fixture.vendor_icon, - category: fixture.categories[0], - total_tokens: totalTokens, - share, - growth_pct: Math.round(growth * 10) / 10, - } - }) -} - -function buildAppListings( - period: RankingPeriod, - category: RankingCategoryId, - models: ModelRanking[] -): AppListing[] { - const seed = periodSeed(period, category) ^ 0xa11 - const periodFactor = PERIOD_FACTOR[period] - - // Map "all" category to all apps; otherwise filter by a soft mapping. We - // keep the list large and let weights distribute naturally. - const filtered = APP_FIXTURES.filter((app) => { - if (category === 'all') return true - if (category === 'programming') return app.category === 'Coding' - if (category === 'roleplay') return app.category === 'Roleplay' - if (category === 'marketing') return app.category === 'Marketing' - if (category === 'translation') return app.category === 'Translation' - if (category === 'education') return app.category === 'Education' - if (category === 'productivity') - return app.category === 'Productivity' || app.category === 'Chat' - if (category === 'science') return app.category === 'Research' - if (category === 'health') return app.category === 'Health' - if (category === 'finance') return app.category === 'Finance' - if (category === 'multimodal') - return ['Creative', 'Marketing'].includes(app.category) - return true - }) - - const ranked = filtered - .map((app) => ({ - app, - score: - app.weight * - (0.85 + seededRandom(seed ^ hashStringToSeed(app.name))() * 0.4), - })) - .sort((a, b) => b.score - a.score) - - if (ranked.length === 0) return [] - - const totalScore = ranked.reduce((s, r) => s + r.score, 0) - const baseTokens = 84_000_000 * periodFactor - const modelNames = new Set(models.map((m) => m.model_name)) - - return ranked.slice(0, 14).map(({ app, score }, idx) => { - const rowSeed = seed ^ hashStringToSeed(app.name) - const share = score / totalScore - const totalTokens = Math.round( - baseTokens * share * (0.9 + seededRandom(rowSeed)() * 0.25) - ) - const growth = randomInRange(seededRandom(rowSeed ^ 0xab), -28, 130) - const topModel = - app.prefers.find((m) => modelNames.has(m)) ?? - app.prefers[0] ?? - models[0]?.model_name ?? - 'gpt-5' - return { - rank: idx + 1, - previous_rank: makePreviousRank( - seededRandom(rowSeed ^ 0xcd), - idx + 1, - ranked.length - ), - name: app.name, - description: app.description, - category: app.category, - url: app.url, - total_tokens: totalTokens, - growth_pct: Math.round(growth * 10) / 10, - top_model: topModel, - initial: app.name.charAt(0).toUpperCase(), - } - }) -} - -function buildVendorRankings(models: ModelRanking[]): VendorRanking[] { - if (models.length === 0) return [] - const totals = new Map< - string, - { - tokens: number - icon?: string - count: number - growthSum: number - topModel: { name: string; tokens: number } - } - >() - for (const m of models) { - const cur = totals.get(m.vendor) - if (!cur) { - totals.set(m.vendor, { - tokens: m.total_tokens, - icon: m.vendor_icon, - count: 1, - growthSum: m.growth_pct, - topModel: { name: m.model_name, tokens: m.total_tokens }, - }) - } else { - cur.tokens += m.total_tokens - cur.count += 1 - cur.growthSum += m.growth_pct - if (m.total_tokens > cur.topModel.tokens) { - cur.topModel = { name: m.model_name, tokens: m.total_tokens } - } - } - } - - const grand = [...totals.values()].reduce((s, v) => s + v.tokens, 0) - const sorted = [...totals.entries()] - .map(([vendor, v]) => ({ - vendor, - total_tokens: v.tokens, - vendor_icon: v.icon, - models_count: v.count, - top_model: v.topModel.name, - share: v.tokens / Math.max(grand, 1), - growth_pct: Math.round((v.growthSum / v.count) * 10) / 10, - })) - .sort((a, b) => b.total_tokens - a.total_tokens) - - return sorted.map((row, idx) => ({ rank: idx + 1, ...row })) -} - -function buildMovers(models: ModelRanking[]): { - movers: RankingMover[] - droppers: RankingMover[] -} { - const withDelta = models - .filter((m) => m.previous_rank !== undefined) - .map((m) => ({ - model_name: m.model_name, - vendor: m.vendor, - vendor_icon: m.vendor_icon, - current_rank: m.rank, - rank_delta: (m.previous_rank ?? m.rank) - m.rank, - growth_pct: m.growth_pct, - })) - - const movers = [...withDelta] - .filter((x) => x.rank_delta > 0) - .sort((a, b) => b.rank_delta - a.rank_delta || b.growth_pct - a.growth_pct) - .slice(0, 5) - - const droppers = [...withDelta] - .filter((x) => x.rank_delta < 0) - .sort((a, b) => a.rank_delta - b.rank_delta || a.growth_pct - b.growth_pct) - .slice(0, 5) - - return { movers, droppers } -} - -function buildNewModels(period: RankingPeriod): NewModelEntry[] { - const seed = periodSeed(period, 'all') ^ 0xfa11 - const rand = seededRandom(seed) - // "New" = released within the last 90 days for shorter periods, last 12 - // months for "year/all" - const cutoffDays = period === 'today' || period === 'week' ? 90 : 365 - const cutoffMs = Date.now() - cutoffDays * 86_400_000 - return MODEL_FIXTURES.filter((m) => Date.parse(m.release_date) >= cutoffMs) - .slice() - .sort( - (a, b) => - Date.parse(b.release_date) - Date.parse(a.release_date) || - b.weight - a.weight - ) - .slice(0, 6) - .map((m) => ({ - model_name: m.name, - vendor: m.vendor, - vendor_icon: m.vendor_icon, - category: m.categories[0], - release_date: m.release_date, - total_tokens: Math.round( - 220_000_000 * m.weight * PERIOD_FACTOR[period] * (0.85 + rand() * 0.3) - ), - growth_pct: Math.round(randomInRange(rand, 35, 220) * 10) / 10, - })) -} - -// ---------------------------------------------------------------------------- -// History (stacked bar / 100% stacked area) builders -// ---------------------------------------------------------------------------- -// -// These produce a longer time-series than the leaderboard sparklines so the -// charts can render a recognisable growth story across 30+ buckets. Bucket -// granularity scales with the active period. - -const HISTORY_BUCKETS: Record = { - today: 24, // hourly - week: 21, // 3 weeks of daily - month: 30, // ~30 days - year: 52, // ~1 year of weekly - all: 78, // ~18 months of weekly -} - -/** Cap stacked series so the chart legend / colour palette stays legible. */ -const HISTORY_TOP_MODELS = 18 -const HISTORY_TOP_VENDORS = 12 -const OTHERS_LABEL = 'Others' - -function bucketStepMs(period: RankingPeriod): number { - if (period === 'today') return 60 * 60 * 1000 - if (period === 'week' || period === 'month') return 24 * 60 * 60 * 1000 - return 7 * 24 * 60 * 60 * 1000 -} - -function formatBucketLabel(date: Date, period: RankingPeriod): string { - if (period === 'today') { - return `${String(date.getHours()).padStart(2, '0')}:00` - } - return date.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - }) -} - -/** - * Smooth ramp-up profile in [0..1] for a model that launched at - * `releaseTs` and is observed at `bucketTs`. Models launched after the - * bucket return 0; long-established models return 1. The S-curve gives a - * natural ramp during the first ~6 weeks after launch. - */ -function rampWeight(bucketTs: number, releaseTs: number): number { - if (!Number.isFinite(releaseTs)) return 1 - const ageMs = bucketTs - releaseTs - if (ageMs <= 0) return 0 - const sixWeeks = 6 * 7 * 24 * 60 * 60 * 1000 - if (ageMs >= sixWeeks) return 1 - const t = ageMs / sixWeeks - return 1 - Math.pow(1 - t, 3) -} - -function buildModelsHistory( - period: RankingPeriod, - models: ModelRanking[] -): ModelHistorySeries { - const buckets = HISTORY_BUCKETS[period] - if (buckets === 0 || models.length === 0) { - return { points: [], models: [], buckets: 0 } - } - - const stepMs = bucketStepMs(period) - const now = Date.now() - const seed = periodSeed(period, 'all') ^ 0x71_57_07_4d - const top = models.slice(0, Math.min(models.length, HISTORY_TOP_MODELS)) - - const points: ModelHistoryPoint[] = [] - const totals = new Map() - - for (const model of top) { - const releaseFixture = MODEL_FIXTURES.find( - (m) => m.name === model.model_name - ) - const releaseTs = releaseFixture - ? Date.parse(releaseFixture.release_date) - : Number.NaN - - const modelSeed = seed ^ hashStringToSeed(`${model.model_name}:hist`) - const rand = seededRandom(modelSeed) - - // Per-model average tokens per bucket so the area under the curve roughly - // matches `total_tokens` (the leaderboard summary). - const avgPerBucket = model.total_tokens / buckets - - // Drift = how much the model has been growing across the visible window. - // Newer / faster-growing models (high growth_pct) show a steeper slope. - const drift = 0.4 + Math.min(2.4, model.growth_pct / 50) - // Shape factor — most weight near the end for growing models, more even - // for established ones. - const skew = 0.8 + rand() * 0.6 - - let modelTotal = 0 - for (let i = buckets - 1; i >= 0; i--) { - const bucketTs = now - i * stepMs - const date = new Date(bucketTs) - const t = (buckets - 1 - i) / Math.max(1, buckets - 1) - const trendShape = Math.pow(t, 1.4 * skew) * drift + 0.4 - const ramp = rampWeight(bucketTs, releaseTs) - const jitter = 0.78 + rand() * 0.45 - const tokens = Math.max( - 0, - Math.round(avgPerBucket * trendShape * ramp * jitter) - ) - modelTotal += tokens - points.push({ - ts: date.toISOString(), - label: formatBucketLabel(date, period), - model: model.model_name, - vendor: model.vendor, - tokens, - }) - } - totals.set(model.model_name, modelTotal) - } - - // Stable oldest → newest ordering. - points.sort((a, b) => a.ts.localeCompare(b.ts)) - - const ranked = top - .map((m) => ({ - name: m.model_name, - vendor: m.vendor, - total: totals.get(m.model_name) ?? 0, - })) - .sort((a, b) => b.total - a.total) - - return { points, models: ranked, buckets } -} - -function buildVendorShareHistory( - history: ModelHistorySeries -): VendorShareSeries { - if (history.points.length === 0) { - return { points: [], vendors: [], buckets: 0 } - } - - const byBucket = new Map>() - const labelByTs = new Map() - for (const point of history.points) { - if (!byBucket.has(point.ts)) byBucket.set(point.ts, new Map()) - if (!labelByTs.has(point.ts)) labelByTs.set(point.ts, point.label) - const map = byBucket.get(point.ts)! - map.set(point.vendor, (map.get(point.vendor) ?? 0) + point.tokens) - } - - // Use the union of vendors observed across the window so the area chart - // has stable series even on buckets where a vendor has 0 tokens. - const vendorTotals = new Map() - for (const [, vendorMap] of byBucket) { - for (const [vendor, tokens] of vendorMap) { - vendorTotals.set(vendor, (vendorTotals.get(vendor) ?? 0) + tokens) - } - } - const grand = [...vendorTotals.values()].reduce((s, v) => s + v, 0) || 1 - - const sortedVendors = [...vendorTotals.entries()].sort((a, b) => b[1] - a[1]) - const topVendors = sortedVendors - .slice(0, HISTORY_TOP_VENDORS) - .map(([name]) => name) - const otherVendors = new Set( - sortedVendors.slice(HISTORY_TOP_VENDORS).map(([name]) => name) - ) - const hasOthers = otherVendors.size > 0 - - const points: VendorSharePoint[] = [] - const sortedTimestamps = [...byBucket.keys()].sort() - for (const ts of sortedTimestamps) { - const vendorMap = byBucket.get(ts)! - const label = labelByTs.get(ts) ?? ts - const totalAtBucket = - [...vendorMap.values()].reduce((s, v) => s + v, 0) || 1 - - for (const vendor of topVendors) { - const tokens = vendorMap.get(vendor) ?? 0 - points.push({ - ts, - label, - vendor, - share: tokens / totalAtBucket, - tokens, - }) - } - if (hasOthers) { - let othersTokens = 0 - for (const vendor of otherVendors) { - othersTokens += vendorMap.get(vendor) ?? 0 - } - points.push({ - ts, - label, - vendor: OTHERS_LABEL, - share: othersTokens / totalAtBucket, - tokens: othersTokens, - }) - } - } - - const vendors = topVendors - .map((name) => { - const total = vendorTotals.get(name) ?? 0 - return { name, total, share: total / grand } - }) - .sort((a, b) => b.total - a.total) - if (hasOthers) { - let othersTotal = 0 - for (const vendor of otherVendors) { - othersTotal += vendorTotals.get(vendor) ?? 0 - } - vendors.push({ - name: OTHERS_LABEL, - total: othersTotal, - share: othersTotal / grand, - }) - } - - return { points, vendors, buckets: history.buckets } -} - -/** - * Build a single per-category section. Used by `buildRankingsSnapshot` to - * eagerly compute every category section the page renders inline (rather - * than gating them behind a top-level filter). - */ -function buildCategorySection( - period: RankingPeriod, - category: RankingCategory -): CategorySection { - const models = buildModelRankings(period, category.id).slice(0, 12) - const models_history = buildModelsHistory(period, models) - const total_tokens = models.reduce((s, m) => s + m.total_tokens, 0) - return { - category: category.id, - label: category.label, - description: category.description, - models, - models_history, - total_tokens, - } -} - -// ---------------------------------------------------------------------------- -// Public entry point -// ---------------------------------------------------------------------------- - -/** - * Build a full leaderboard snapshot for the given period. - * - * The snapshot bundles the overall (all-categories) view used by the page - * header sections **and** an independent ranking unit for each non-`all` - * category — so the page can render every category inline instead of - * gating the data behind a category filter. - */ -export function buildRankingsSnapshot(period: RankingPeriod): RankingsSnapshot { - const models = buildModelRankings(period, 'all') - const apps = buildAppListings(period, 'all', models) - const vendors = buildVendorRankings(models) - const { movers, droppers } = buildMovers(models) - const new_models = buildNewModels(period) - const models_history = buildModelsHistory(period, models) - const vendor_share_history = buildVendorShareHistory(models_history) - - const category_sections = RANKING_CATEGORIES.filter( - (c) => c.id !== 'all' - ).map((c) => buildCategorySection(period, c)) - - return { - models, - apps, - vendors, - top_movers: movers, - top_droppers: droppers, - new_models, - models_history, - vendor_share_history, - category_sections, - } -} diff --git a/web/default/src/features/rankings/types.ts b/web/default/src/features/rankings/types.ts index eb851c75..b68779af 100644 --- a/web/default/src/features/rankings/types.ts +++ b/web/default/src/features/rankings/types.ts @@ -2,11 +2,7 @@ // Rankings types // ---------------------------------------------------------------------------- // -// Shape of the data shown on the /rankings page. The backend has not yet -// implemented these analytics endpoints, so the helpers in -// `lib/mock-rankings.ts` produce deterministic mock values seeded from the -// (period, category) tuple. When the real APIs land, these types double as -// the response shape the UI expects. +// Shape of the real data shown on the /rankings page. export type RankingPeriod = 'today' | 'week' | 'month' | 'year' | 'all' @@ -24,13 +20,6 @@ export type RankingCategoryId = | 'productivity' | 'multimodal' -export type RankingCategory = { - id: RankingCategoryId - /** Default English label, fed through i18n at render time. */ - label: string - description: string -} - export type ModelRanking = { rank: number /** Previous rank in the same period; undefined means "new". */ @@ -47,37 +36,6 @@ export type ModelRanking = { growth_pct: number } -export type AppCategory = - | 'Coding' - | 'Chat' - | 'Productivity' - | 'Education' - | 'Creative' - | 'Roleplay' - | 'Translation' - | 'Marketing' - | 'Health' - | 'Finance' - | 'Research' - | 'Other' - -export type AppListing = { - rank: number - previous_rank?: number - name: string - description: string - category: AppCategory - url?: string - /** Total tokens this app sent through new-api in the period. */ - total_tokens: number - /** Period-over-period change. */ - growth_pct: number - /** Top model used by this app (model_name). */ - top_model: string - /** Logo letter / initial. */ - initial: string -} - export type VendorRanking = { rank: number vendor: string @@ -102,17 +60,6 @@ export type RankingMover = { growth_pct: number } -export type NewModelEntry = { - model_name: string - vendor: string - vendor_icon?: string - category: RankingCategoryId - release_date: string - total_tokens: number - /** % growth since the model launched. */ - growth_pct: number -} - /** * One sample of a model's token usage at a given timestamp. * Flat shape ready to feed VChart's stacked-bar spec. @@ -158,42 +105,16 @@ export type VendorShareSeries = { buckets: number } -/** - * Self-contained ranking unit for a single category. Pairs the small - * stacked-bar chart with the leaderboard data it summarises so - * `` can render both halves from one prop. Every - * category gets one of these rendered inline on the rankings page. - */ -export type CategorySection = { - category: RankingCategoryId - /** English source label, fed through i18n at render time. */ - label: string - /** English source description, fed through i18n at render time. */ - description: string - /** Top models in this category, ordered by total tokens desc. */ - models: ModelRanking[] - /** Stacked-bar history of token usage by model in this category. */ - models_history: ModelHistorySeries - /** Sum of all `models[].total_tokens` (cached for the section header). */ - total_tokens: number -} - export type RankingsSnapshot = { // Overall (all categories) ------------------------------------------------ models: ModelRanking[] - apps: AppListing[] vendors: VendorRanking[] /** Largest rank gainers in this period. */ top_movers: RankingMover[] /** Largest rank losers in this period. */ top_droppers: RankingMover[] - /** Newly launched / recently added models. */ - new_models: NewModelEntry[] /** Stacked-bar history of token usage by model over the period. */ models_history: ModelHistorySeries /** 100%-stacked area history of token share by vendor over the period. */ vendor_share_history: VendorShareSeries - // Per-category sections --------------------------------------------------- - /** Independent ranking sections, one per non-`all` category. */ - category_sections: CategorySection[] } diff --git a/web/default/src/features/system-settings/maintenance/config.ts b/web/default/src/features/system-settings/maintenance/config.ts index 27c0e4bd..62c00643 100644 --- a/web/default/src/features/system-settings/maintenance/config.ts +++ b/web/default/src/features/system-settings/maintenance/config.ts @@ -1,4 +1,4 @@ -export type HeaderNavPricingConfig = { +export type HeaderNavAccessConfig = { enabled: boolean requireAuth: boolean } @@ -6,10 +6,11 @@ export type HeaderNavPricingConfig = { export type HeaderNavModulesConfig = { home: boolean console: boolean - pricing: HeaderNavPricingConfig + pricing: HeaderNavAccessConfig + rankings: HeaderNavAccessConfig docs: boolean about: boolean - [key: string]: boolean | HeaderNavPricingConfig + [key: string]: boolean | HeaderNavAccessConfig } export type SidebarSectionConfig = { @@ -26,6 +27,10 @@ export const HEADER_NAV_DEFAULT: HeaderNavModulesConfig = { enabled: true, requireAuth: false, }, + rankings: { + enabled: true, + requireAuth: false, + }, docs: true, about: true, } @@ -74,8 +79,33 @@ const toBoolean = (value: unknown, fallback: boolean): boolean => { const cloneHeaderNavDefault = (): HeaderNavModulesConfig => ({ ...HEADER_NAV_DEFAULT, pricing: { ...HEADER_NAV_DEFAULT.pricing }, + rankings: { ...HEADER_NAV_DEFAULT.rankings }, }) +const parseAccessModule = ( + raw: unknown, + fallback: HeaderNavAccessConfig +): HeaderNavAccessConfig => { + if ( + typeof raw === 'boolean' || + typeof raw === 'string' || + typeof raw === 'number' + ) { + return { + enabled: toBoolean(raw, fallback.enabled), + requireAuth: fallback.requireAuth, + } + } + if (raw && typeof raw === 'object') { + const record = raw as Record + return { + enabled: toBoolean(record.enabled, fallback.enabled), + requireAuth: toBoolean(record.requireAuth, fallback.requireAuth), + } + } + return { ...fallback } +} + const cloneSidebarDefault = (): SidebarModulesAdminConfig => Object.entries(SIDEBAR_MODULES_DEFAULT).reduce( (acc, [section, config]) => { @@ -97,23 +127,16 @@ export function parseHeaderNavModules( const result: HeaderNavModulesConfig = { ...base, pricing: { ...base.pricing }, + rankings: { ...base.rankings }, } Object.entries(parsed).forEach(([key, raw]) => { if (key === 'pricing') { - if (raw && typeof raw === 'object') { - const rawPricing = raw as Record - result.pricing = { - enabled: toBoolean( - rawPricing.enabled, - base.pricing?.enabled ?? true - ), - requireAuth: toBoolean( - rawPricing.requireAuth, - base.pricing?.requireAuth ?? false - ), - } - } + result.pricing = parseAccessModule(raw, base.pricing) + return + } + if (key === 'rankings') { + result.rankings = parseAccessModule(raw, base.rankings) return } diff --git a/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx b/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx index 68d75074..584a55ad 100644 --- a/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx +++ b/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx @@ -27,6 +27,8 @@ const headerNavSchema = z.object({ console: z.boolean(), pricingEnabled: z.boolean(), pricingRequireAuth: z.boolean(), + rankingsEnabled: z.boolean(), + rankingsRequireAuth: z.boolean(), docs: z.boolean(), about: z.boolean(), }) @@ -53,6 +55,14 @@ const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({ config.pricing?.requireAuth === undefined ? HEADER_NAV_DEFAULT.pricing.requireAuth : Boolean(config.pricing.requireAuth), + rankingsEnabled: + config.rankings?.enabled === undefined + ? HEADER_NAV_DEFAULT.rankings.enabled + : Boolean(config.rankings.enabled), + rankingsRequireAuth: + config.rankings?.requireAuth === undefined + ? HEADER_NAV_DEFAULT.rankings.requireAuth + : Boolean(config.rankings.requireAuth), docs: config.docs === undefined ? HEADER_NAV_DEFAULT.docs : Boolean(config.docs), about: @@ -90,6 +100,11 @@ export function HeaderNavigationSection({ enabled: values.pricingEnabled, requireAuth: values.pricingRequireAuth, }, + rankings: { + ...(config.rankings ?? HEADER_NAV_DEFAULT.rankings), + enabled: values.rankingsEnabled, + requireAuth: values.rankingsRequireAuth, + }, } const serialized = serializeHeaderNavModules(payload) @@ -107,7 +122,7 @@ export function HeaderNavigationSection({ form.reset(toFormValues(HEADER_NAV_DEFAULT)) } - const modules: Array<{ + const simpleModules: Array<{ key: keyof HeaderNavFormValues title: string description: string @@ -134,6 +149,39 @@ export function HeaderNavigationSection({ }, ] + const accessModules: Array<{ + enabledKey: keyof HeaderNavFormValues + requireAuthKey: keyof HeaderNavFormValues + requireAuthDependsOn: 'pricingEnabled' | 'rankingsEnabled' + title: string + description: string + requireAuthTitle: string + requireAuthDescription: string + }> = [ + { + enabledKey: 'pricingEnabled', + requireAuthKey: 'pricingRequireAuth', + requireAuthDependsOn: 'pricingEnabled', + title: t('Model Square'), + description: t('Public model catalog and pricing page.'), + requireAuthTitle: t('Require login to view models'), + requireAuthDescription: t( + 'Visitors must authenticate before accessing the pricing directory.' + ), + }, + { + enabledKey: 'rankingsEnabled', + requireAuthKey: 'rankingsRequireAuth', + requireAuthDependsOn: 'rankingsEnabled', + title: t('Rankings'), + description: t('Public rankings page based on live usage data.'), + requireAuthTitle: t('Require login to view rankings'), + requireAuthDescription: t( + 'Visitors must authenticate before accessing the rankings page.' + ), + }, + ] + return (
    - {modules.map((module) => ( + {simpleModules.map((module) => ( -
    - ( - -
    - - {t('Models directory')} - - - {t( - 'Exposes the pricing/models catalog in the top navigation.' - )} - -
    - - - - -
    - )} - /> +
    + {accessModules.map((module) => ( +
    + ( + +
    + + {module.title} + + {module.description} +
    + + + + +
    + )} + /> - ( - -
    - - {t('Require login to view models')} - - - {t( - 'Visitors must authenticate before accessing the pricing directory.' - )} - -
    - - - - -
    - )} - /> + ( + +
    + + {module.requireAuthTitle} + + + {module.requireAuthDescription} + +
    + + + + +
    + )} + /> +
    + ))}
    diff --git a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx index 419a5eca..37b0d0f1 100644 --- a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx +++ b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx @@ -269,11 +269,6 @@ function getModeBadgeVariant( return 'outline' } -function truncateExpr(value: string) { - if (!value) return '' - return value.length > 110 ? `${value.slice(0, 110)}...` : value -} - function buildPreviewRows( values: ModelPricingFormValues, mode: PricingMode, diff --git a/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx b/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx index bac0ef5b..e04643cd 100644 --- a/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx +++ b/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx @@ -2,9 +2,16 @@ import { useState, useEffect, useCallback } from 'react' import { useQueryClient, useIsFetching } from '@tanstack/react-query' import { useNavigate, getRouteApi } from '@tanstack/react-router' import { type Table } from '@tanstack/react-table' +import { Eye, EyeOff } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useIsAdmin } from '@/hooks/use-admin' +import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' import { Select, SelectContent, @@ -17,6 +24,7 @@ import { LOG_TYPES } from '../constants' import { buildSearchParams } from '../lib/filter' import { getDefaultTimeRange } from '../lib/utils' import type { CommonLogFilters } from '../types' +import { CommonLogsStats } from './common-logs-stats' import { CompactDateTimeRangePicker } from './compact-date-time-range-picker' import { useUsageLogsContext } from './usage-logs-provider' @@ -41,7 +49,7 @@ export function CommonLogsFilterBar( const queryClient = useQueryClient() const searchParams = route.useSearch() const isAdmin = useIsAdmin() - const { sensitiveVisible } = useUsageLogsContext() + const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext() const fetchingLogs = useIsFetching({ queryKey: ['logs'] }) const [filters, setFilters] = useState(() => { @@ -142,9 +150,34 @@ export function CommonLogsFilterBar( const inputClass = 'w-full sm:w-[140px] lg:w-[160px]' const sensitiveType = sensitiveVisible ? 'text' : 'password' + const statsBar = ( +
    + + + setSensitiveVisible(!sensitiveVisible)} + aria-label={sensitiveVisible ? t('Hide') : t('Show')} + className='text-muted-foreground hover:text-foreground size-7' + /> + } + > + {sensitiveVisible ? : } + + + {sensitiveVisible ? t('Hide') : t('Show')} + + +
    + ) + return ( {t(pageMeta.descriptionKey)} - {activeCategory === 'common' && ( - - - - )}
    {showTaskSwitcher && ( diff --git a/web/default/src/hooks/use-top-nav-links.ts b/web/default/src/hooks/use-top-nav-links.ts index 004e5944..48365e26 100644 --- a/web/default/src/hooks/use-top-nav-links.ts +++ b/web/default/src/hooks/use-top-nav-links.ts @@ -20,6 +20,59 @@ const DEFAULT_HEADER_NAV_MODULES = { about: true, } +function parseAccessModule( + raw: unknown, + fallback: { enabled: boolean; requireAuth: boolean } +) { + if ( + typeof raw === 'boolean' || + typeof raw === 'string' || + typeof raw === 'number' + ) { + return { + enabled: raw === true || raw === 'true' || raw === '1' || raw === 1, + requireAuth: fallback.requireAuth, + } + } + if (raw && typeof raw === 'object') { + const record = raw as Record + return { + enabled: + typeof record.enabled === 'boolean' ? record.enabled : fallback.enabled, + requireAuth: + typeof record.requireAuth === 'boolean' + ? record.requireAuth + : fallback.requireAuth, + } + } + return { ...fallback } +} + +function parseHeaderNavModules( + raw: unknown +): typeof DEFAULT_HEADER_NAV_MODULES { + if (!raw || String(raw).trim() === '') { + return DEFAULT_HEADER_NAV_MODULES + } + try { + const parsed = JSON.parse(String(raw)) as Record + return { + ...DEFAULT_HEADER_NAV_MODULES, + ...parsed, + pricing: parseAccessModule( + parsed.pricing, + DEFAULT_HEADER_NAV_MODULES.pricing + ), + rankings: parseAccessModule( + parsed.rankings, + DEFAULT_HEADER_NAV_MODULES.rankings + ), + } + } catch { + return DEFAULT_HEADER_NAV_MODULES + } +} + /** * Generate top navigation links based on HeaderNavModules configuration from backend /api/status * Backend format example (stringified JSON): @@ -27,6 +80,7 @@ const DEFAULT_HEADER_NAV_MODULES = { * home: true, * console: true, * pricing: { enabled: true, requireAuth: false }, + * rankings: { enabled: true, requireAuth: false }, * docs: true, * about: true * } @@ -38,17 +92,7 @@ export function useTopNavLinks(): TopNavLink[] { // Parse HeaderNavModules const modules = useMemo(() => { - const raw = status?.HeaderNavModules - // If empty string, null, or undefined, use default config - if (!raw || (raw as string).trim() === '') { - return DEFAULT_HEADER_NAV_MODULES - } - try { - return JSON.parse(raw as string) - } catch { - // Parse failed, use default config - return DEFAULT_HEADER_NAV_MODULES - } + return parseHeaderNavModules(status?.HeaderNavModules) }, [status?.HeaderNavModules]) // Documentation link (may be external) diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 2528b1ac..09bad028 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -2943,6 +2943,8 @@ "Provide Markdown, HTML, or an external URL for the user agreement": "Provide Markdown, HTML, or an external URL for the user agreement", "Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Provide per-category safety overrides as JSON. Use `default` for fallback values.", "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.", + "Public model catalog and pricing page.": "Public model catalog and pricing page.", + "Public rankings page based on live usage data.": "Public rankings page based on live usage data.", "Provider": "Provider", "Provider & data privacy": "Provider & data privacy", "Provider created successfully": "Provider created successfully", @@ -3157,6 +3159,7 @@ "Require email verification for new accounts": "Require email verification for new accounts", "Require job success before follow-up actions": "Require job success before follow-up actions", "Require login to view models": "Require login to view models", + "Require login to view rankings": "Require login to view rankings", "required": "required", "Required": "Required", "Required events:": "Required events:", @@ -4125,6 +4128,7 @@ "Vision, image / video, document chat": "Vision, image / video, document chat", "Visit Settings → General and adjust quota options...": "Visit Settings → General and adjust quota options...", "Visitors must authenticate before accessing the pricing directory.": "Visitors must authenticate before accessing the pricing directory.", + "Visitors must authenticate before accessing the rankings page.": "Visitors must authenticate before accessing the rankings page.", "Visual": "Visual", "Visual edit": "Visual edit", "Visual editor": "Visual editor", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 0afff4bd..2bfe53fe 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -2943,6 +2943,8 @@ "Provide Markdown, HTML, or an external URL for the user agreement": "Fournir du Markdown, du HTML ou une URL externe pour l'accord utilisateur", "Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Fournir des remplacements de sécurité par catégorie au format JSON. Utilisez `default` pour les valeurs de secours.", "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Fournir des remplacements d'en-tête par modèle au format JSON. Utile pour activer des fonctionnalités bêta telles que les fenêtres de contexte étendues.", + "Public model catalog and pricing page.": "Page publique du catalogue des modèles et des tarifs.", + "Public rankings page based on live usage data.": "Page publique des classements basée sur les données d'utilisation réelles.", "Provider": "Fournisseur", "Provider & data privacy": "Fournisseur & confidentialité", "Provider created successfully": "Fournisseur créé avec succès", @@ -3157,6 +3159,7 @@ "Require email verification for new accounts": "Exiger la vérification de l'e-mail pour les nouveaux comptes", "Require job success before follow-up actions": "Exiger le succès de la tâche avant les actions de suivi", "Require login to view models": "Exiger la connexion pour voir les modèles", + "Require login to view rankings": "Exiger la connexion pour voir les classements", "required": "requis", "Required": "Requis", "Required events:": "Événements requis :", @@ -4125,6 +4128,7 @@ "Vision, image / video, document chat": "Vision, image / vidéo, conversation sur document", "Visit Settings → General and adjust quota options...": "Visitez Paramètres → Général et ajustez les options de quota...", "Visitors must authenticate before accessing the pricing directory.": "Les visiteurs doivent s'authentifier avant d'accéder au répertoire des prix.", + "Visitors must authenticate before accessing the rankings page.": "Les visiteurs doivent s'authentifier avant d'accéder à la page des classements.", "Visual": "Visuel", "Visual edit": "Édition visuelle", "Visual editor": "Éditeur visuel", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 8e19cb4c..2decc831 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -2943,6 +2943,8 @@ "Provide Markdown, HTML, or an external URL for the user agreement": "ユーザー同意書にMarkdown、HTML、または外部URLを提供する", "Provide per-category safety overrides as JSON. Use `default` for fallback values.": "カテゴリごとの安全オーバーライドをJSONとして提供します。フォールバック値には`default`を使用してください。", "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "モデルごとのヘッダーオーバーライドをJSONとして提供します。拡張コンテキストウィンドウなどのベータ機能を有効にするのに役立ちます。", + "Public model catalog and pricing page.": "モデルカタログと料金の公開ページ。", + "Public rankings page based on live usage data.": "実際の利用データに基づく公開ランキングページ。", "Provider": "プロバイダ", "Provider & data privacy": "プロバイダーとデータ保護", "Provider created successfully": "プロバイダーの作成に成功しました", @@ -3157,6 +3159,7 @@ "Require email verification for new accounts": "新しいアカウントにメール認証を要求する", "Require job success before follow-up actions": "フォローアップ アクション前にジョブの成功を要求", "Require login to view models": "モデルを表示するにはログインを要求する", + "Require login to view rankings": "ランキングを表示するにはログインを要求する", "required": "必須", "Required": "必須", "Required events:": "必須イベント:", @@ -4125,6 +4128,7 @@ "Vision, image / video, document chat": "ビジョン・画像/動画・ドキュメントチャット", "Visit Settings → General and adjust quota options...": "「設定」→「一般」にアクセスして、クォータオプションを調整してください...", "Visitors must authenticate before accessing the pricing directory.": "訪問者は料金ディレクトリにアクセスする前に認証を行う必要があります。", + "Visitors must authenticate before accessing the rankings page.": "訪問者はランキングページにアクセスする前に認証を行う必要があります。", "Visual": "ビジュアル", "Visual edit": "ビジュアル編集", "Visual editor": "ビジュアルエディター", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 28a4db2a..24b80199 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -2943,6 +2943,8 @@ "Provide Markdown, HTML, or an external URL for the user agreement": "Укажите Markdown, HTML или внешний URL для пользовательского соглашения", "Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Предоставьте переопределения безопасности по категориям в формате JSON. Используйте `default` для резервных значений.", "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Предоставьте переопределения заголовков для каждой модели в формате JSON. Полезно для включения бета-функций, таких как расширенные окна контекста.", + "Public model catalog and pricing page.": "Публичная страница каталога моделей и цен.", + "Public rankings page based on live usage data.": "Публичная страница рейтингов на основе реальных данных использования.", "Provider": "Провайдер", "Provider & data privacy": "Поставщик и конфиденциальность", "Provider created successfully": "Поставщик успешно создан", @@ -3157,6 +3159,7 @@ "Require email verification for new accounts": "Требовать подтверждение электронной почты для новых учетных записей", "Require job success before follow-up actions": "Требовать успеха задания перед последующими действиями", "Require login to view models": "Требовать вход для просмотра моделей", + "Require login to view rankings": "Требовать вход для просмотра рейтингов", "required": "обязателен", "Required": "Обязательно", "Required events:": "Обязательные события:", @@ -4125,6 +4128,7 @@ "Vision, image / video, document chat": "Зрение, изображения / видео, чат по документам", "Visit Settings → General and adjust quota options...": "Перейдите в Настройки → Общие и настройте параметры квоты...", "Visitors must authenticate before accessing the pricing directory.": "Посетители должны пройти аутентификацию перед доступом к каталогу цен.", + "Visitors must authenticate before accessing the rankings page.": "Посетители должны пройти аутентификацию перед доступом к странице рейтингов.", "Visual": "Визуальный", "Visual edit": "Визуальное редактирование", "Visual editor": "Визуальный редактор", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 08786162..dfb1732c 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -2943,6 +2943,8 @@ "Provide Markdown, HTML, or an external URL for the user agreement": "Cung cấp Markdown, HTML, hoặc một URL bên ngoài cho thỏa thuận người dùng", "Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Cung cấp các ghi đè an toàn theo từng danh mục dưới dạng JSON. Sử dụng `default` cho các giá trị dự phòng.", "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Cung cấp các ghi đè tiêu đề theo từng mô hình dưới dạng JSON. Hữu ích để bật các tính năng beta như cửa sổ ngữ cảnh mở rộng.", + "Public model catalog and pricing page.": "Trang công khai cho danh mục mô hình và giá.", + "Public rankings page based on live usage data.": "Trang bảng xếp hạng công khai dựa trên dữ liệu sử dụng thực.", "Provider": "Nhà cung cấp", "Provider & data privacy": "Nhà cung cấp & quyền riêng tư", "Provider created successfully": "Đã tạo nhà cung cấp thành công", @@ -3157,6 +3159,7 @@ "Require email verification for new accounts": "Yêu cầu xác minh email cho tài khoản mới", "Require job success before follow-up actions": "Yêu cầu công việc thành công trước các hành động tiếp theo", "Require login to view models": "Yêu cầu đăng nhập để xem các mô hình", + "Require login to view rankings": "Yêu cầu đăng nhập để xem bảng xếp hạng", "required": "bắt buộc", "Required": "Bắt buộc", "Required events:": "Sự kiện bắt buộc:", @@ -4125,6 +4128,7 @@ "Vision, image / video, document chat": "Thị giác, ảnh / video, hỏi đáp tài liệu", "Visit Settings → General and adjust quota options...": "Truy cập Cài đặt → Chung và điều chỉnh tùy chọn hạn mức...", "Visitors must authenticate before accessing the pricing directory.": "Khách truy cập phải xác thực trước khi truy cập thư mục giá.", + "Visitors must authenticate before accessing the rankings page.": "Khách truy cập phải xác thực trước khi truy cập trang bảng xếp hạng.", "Visual": "Trực quan", "Visual edit": "Chỉnh sửa trực quan", "Visual editor": "Trình sửa trực quan", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index bb7a7626..4306ef5e 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -2943,6 +2943,8 @@ "Provide Markdown, HTML, or an external URL for the user agreement": "提供 Markdown、HTML 或外部 URL 作为用户协议", "Provide per-category safety overrides as JSON. Use `default` for fallback values.": "以 JSON 格式提供按类别划分的安全覆盖。使用 `default` 作为回退值。", "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "以 JSON 格式提供按模型划分的标头覆盖。可用于启用测试功能,例如扩展上下文窗口。", + "Public model catalog and pricing page.": "公开模型目录和价格页面。", + "Public rankings page based on live usage data.": "基于真实用量数据的公开排行榜页面。", "Provider": "提供商", "Provider & data privacy": "厂商与数据隐私", "Provider created successfully": "提供商创建成功", @@ -3157,6 +3159,7 @@ "Require email verification for new accounts": "要求新账户验证邮箱", "Require job success before follow-up actions": "在后续操作前要求任务成功", "Require login to view models": "要求登录才能查看模型", + "Require login to view rankings": "要求登录才能查看排行榜", "required": "必填", "Required": "必需", "Required events:": "必需事件:", @@ -4125,6 +4128,7 @@ "Vision, image / video, document chat": "视觉理解、图像 / 视频、文档对话", "Visit Settings → General and adjust quota options...": "访问设置 → 通用并调整配额选项...", "Visitors must authenticate before accessing the pricing directory.": "访客必须先进行身份验证才能访问定价目录。", + "Visitors must authenticate before accessing the rankings page.": "访客必须先进行身份验证才能访问排行榜页面。", "Visual": "可视", "Visual edit": "可视化编辑", "Visual editor": "可视化编辑器", diff --git a/web/default/src/i18n/static-keys.ts b/web/default/src/i18n/static-keys.ts index 49172d73..026f6a75 100644 --- a/web/default/src/i18n/static-keys.ts +++ b/web/default/src/i18n/static-keys.ts @@ -4,7 +4,8 @@ export const STATIC_I18N_KEYS = [ // Header navigation 'Home', 'Console', - 'Pricing', + 'Model Square', + 'Rankings', 'Docs', 'About',