Просмотр исходного кода

feat(default): add real rankings data

CaIon 1 неделя назад
Родитель
Сommit
f8cf9c57c4
41 измененных файлов с 1498 добавлено и 1912 удалено
  1. 24 0
      controller/rankings.go
  2. 1 1
      controller/relay.go
  3. 9 5
      model/perf_metric.go
  4. 66 0
      model/usedata_rankings.go
  5. 4 0
      pkg/perf_metrics/flush.go
  6. 38 13
      pkg/perf_metrics/metrics.go
  7. 28 12
      pkg/perf_metrics/types.go
  8. 1 0
      router/api-router.go
  9. 1 1
      service/quota.go
  10. 599 0
      service/rankings.go
  11. 1 1
      service/text_quota.go
  12. 41 0
      web/default/src/components/data-table/toolbar.tsx
  13. 2 6
      web/default/src/features/pricing/api.ts
  14. 24 11
      web/default/src/features/pricing/components/model-details-charts.tsx
  15. 92 97
      web/default/src/features/pricing/components/model-details-performance.tsx
  16. 222 133
      web/default/src/features/pricing/components/model-details.tsx
  17. 15 0
      web/default/src/features/rankings/api.ts
  18. 0 97
      web/default/src/features/rankings/components/apps-section.tsx
  19. 0 205
      web/default/src/features/rankings/components/category-section.tsx
  20. 0 2
      web/default/src/features/rankings/components/index.ts
  21. 5 8
      web/default/src/features/rankings/components/market-share-section.tsx
  22. 3 49
      web/default/src/features/rankings/components/pulse-section.tsx
  23. 2 4
      web/default/src/features/rankings/components/rankings-hero.tsx
  24. 9 13
      web/default/src/features/rankings/hooks/use-rankings.ts
  25. 55 30
      web/default/src/features/rankings/index.tsx
  26. 0 1
      web/default/src/features/rankings/lib/index.ts
  27. 0 1048
      web/default/src/features/rankings/lib/mock-rankings.ts
  28. 1 80
      web/default/src/features/rankings/types.ts
  29. 39 16
      web/default/src/features/system-settings/maintenance/config.ts
  30. 101 55
      web/default/src/features/system-settings/maintenance/header-navigation-section.tsx
  31. 0 5
      web/default/src/features/system-settings/models/model-pricing-sheet.tsx
  32. 34 1
      web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx
  33. 0 6
      web/default/src/features/usage-logs/index.tsx
  34. 55 11
      web/default/src/hooks/use-top-nav-links.ts
  35. 4 0
      web/default/src/i18n/locales/en.json
  36. 4 0
      web/default/src/i18n/locales/fr.json
  37. 4 0
      web/default/src/i18n/locales/ja.json
  38. 4 0
      web/default/src/i18n/locales/ru.json
  39. 4 0
      web/default/src/i18n/locales/vi.json
  40. 4 0
      web/default/src/i18n/locales/zh.json
  41. 2 1
      web/default/src/i18n/static-keys.ts

+ 24 - 0
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,
+	})
+}

+ 1 - 1
controller/relay.go

@@ -242,7 +242,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
 	}
 	}
 	if newAPIError != nil {
 	if newAPIError != nil {
 		gopool.Go(func() {
 		gopool.Go(func() {
-			perfmetrics.RecordRelaySample(relayInfo, false)
+			perfmetrics.RecordRelaySample(relayInfo, false, 0)
 		})
 		})
 	}
 	}
 }
 }

+ 9 - 5
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"`
 	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"`
 	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"`
 	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 {
 func (PerfMetric) TableName() string {
@@ -40,6 +42,8 @@ func UpsertPerfMetric(metric *PerfMetric) error {
 			"total_latency_ms": gorm.Expr("total_latency_ms + ?", metric.TotalLatencyMs),
 			"total_latency_ms": gorm.Expr("total_latency_ms + ?", metric.TotalLatencyMs),
 			"ttft_sum_ms":      gorm.Expr("ttft_sum_ms + ?", metric.TtftSumMs),
 			"ttft_sum_ms":      gorm.Expr("ttft_sum_ms + ?", metric.TtftSumMs),
 			"ttft_count":       gorm.Expr("ttft_count + ?", metric.TtftCount),
 			"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
 	}).Create(metric).Error
 }
 }

+ 66 - 0
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
+}

+ 4 - 0
pkg/perf_metrics/flush.go

@@ -47,6 +47,8 @@ func flushCompletedBuckets() {
 			TotalLatencyMs: drained.totalLatencyMs,
 			TotalLatencyMs: drained.totalLatencyMs,
 			TtftSumMs:      drained.ttftSumMs,
 			TtftSumMs:      drained.ttftSumMs,
 			TtftCount:      drained.ttftCount,
 			TtftCount:      drained.ttftCount,
+			OutputTokens:   drained.outputTokens,
+			GenerationMs:   drained.generationMs,
 		})
 		})
 		if err != nil {
 		if err != nil {
 			bucket.addCounters(drained)
 			bucket.addCounters(drained)
@@ -82,6 +84,8 @@ func redisCounters(values map[string]string) counters {
 		totalLatencyMs: parseRedisInt(values["lat"]),
 		totalLatencyMs: parseRedisInt(values["lat"]),
 		ttftSumMs:      parseRedisInt(values["ttft"]),
 		ttftSumMs:      parseRedisInt(values["ttft"]),
 		ttftCount:      parseRedisInt(values["ttft_n"]),
 		ttftCount:      parseRedisInt(values["ttft_n"]),
+		outputTokens:   parseRedisInt(values["out"]),
+		generationMs:   parseRedisInt(values["gen_ms"]),
 	}
 	}
 }
 }
 
 

+ 38 - 13
pkg/perf_metrics/metrics.go

@@ -15,13 +15,15 @@ import (
 
 
 var hotBuckets sync.Map
 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"
 const seriesSchema = "dbcd0a3c01b55203"
 
 
 func Init() {
 func Init() {
 	go flushLoop()
 	go flushLoop()
 }
 }
 
 
-func RecordRelaySample(info *relaycommon.RelayInfo, success bool) {
+func RecordRelaySample(info *relaycommon.RelayInfo, success bool, outputTokens int64) {
 	if info == nil {
 	if info == nil {
 		return
 		return
 	}
 	}
@@ -31,13 +33,23 @@ func RecordRelaySample(info *relaycommon.RelayInfo, success bool) {
 	if hasTtft {
 	if hasTtft {
 		ttftMs = info.FirstResponseTime.Sub(info.StartTime).Milliseconds()
 		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{
 	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,
 			totalLatencyMs: row.TotalLatencyMs,
 			ttftSumMs:      row.TtftSumMs,
 			ttftSumMs:      row.TtftSumMs,
 			ttftCount:      row.TtftCount,
 			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.totalLatencyMs += value.totalLatencyMs
 	current.ttftSumMs += value.ttftSumMs
 	current.ttftSumMs += value.ttftSumMs
 	current.ttftCount += value.ttftCount
 	current.ttftCount += value.ttftCount
+	current.outputTokens += value.outputTokens
+	current.generationMs += value.generationMs
 	merged[key] = current
 	merged[key] = current
 }
 }
 
 
@@ -166,6 +182,8 @@ func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResu
 			total.totalLatencyMs += value.totalLatencyMs
 			total.totalLatencyMs += value.totalLatencyMs
 			total.ttftSumMs += value.ttftSumMs
 			total.ttftSumMs += value.ttftSumMs
 			total.ttftCount += value.ttftCount
 			total.ttftCount += value.ttftCount
+			total.outputTokens += value.outputTokens
+			total.generationMs += value.generationMs
 			series = append(series, bucketPoint(ts, value))
 			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),
 			AvgTtftMs:    avg(total.ttftSumMs, total.ttftCount),
 			AvgLatencyMs: avg(total.totalLatencyMs, total.requestCount),
 			AvgLatencyMs: avg(total.totalLatencyMs, total.requestCount),
 			SuccessRate:  successRate(total),
 			SuccessRate:  successRate(total),
-			RequestCount: total.requestCount,
-			SuccessCount: total.successCount,
-			TtftCount:    total.ttftCount,
+			AvgTps:       avgTps(total),
 			Series:       series,
 			Series:       series,
 		})
 		})
 	}
 	}
@@ -194,9 +210,7 @@ func bucketPoint(ts int64, value counters) BucketPoint {
 		AvgTtftMs:    avg(value.ttftSumMs, value.ttftCount),
 		AvgTtftMs:    avg(value.ttftSumMs, value.ttftCount),
 		AvgLatencyMs: avg(value.totalLatencyMs, value.requestCount),
 		AvgLatencyMs: avg(value.totalLatencyMs, value.requestCount),
 		SuccessRate:  successRate(value),
 		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
 	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) {
 func recordRedis(key bucketKey, sample Sample) {
 	if !common.RedisEnabled || common.RDB == nil {
 	if !common.RedisEnabled || common.RDB == nil {
 		return
 		return
@@ -234,6 +255,10 @@ func recordRedis(key bucketKey, sample Sample) {
 		pipe.HIncrBy(ctx, redisKey, "ttft", sample.TtftMs)
 		pipe.HIncrBy(ctx, redisKey, "ttft", sample.TtftMs)
 		pipe.HIncrBy(ctx, redisKey, "ttft_n", 1)
 		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.Expire(ctx, redisKey, time.Hour)
 	_, _ = pipe.Exec(ctx)
 	_, _ = pipe.Exec(ctx)
 }
 }

+ 28 - 12
pkg/perf_metrics/types.go

@@ -8,12 +8,14 @@ type Store interface {
 }
 }
 
 
 type Sample struct {
 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 {
 type QueryParams struct {
@@ -27,9 +29,7 @@ type BucketPoint struct {
 	AvgTtftMs    int64   `json:"avg_ttft_ms"`
 	AvgTtftMs    int64   `json:"avg_ttft_ms"`
 	AvgLatencyMs int64   `json:"avg_latency_ms"`
 	AvgLatencyMs int64   `json:"avg_latency_ms"`
 	SuccessRate  float64 `json:"success_rate"`
 	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 {
 type GroupResult struct {
@@ -37,9 +37,7 @@ type GroupResult struct {
 	AvgTtftMs    int64         `json:"avg_ttft_ms"`
 	AvgTtftMs    int64         `json:"avg_ttft_ms"`
 	AvgLatencyMs int64         `json:"avg_latency_ms"`
 	AvgLatencyMs int64         `json:"avg_latency_ms"`
 	SuccessRate  float64       `json:"success_rate"`
 	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"`
 	Series       []BucketPoint `json:"series"`
 }
 }
 
 
@@ -61,6 +59,8 @@ type counters struct {
 	totalLatencyMs int64
 	totalLatencyMs int64
 	ttftSumMs      int64
 	ttftSumMs      int64
 	ttftCount      int64
 	ttftCount      int64
+	outputTokens   int64
+	generationMs   int64
 }
 }
 
 
 type atomicBucket struct {
 type atomicBucket struct {
@@ -69,6 +69,8 @@ type atomicBucket struct {
 	totalLatencyMs atomic.Int64
 	totalLatencyMs atomic.Int64
 	ttftSumMs      atomic.Int64
 	ttftSumMs      atomic.Int64
 	ttftCount      atomic.Int64
 	ttftCount      atomic.Int64
+	outputTokens   atomic.Int64
+	generationMs   atomic.Int64
 }
 }
 
 
 func (b *atomicBucket) add(sample Sample) {
 func (b *atomicBucket) add(sample Sample) {
@@ -83,6 +85,10 @@ func (b *atomicBucket) add(sample Sample) {
 		b.ttftSumMs.Add(sample.TtftMs)
 		b.ttftSumMs.Add(sample.TtftMs)
 		b.ttftCount.Add(1)
 		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 {
 func (b *atomicBucket) snapshot() counters {
@@ -92,6 +98,8 @@ func (b *atomicBucket) snapshot() counters {
 		totalLatencyMs: b.totalLatencyMs.Load(),
 		totalLatencyMs: b.totalLatencyMs.Load(),
 		ttftSumMs:      b.ttftSumMs.Load(),
 		ttftSumMs:      b.ttftSumMs.Load(),
 		ttftCount:      b.ttftCount.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),
 		totalLatencyMs: b.totalLatencyMs.Swap(0),
 		ttftSumMs:      b.ttftSumMs.Swap(0),
 		ttftSumMs:      b.ttftSumMs.Swap(0),
 		ttftCount:      b.ttftCount.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 {
 	if c.ttftCount != 0 {
 		b.ttftCount.Add(c.ttftCount)
 		b.ttftCount.Add(c.ttftCount)
 	}
 	}
+	if c.outputTokens != 0 {
+		b.outputTokens.Add(c.outputTokens)
+	}
+	if c.generationMs != 0 {
+		b.generationMs.Add(c.generationMs)
+	}
 }
 }

+ 1 - 0
router/api-router.go

@@ -32,6 +32,7 @@ func SetApiRouter(router *gin.Engine) {
 		apiRouter.GET("/home_page_content", controller.GetHomePageContent)
 		apiRouter.GET("/home_page_content", controller.GetHomePageContent)
 		apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing)
 		apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing)
 		apiRouter.GET("/perf-metrics", middleware.TryUserAuth(), controller.GetPerfMetrics)
 		apiRouter.GET("/perf-metrics", middleware.TryUserAuth(), controller.GetPerfMetrics)
+		apiRouter.GET("/rankings", controller.GetRankings)
 		apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
 		apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
 		apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
 		apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
 		apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
 		apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)

+ 1 - 1
service/quota.go

@@ -377,7 +377,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
 		Other:            other,
 		Other:            other,
 	})
 	})
 	gopool.Go(func() {
 	gopool.Go(func() {
-		perfmetrics.RecordRelaySample(relayInfo, true)
+		perfmetrics.RecordRelaySample(relayInfo, true, int64(usage.CompletionTokens))
 	})
 	})
 }
 }
 
 

+ 599 - 0
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
+}

+ 1 - 1
service/text_quota.go

@@ -474,6 +474,6 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
 		Other:            other,
 		Other:            other,
 	})
 	})
 	gopool.Go(func() {
 	gopool.Go(func() {
-		perfmetrics.RecordRelaySample(relayInfo, true)
+		perfmetrics.RecordRelaySample(relayInfo, true, int64(summary.CompletionTokens))
 	})
 	})
 }
 }

+ 41 - 0
web/default/src/components/data-table/toolbar.tsx

@@ -87,6 +87,14 @@ export type DataTableToolbarProps<TData> = {
    * Hide the View Options (column visibility) dropdown.
    * Hide the View Options (column visibility) dropdown.
    */
    */
   hideViewOptions?: boolean
   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.
    * Outer wrapper className override.
    */
    */
@@ -216,6 +224,39 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
     </Button>
     </Button>
   ) : null
   ) : null
 
 
+  const hasLeftActions = props.leftActions != null
+
+  if (hasLeftActions) {
+    return (
+      <div className={cn('flex flex-col gap-2', props.className)}>
+        <div className='flex flex-wrap items-center gap-2 sm:gap-3'>
+          {props.customSearch !== undefined ? props.customSearch : searchInput}
+          {props.additionalSearch}
+          {filterChips}
+          <div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
+            {expandToggle}
+          </div>
+        </div>
+
+        {expanded && hasExpandable && (
+          <div className='flex flex-wrap items-center gap-2 sm:gap-3'>
+            {props.expandable}
+          </div>
+        )}
+
+        <div className='flex flex-wrap items-center gap-2 sm:gap-3'>
+          {props.leftActions}
+          <div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
+            {props.preActions}
+            {resetButton}
+            {searchButton}
+            {viewOptionsNode}
+          </div>
+        </div>
+      </div>
+    )
+  }
+
   return (
   return (
     <div
     <div
       className={cn(
       className={cn(

+ 2 - 6
web/default/src/features/pricing/api.ts

@@ -16,9 +16,7 @@ export type PerformanceSeriesPoint = {
   avg_ttft_ms: number
   avg_ttft_ms: number
   avg_latency_ms: number
   avg_latency_ms: number
   success_rate: number
   success_rate: number
-  count: number
-  success_count: number
-  ttft_count: number
+  avg_tps: number
 }
 }
 
 
 export type PerformanceGroup = {
 export type PerformanceGroup = {
@@ -26,9 +24,7 @@ export type PerformanceGroup = {
   avg_ttft_ms: number
   avg_ttft_ms: number
   avg_latency_ms: number
   avg_latency_ms: number
   success_rate: number
   success_rate: number
-  request_count: number
-  success_count: number
-  ttft_count: number
+  avg_tps: number
   series: PerformanceSeriesPoint[]
   series: PerformanceSeriesPoint[]
 }
 }
 
 

+ 24 - 11
web/default/src/features/pricing/components/model-details-charts.tsx

@@ -28,7 +28,7 @@ function formatDayLabel(date: string): string {
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
-// Latency trend chart (24h, multi-group line chart)
+// Latency trend chart (24h, multi-group point-line chart)
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
 export function LatencyTrendChart(props: {
 export function LatencyTrendChart(props: {
@@ -52,14 +52,20 @@ export function LatencyTrendChart(props: {
       yField: 'ttft',
       yField: 'ttft',
       seriesField: 'group',
       seriesField: 'group',
       smooth: true,
       smooth: true,
-      point: { visible: false },
-      legends: { visible: true, orient: 'top', position: 'start' },
+      point: {
+        visible: true,
+        style: { size: 5, stroke: '#ffffff', lineWidth: 1.5 },
+      },
+      line: {
+        style: { lineWidth: 2 },
+      },
+      legends: { visible: false },
       tooltip: {
       tooltip: {
         mark: {
         mark: {
           title: { value: (d: { time: string }) => d.time },
           title: { value: (d: { time: string }) => d.time },
           content: [
           content: [
             {
             {
-              key: (d: { group: string }) => d.group,
+              key: t('Average TTFT'),
               value: (d: { ttft: number }) => `${Math.round(d.ttft)} ms`,
               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) {
   if (props.series.length === 0) {
     return (
     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[]
   series: UptimeDayPoint[]
   className?: string
   className?: string
 }) {
 }) {
@@ -137,18 +143,25 @@ export function UptimeBarChart(props: {
     }))
     }))
 
 
     return {
     return {
-      type: 'bar' as const,
+      type: 'line' as const,
       data: [{ id: 'uptime', values: data }],
       data: [{ id: 'uptime', values: data }],
       xField: 'date',
       xField: 'date',
       yField: 'uptime',
       yField: 'uptime',
-      bar: {
+      smooth: true,
+      line: {
+        style: { stroke: '#10b981', lineWidth: 2 },
+      },
+      point: {
+        visible: true,
         style: {
         style: {
+          size: 5,
+          stroke: '#ffffff',
+          lineWidth: 1.5,
           fill: (datum: { uptime: number }) => {
           fill: (datum: { uptime: number }) => {
             if (datum.uptime >= 99.9) return '#10b981'
             if (datum.uptime >= 99.9) return '#10b981'
             if (datum.uptime >= 99.0) return '#f59e0b'
             if (datum.uptime >= 99.0) return '#f59e0b'
             return '#ef4444'
             return '#ef4444'
           },
           },
-          cornerRadius: 2,
         },
         },
       },
       },
       tooltip: {
       tooltip: {
@@ -210,7 +223,7 @@ export function UptimeBarChart(props: {
     <div className={cn('h-56 sm:h-64', props.className)}>
     <div className={cn('h-56 sm:h-64', props.className)}>
       {themeReady && spec && (
       {themeReady && spec && (
         <VChart
         <VChart
-          key={`uptime-${resolvedTheme}`}
+          key={`uptime-trend-${resolvedTheme}`}
           spec={{
           spec={{
             ...spec,
             ...spec,
             theme: resolvedTheme === 'dark' ? 'dark' : 'light',
             theme: resolvedTheme === 'dark' ? 'dark' : 'light',

+ 92 - 97
web/default/src/features/pricing/components/model-details-performance.tsx

@@ -1,12 +1,6 @@
 import { useMemo } from 'react'
 import { useMemo } from 'react'
 import { useQuery } from '@tanstack/react-query'
 import { useQuery } from '@tanstack/react-query'
-import {
-  Activity,
-  AlertTriangle,
-  HeartPulse,
-  Timer,
-  TrendingUp,
-} from 'lucide-react'
+import { AlertTriangle, HeartPulse, Timer } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 import {
 import {
@@ -21,18 +15,14 @@ import { GroupBadge } from '@/components/group-badge'
 import { getPerfMetrics, type PerformanceGroup } from '../api'
 import { getPerfMetrics, type PerformanceGroup } from '../api'
 import {
 import {
   formatLatency,
   formatLatency,
+  formatThroughput,
   formatUptimePct,
   formatUptimePct,
   type UptimeDayPoint,
   type UptimeDayPoint,
 } from '../lib/mock-stats'
 } from '../lib/mock-stats'
 import type { PricingModel } from '../types'
 import type { PricingModel } from '../types'
-import { LatencyTrendChart, UptimeBarChart } from './model-details-charts'
+import { LatencyTrendChart, UptimeTrendChart } from './model-details-charts'
 import { UptimeSparkline } from './model-details-uptime-sparkline'
 import { UptimeSparkline } from './model-details-uptime-sparkline'
 
 
-const COMPACT_NUMBER = new Intl.NumberFormat(undefined, {
-  notation: 'compact',
-  maximumFractionDigits: 1,
-})
-
 function StatCard(props: {
 function StatCard(props: {
   icon: React.ComponentType<{ className?: string }>
   icon: React.ComponentType<{ className?: string }>
   label: string
   label: string
@@ -71,39 +61,55 @@ type PerformanceRow = {
   avg_ttft_ms: number
   avg_ttft_ms: number
   avg_latency_ms: number
   avg_latency_ms: number
   success_rate: number
   success_rate: number
-  request_count: number
+  avg_tps: number
 }
 }
 
 
 function toLatencySeries(groups: PerformanceGroup[]) {
 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<number, number[]>()
+  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[] {
 function toUptimeSeries(groups: PerformanceGroup[]): UptimeDayPoint[] {
-  const byTs = new Map<number, { count: number; success: number }>()
+  const byTs = new Map<number, { rates: number[]; incidents: number }>()
   for (const group of groups) {
   for (const group of groups) {
     for (const point of group.series) {
     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)
       byTs.set(point.ts, current)
     }
     }
   }
   }
   return Array.from(byTs.entries())
   return Array.from(byTs.entries())
     .sort(([a], [b]) => a - b)
     .sort(([a], [b]) => a - b)
     .map(([ts, value]) => {
     .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 {
       return {
         date: new Date(ts * 1000).toISOString(),
         date: new Date(ts * 1000).toISOString(),
         uptime_pct: Math.round(uptime * 100) / 100,
         uptime_pct: Math.round(uptime * 100) / 100,
-        incidents: value.success < value.count ? 1 : 0,
+        incidents: value.incidents,
         outage_minutes: 0,
         outage_minutes: 0,
       }
       }
     })
     })
@@ -113,23 +119,20 @@ function toGroupUptimeSeries(group: PerformanceGroup): UptimeDayPoint[] {
   return group.series.map((point) => ({
   return group.series.map((point) => ({
     date: new Date(point.ts * 1000).toISOString(),
     date: new Date(point.ts * 1000).toISOString(),
     uptime_pct: Math.round(point.success_rate * 100) / 100,
     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,
     outage_minutes: 0,
   }))
   }))
 }
 }
 
 
-function weightedAverage(
+function average(
   rows: PerformanceRow[],
   rows: PerformanceRow[],
   field: 'avg_ttft_ms' | 'avg_latency_ms'
   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 }) {
 export function ModelDetailsPerformance(props: { model: PricingModel }) {
@@ -147,7 +150,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
         avg_ttft_ms: group.avg_ttft_ms,
         avg_ttft_ms: group.avg_ttft_ms,
         avg_latency_ms: group.avg_latency_ms,
         avg_latency_ms: group.avg_latency_ms,
         success_rate: group.success_rate,
         success_rate: group.success_rate,
-        request_count: group.request_count,
+        avg_tps: group.avg_tps,
       })),
       })),
     [groups]
     [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)
     .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 =
   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)
   const incidentCount = uptimeSeries.reduce((s, p) => s + p.incidents, 0)
   let intent: 'default' | 'warning' | 'success' = 'warning'
   let intent: 'default' | 'warning' | 'success' = 'warning'
   if (successRate >= 99.9) {
   if (successRate >= 99.9) {
@@ -191,18 +201,17 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
 
 
   return (
   return (
     <div className='flex flex-col gap-4'>
     <div className='flex flex-col gap-4'>
-      <div className='grid grid-cols-2 gap-2 lg:grid-cols-4'>
+      <div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
         <StatCard
         <StatCard
           icon={Timer}
           icon={Timer}
-          label={t('Best TTFT')}
-          value={formatLatency(bestTtft)}
-          hint={t('Lowest median first-token latency')}
+          label='TPS'
+          value={formatThroughput(avgTps)}
+          hint={t('Sustained tokens per second')}
         />
         />
         <StatCard
         <StatCard
           icon={Timer}
           icon={Timer}
           label={t('Average latency')}
           label={t('Average latency')}
           value={formatLatency(avgLatency)}
           value={formatLatency(avgLatency)}
-          hint={t('Across all groups')}
         />
         />
         <StatCard
         <StatCard
           icon={HeartPulse}
           icon={HeartPulse}
@@ -217,25 +226,22 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
           }
           }
           intent={intent}
           intent={intent}
         />
         />
-        <StatCard
-          icon={TrendingUp}
-          label={t('Requests (24h)')}
-          value={COMPACT_NUMBER.format(totalRequests)}
-          hint={t('Aggregated across enabled groups')}
-        />
       </div>
       </div>
 
 
       <section>
       <section>
         <SectionHeader
         <SectionHeader
-          icon={Activity}
+          icon={HeartPulse}
           title={t('Per-group performance')}
           title={t('Per-group performance')}
-          description={t('Average latency, TTFT, and success rate by group')}
+          description={t('Average latency, TTFT, TPS, and success rate')}
         />
         />
         <div className='overflow-x-auto rounded-lg border'>
         <div className='overflow-x-auto rounded-lg border'>
           <Table className='text-sm'>
           <Table className='text-sm'>
             <TableHeader>
             <TableHeader>
               <TableRow className='hover:bg-transparent'>
               <TableRow className='hover:bg-transparent'>
                 <TableHead className={headerCellClass}>{t('Group')}</TableHead>
                 <TableHead className={headerCellClass}>{t('Group')}</TableHead>
+                <TableHead className={`${headerCellClass} text-right`}>
+                  TPS
+                </TableHead>
                 <TableHead className={`${headerCellClass} text-right`}>
                 <TableHead className={`${headerCellClass} text-right`}>
                   {t('Average TTFT')}
                   {t('Average TTFT')}
                 </TableHead>
                 </TableHead>
@@ -243,46 +249,35 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
                   {t('Average latency')}
                   {t('Average latency')}
                 </TableHead>
                 </TableHead>
                 <TableHead
                 <TableHead
-                  className={`${headerCellClass} min-w-[160px] text-left`}
+                  className={`${headerCellClass} min-w-[180px] text-left`}
                 >
                 >
                   {t('Success rate')}
                   {t('Success rate')}
                 </TableHead>
                 </TableHead>
-                <TableHead className={`${headerCellClass} text-right`}>
-                  {t('Request Count')}
-                </TableHead>
               </TableRow>
               </TableRow>
             </TableHeader>
             </TableHeader>
             <TableBody>
             <TableBody>
-              {performances.map((perf) => {
-                const isBestTtft = perf.avg_ttft_ms === bestTtft
-                return (
-                  <TableRow key={perf.group}>
-                    <TableCell className='py-2.5'>
-                      <GroupBadge group={perf.group} size='sm' />
-                    </TableCell>
-                    <TableCell
-                      className={cn(
-                        'py-2.5 text-right font-mono',
-                        isBestTtft && 'text-emerald-600 dark:text-emerald-400'
-                      )}
-                    >
-                      {formatLatency(perf.avg_ttft_ms)}
-                    </TableCell>
-                    <TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
-                      {formatLatency(perf.avg_latency_ms)}
-                    </TableCell>
-                    <TableCell className='py-2.5'>
-                      <UptimeSparkline
-                        size='sm'
-                        series={uptimeByGroup[perf.group] ?? []}
-                      />
-                    </TableCell>
-                    <TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
-                      {COMPACT_NUMBER.format(perf.request_count)}
-                    </TableCell>
-                  </TableRow>
-                )
-              })}
+              {performances.map((perf) => (
+                <TableRow key={perf.group}>
+                  <TableCell className='py-2.5'>
+                    <GroupBadge group={perf.group} size='sm' />
+                  </TableCell>
+                  <TableCell className='py-2.5 text-right font-mono'>
+                    {formatThroughput(perf.avg_tps)}
+                  </TableCell>
+                  <TableCell className='py-2.5 text-right font-mono'>
+                    {formatLatency(perf.avg_ttft_ms)}
+                  </TableCell>
+                  <TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
+                    {formatLatency(perf.avg_latency_ms)}
+                  </TableCell>
+                  <TableCell className='py-2.5'>
+                    <UptimeSparkline
+                      size='sm'
+                      series={uptimeByGroup[perf.group] ?? []}
+                    />
+                  </TableCell>
+                </TableRow>
+              ))}
             </TableBody>
             </TableBody>
           </Table>
           </Table>
         </div>
         </div>
@@ -292,7 +287,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
         <SectionHeader
         <SectionHeader
           icon={Timer}
           icon={Timer}
           title={t('Latency trend (last 24h)')}
           title={t('Latency trend (last 24h)')}
-          description={t('Average time-to-first-token (TTFT) by group')}
+          description={t('Average TTFT')}
         />
         />
         <LatencyTrendChart series={latencySeries} />
         <LatencyTrendChart series={latencySeries} />
       </section>
       </section>
@@ -322,7 +317,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
             ) : null
             ) : null
           }
           }
         />
         />
-        <UptimeBarChart series={uptimeSeries} />
+        <UptimeTrendChart series={uptimeSeries} />
       </section>
       </section>
     </div>
     </div>
   )
   )

+ 222 - 133
web/default/src/features/pricing/components/model-details.tsx

@@ -1,16 +1,10 @@
 import { useMemo } from 'react'
 import { useMemo } from 'react'
+import { useQuery } from '@tanstack/react-query'
 import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
 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 { useTranslation } from 'react-i18next'
 import { getLobeIcon } from '@/lib/lobe-icon'
 import { getLobeIcon } from '@/lib/lobe-icon'
+import { cn } from '@/lib/utils'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import {
 import {
   Sheet,
   Sheet,
@@ -32,6 +26,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
 import { CopyButton } from '@/components/copy-button'
 import { CopyButton } from '@/components/copy-button'
 import { GroupBadge } from '@/components/group-badge'
 import { GroupBadge } from '@/components/group-badge'
 import { PublicLayout } from '@/components/layout'
 import { PublicLayout } from '@/components/layout'
+import { getPerfMetrics } from '../api'
 import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
 import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
 import { usePricingData } from '../hooks/use-pricing-data'
 import { usePricingData } from '../hooks/use-pricing-data'
 import {
 import {
@@ -42,18 +37,23 @@ import {
 } from '../lib/dynamic-price'
 } from '../lib/dynamic-price'
 import { parseTags } from '../lib/filters'
 import { parseTags } from '../lib/filters'
 import {
 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 { inferModelMetadata } from '../lib/model-metadata'
 import { formatFixedPrice, formatGroupPrice } from '../lib/price'
 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 { DynamicPricingBreakdown } from './dynamic-pricing-breakdown'
 import { ModelDetailsApi, ModelDetailsProviderInfo } from './model-details-api'
 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 { ModelDetailsPerformance } from './model-details-performance'
 import { ModelDetailsQuickStats } from './model-details-quick-stats'
 import { ModelDetailsQuickStats } from './model-details-quick-stats'
 
 
@@ -69,8 +69,181 @@ function SectionTitle(props: { children: React.ReactNode }) {
   )
   )
 }
 }
 
 
+const CAPABILITY_LABEL_KEYS: Record<ModelCapability, string> = {
+  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 (
+      <span className='text-muted-foreground text-xs'>
+        {t('No capabilities reported for this model.')}
+      </span>
+    )
+  }
+
+  return (
+    <div className='flex flex-wrap gap-1.5'>
+      {props.capabilities.map((capability) => (
+        <span
+          key={capability}
+          className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs font-medium'
+        >
+          {t(CAPABILITY_LABEL_KEYS[capability] ?? capability)}
+        </span>
+      ))}
+    </div>
+  )
+}
+
+function CompactModalities(props: { input: Modality[]; output: Modality[] }) {
+  const { t } = useTranslation()
+
+  return (
+    <div className='grid gap-2 sm:grid-cols-2'>
+      <div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
+        <span className='text-muted-foreground text-xs font-medium'>
+          {t('Input')}
+        </span>
+        <ModalityIcons modalities={props.input} />
+      </div>
+      <div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
+        <span className='text-muted-foreground text-xs font-medium'>
+          {t('Output')}
+        </span>
+        <ModalityIcons modalities={props.output} />
+      </div>
+    </div>
+  )
+}
+
+function ModelSignalsSection(props: {
+  capabilities: ModelCapability[]
+  input: Modality[]
+  output: Modality[]
+}) {
+  const { t } = useTranslation()
+
+  return (
+    <section>
+      <SectionTitle>
+        {t('Capabilities')} / {t('Supported modalities')}
+      </SectionTitle>
+      <div className='grid gap-3 rounded-xl border p-3 @2xl/details:grid-cols-[minmax(0,1.5fr)_minmax(260px,1fr)]'>
+        <CompactCapabilityList capabilities={props.capabilities} />
+        <CompactModalities input={props.input} output={props.output} />
+      </div>
+    </section>
+  )
+}
+
+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 (
+    <div className='flex min-w-0 items-center gap-2 px-3 py-2'>
+      <Icon className='text-muted-foreground/70 size-3.5 shrink-0' />
+      <div className='min-w-0 flex-1'>
+        <div className='text-muted-foreground truncate text-[10px] font-medium tracking-wider uppercase'>
+          {props.label}
+        </div>
+        <div
+          className={cn(
+            'text-foreground truncate font-mono text-sm font-semibold tabular-nums',
+            intent === 'warning' && 'text-amber-600 dark:text-amber-400',
+            intent === 'success' && 'text-emerald-600 dark:text-emerald-400'
+          )}
+        >
+          {props.value}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+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 (
+    <div className='bg-muted/20 grid overflow-hidden rounded-lg border sm:grid-cols-3 sm:divide-x'>
+      <OverviewMetric
+        icon={Timer}
+        label='TPS'
+        value={formatThroughput(avgTps)}
+      />
+      <OverviewMetric
+        icon={Timer}
+        label={t('Average latency')}
+        value={formatLatency(avgLatency)}
+      />
+      <OverviewMetric
+        icon={HeartPulse}
+        label={t('Success rate')}
+        value={formatUptimePct(successRate)}
+        intent={successIntent}
+      />
+    </div>
+  )
+}
+
 // ----------------------------------------------------------------------------
 // ----------------------------------------------------------------------------
-// Model header (always visible above the tabs)
+// Model header (always visible above the detail sections)
 // ----------------------------------------------------------------------------
 // ----------------------------------------------------------------------------
 
 
 function ModelHeader(props: { model: PricingModel }) {
 function ModelHeader(props: { model: PricingModel }) {
@@ -362,55 +535,6 @@ function PriceSection(props: {
   )
   )
 }
 }
 
 
-// ----------------------------------------------------------------------------
-// API endpoints list
-// ----------------------------------------------------------------------------
-
-function EndpointsSection(props: {
-  model: PricingModel
-  endpointMap: Record<string, { path?: string; method?: string }>
-}) {
-  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 (
-    <section>
-      <SectionTitle>{t('API Endpoints')}</SectionTitle>
-      <div className='space-y-1'>
-        {endpoints.map(({ type, path, method }) => (
-          <div key={type} className='flex items-center justify-between py-1'>
-            <div className='flex items-center gap-2'>
-              <span className='text-sm font-medium'>{type}</span>
-              {path && (
-                <code className='text-muted-foreground/60 text-xs break-all'>
-                  {path}
-                </code>
-              )}
-            </div>
-            {path && (
-              <span className='bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-[10px] font-medium uppercase'>
-                {method}
-              </span>
-            )}
-          </div>
-        ))}
-      </div>
-    </section>
-  )
-}
-
 // ----------------------------------------------------------------------------
 // ----------------------------------------------------------------------------
 // Auto group chain (used inside group pricing section)
 // 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]
 type TabValue = (typeof TAB_VALUES)[number]
 
 
 const TAB_META: Record<
 const TAB_META: Record<
@@ -758,10 +872,8 @@ const TAB_META: Record<
   { icon: React.ComponentType<{ className?: string }>; labelKey: string }
   { icon: React.ComponentType<{ className?: string }>; labelKey: string }
 > = {
 > = {
   overview: { icon: Info, labelKey: 'Overview' },
   overview: { icon: Info, labelKey: 'Overview' },
-  pricing: { icon: ReceiptText, labelKey: 'Pricing' },
   performance: { icon: HeartPulse, labelKey: 'Performance' },
   performance: { icon: HeartPulse, labelKey: 'Performance' },
   api: { icon: Code2, labelKey: 'API' },
   api: { icon: Code2, labelKey: 'API' },
-  apps: { icon: Rocket, labelKey: 'Apps' },
 }
 }
 
 
 export interface ModelDetailsContentProps {
 export interface ModelDetailsContentProps {
@@ -789,8 +901,6 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
     <div className='@container/details space-y-4'>
     <div className='@container/details space-y-4'>
       <ModelHeader model={props.model} />
       <ModelHeader model={props.model} />
 
 
-      <ModelDetailsQuickStats metadata={metadata} />
-
       <Tabs defaultValue='overview' className='gap-4'>
       <Tabs defaultValue='overview' className='gap-4'>
         <TabsList className='bg-muted/60 h-auto w-full justify-start gap-1 overflow-x-auto rounded-lg p-1'>
         <TabsList className='bg-muted/60 h-auto w-full justify-start gap-1 overflow-x-auto rounded-lg p-1'>
           {TAB_VALUES.map((value) => {
           {TAB_VALUES.map((value) => {
@@ -808,59 +918,42 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
           })}
           })}
         </TabsList>
         </TabsList>
 
 
-        <TabsContent value='overview' className='space-y-5 outline-none'>
-          <section>
-            <div className='mb-3 flex items-center gap-2'>
-              <Boxes className='text-muted-foreground/70 size-3.5' />
-              <h3 className='text-foreground text-sm font-semibold'>
-                {t('Capabilities')}
-              </h3>
-            </div>
-            <ModelDetailsCapabilities capabilities={metadata.capabilities} />
-          </section>
-
-          <section>
-            <div className='mb-3 flex items-center gap-2'>
-              <h3 className='text-foreground text-sm font-semibold'>
-                {t('Supported modalities')}
-              </h3>
-            </div>
-            <ModalitiesMatrix
-              input={metadata.input_modalities}
-              output={metadata.output_modalities}
+        <TabsContent value='overview' className='space-y-6 outline-none'>
+          <OverviewSummaryGrid model={props.model} />
+
+          <section className='bg-card/60 space-y-5 rounded-xl border p-4 shadow-sm'>
+            <SectionTitle>{t('Pricing')}</SectionTitle>
+            <PriceSection
+              model={props.model}
+              priceRate={props.priceRate}
+              usdExchangeRate={props.usdExchangeRate}
+              tokenUnit={props.tokenUnit}
+              showRechargePrice={showRechargePrice}
+            />
+            {isDynamic && (
+              <DynamicPricingBreakdown billingExpr={props.model.billing_expr} />
+            )}
+            <GroupPricingSection
+              model={props.model}
+              groupRatio={props.groupRatio}
+              usableGroup={props.usableGroup}
+              autoGroups={props.autoGroups}
+              priceRate={props.priceRate}
+              usdExchangeRate={props.usdExchangeRate}
+              tokenUnit={props.tokenUnit}
+              showRechargePrice={showRechargePrice}
             />
             />
           </section>
           </section>
 
 
-          <ModelDetailsProviderInfo model={props.model} />
+          <ModelDetailsQuickStats metadata={metadata} />
 
 
-          <PriceSection
-            model={props.model}
-            priceRate={props.priceRate}
-            usdExchangeRate={props.usdExchangeRate}
-            tokenUnit={props.tokenUnit}
-            showRechargePrice={showRechargePrice}
+          <ModelSignalsSection
+            capabilities={metadata.capabilities}
+            input={metadata.input_modalities}
+            output={metadata.output_modalities}
           />
           />
 
 
-          <EndpointsSection
-            model={props.model}
-            endpointMap={props.endpointMap}
-          />
-        </TabsContent>
-
-        <TabsContent value='pricing' className='space-y-5 outline-none'>
-          {isDynamic && (
-            <DynamicPricingBreakdown billingExpr={props.model.billing_expr} />
-          )}
-          <GroupPricingSection
-            model={props.model}
-            groupRatio={props.groupRatio}
-            usableGroup={props.usableGroup}
-            autoGroups={props.autoGroups}
-            priceRate={props.priceRate}
-            usdExchangeRate={props.usdExchangeRate}
-            tokenUnit={props.tokenUnit}
-            showRechargePrice={showRechargePrice}
-          />
+          <ModelDetailsProviderInfo model={props.model} />
         </TabsContent>
         </TabsContent>
 
 
         <TabsContent value='performance' className='outline-none'>
         <TabsContent value='performance' className='outline-none'>
@@ -873,10 +966,6 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
             endpointMap={props.endpointMap}
             endpointMap={props.endpointMap}
           />
           />
         </TabsContent>
         </TabsContent>
-
-        <TabsContent value='apps' className='outline-none'>
-          <ModelDetailsApps model={props.model} />
-        </TabsContent>
       </Tabs>
       </Tabs>
     </div>
     </div>
   )
   )

+ 15 - 0
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<RankingsResponse> {
+  const res = await api.get('/api/rankings', { params: { period } })
+  return res.data
+}

+ 0 - 97
web/default/src/features/rankings/components/apps-section.tsx

@@ -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 (
-    <section className='bg-card overflow-hidden rounded-lg border'>
-      <header className='px-5 py-4'>
-        <h2 className='text-foreground inline-flex items-center gap-2 text-base font-semibold'>
-          <Rocket className='text-primary size-4' />
-          {t('Top Apps')}
-        </h2>
-        <p className='text-muted-foreground mt-1 text-sm'>
-          {t('Apps using the most tokens through new-api')}
-        </p>
-      </header>
-      {props.rows.length === 0 ? (
-        <div className='text-muted-foreground/80 border-t px-5 py-8 text-center text-sm'>
-          {t('No apps match the selected filters')}
-        </div>
-      ) : (
-        <div className='grid grid-cols-1 gap-x-8 border-t px-5 pt-3 pb-4 md:grid-cols-2'>
-          <AppList rows={left} />
-          {right.length > 0 && <AppList rows={right} />}
-        </div>
-      )}
-    </section>
-  )
-}
-
-function AppList(props: { rows: AppListing[] }) {
-  return (
-    <ul>
-      {props.rows.map((row) => (
-        <li key={row.name} className='flex items-center gap-3 py-2.5'>
-          <span className='text-muted-foreground/80 w-6 shrink-0 text-right font-mono text-xs tabular-nums'>
-            {row.rank}.
-          </span>
-          <span className='bg-muted text-muted-foreground inline-flex size-9 shrink-0 items-center justify-center rounded-md text-sm font-bold uppercase'>
-            {row.initial}
-          </span>
-          <div className='min-w-0 flex-1'>
-            <div className='flex items-center gap-2 text-sm font-semibold'>
-              {row.url ? (
-                <a
-                  href={row.url}
-                  target='_blank'
-                  rel='noopener noreferrer'
-                  className='text-foreground hover:text-primary inline-flex items-center gap-1 truncate transition-colors'
-                >
-                  <span className='truncate'>{row.name}</span>
-                  <ExternalLink className='text-muted-foreground/60 size-3 shrink-0' />
-                </a>
-              ) : (
-                <span className='text-foreground truncate'>{row.name}</span>
-              )}
-              <Badge
-                variant='outline'
-                className='h-4 shrink-0 rounded-sm px-1 text-[10px] font-normal'
-              >
-                {row.category}
-              </Badge>
-            </div>
-            <p className='text-muted-foreground/80 truncate text-xs'>
-              {row.description}
-            </p>
-          </div>
-          <div className='shrink-0 text-right'>
-            <div className='text-foreground font-mono text-sm font-semibold tabular-nums'>
-              {formatTokens(row.total_tokens)}
-            </div>
-            <GrowthText value={row.growth_pct} className='text-[11px]' />
-          </div>
-        </li>
-      ))}
-    </ul>
-  )
-}

+ 0 - 205
web/default/src/features/rankings/components/category-section.tsx

@@ -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, unknown>) =>
-                String(datum?.model ?? ''),
-              value: (datum: Record<string, unknown>) =>
-                formatTokens(Number(datum?.tokens) || 0),
-            },
-          ],
-        },
-        dimension: {
-          title: {
-            value: (datum: Record<string, unknown>) =>
-              String(datum?.label ?? ''),
-          },
-          content: [
-            {
-              key: (datum: Record<string, unknown>) =>
-                String(datum?.model ?? ''),
-              value: (datum: Record<string, unknown>) =>
-                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 (
-    <article
-      id={`category-${props.section.category}`}
-      className='bg-card scroll-mt-20 overflow-hidden rounded-lg border'
-    >
-      <header className='flex items-start justify-between gap-4 px-5 py-3.5'>
-        <div className='min-w-0 flex-1'>
-          <h3 className='text-foreground text-base font-semibold'>
-            {t(props.section.label)}
-          </h3>
-          <p className='text-muted-foreground/80 mt-0.5 truncate text-xs'>
-            {t(props.section.description)}
-          </p>
-        </div>
-        <div className='shrink-0 text-right'>
-          <div className='text-foreground font-mono text-base font-semibold tabular-nums'>
-            {formatTokens(props.section.total_tokens)}
-          </div>
-          <div className='text-muted-foreground/80 text-[10px] tracking-widest uppercase'>
-            {t('tokens')}
-          </div>
-        </div>
-      </header>
-
-      <div className='px-5 pb-4'>
-        <div className='h-44 sm:h-48'>
-          {themeReady && spec ? (
-            <VChart
-              key={`category-history-${props.section.category}-${resolvedTheme}`}
-              spec={{
-                ...spec,
-                theme: resolvedTheme === 'dark' ? 'dark' : 'light',
-                background: 'transparent',
-              }}
-              option={VCHART_OPTION}
-            />
-          ) : (
-            <div className='text-muted-foreground/80 flex h-full items-center justify-center text-xs'>
-              {t('No history data available')}
-            </div>
-          )}
-        </div>
-      </div>
-
-      {props.section.models.length === 0 ? (
-        <div className='text-muted-foreground/80 border-t px-5 py-6 text-center text-sm'>
-          {t('No models match the selected filters')}
-        </div>
-      ) : (
-        <div className='border-t px-5 pt-2 pb-4'>
-          <ModelLeaderboard
-            rows={props.section.models}
-            limit={MAX_LEADERBOARD_ROWS}
-            variant='compact'
-          />
-        </div>
-      )}
-    </article>
-  )
-}
-
-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 (
-    <section className='space-y-5'>
-      <header className='space-y-1'>
-        <p className='text-muted-foreground text-[11px] font-medium tracking-widest uppercase'>
-          {t('By category')}
-        </p>
-        <h2 className='text-foreground text-xl font-semibold tracking-tight'>
-          {t('Browse rankings by category')}
-        </h2>
-        <p className='text-muted-foreground/80 max-w-2xl text-sm'>
-          {t('Discover the leading models in each domain')}
-        </p>
-      </header>
-      <div className='grid grid-cols-1 gap-5 lg:grid-cols-2'>
-        {props.sections.map((section) => (
-          <CategorySection key={section.category} section={section} />
-        ))}
-      </div>
-    </section>
-  )
-}

+ 0 - 2
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 './entity-links'
 export * from './growth-text'
 export * from './growth-text'
 export * from './market-share-section'
 export * from './market-share-section'

+ 5 - 8
web/default/src/features/rankings/components/market-share-section.tsx

@@ -16,7 +16,7 @@ const PERIOD_DESCRIPTIONS: Record<RankingPeriod, string> = {
   all: 'Token share by model author since launch',
   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
  * legend dots. Falls back to a neutral palette for unknown vendors so that
  * future additions still render. */
  * future additions still render. */
 const VENDOR_COLOURS: Record<string, string> = {
 const VENDOR_COLOURS: Record<string, string> = {
@@ -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's slice of total token volume, paired below with a two-column
  * vendor list.
  * vendor list.
  */
  */
@@ -104,18 +104,15 @@ export function MarketShareSection(props: MarketShareSectionProps) {
   const spec = useMemo(() => {
   const spec = useMemo(() => {
     if (orderedPoints.length === 0) return null
     if (orderedPoints.length === 0) return null
     return {
     return {
-      type: 'area' as const,
+      type: 'bar' as const,
       data: [{ id: 'vendor-share', values: orderedPoints }],
       data: [{ id: 'vendor-share', values: orderedPoints }],
       xField: 'label',
       xField: 'label',
       yField: 'share',
       yField: 'share',
       seriesField: 'vendor',
       seriesField: 'vendor',
       stack: true,
       stack: true,
+      paddingInner: 0.12,
       legends: { visible: false },
       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 },
       color: { specified: colourMap },
       axes: [
       axes: [
         {
         {

+ 3 - 49
web/default/src/features/rankings/components/pulse-section.tsx

@@ -1,33 +1,28 @@
 import {
 import {
   ArrowDownRight,
   ArrowDownRight,
   ArrowUpRight,
   ArrowUpRight,
-  Sparkles,
   TrendingDown,
   TrendingDown,
   TrendingUp,
   TrendingUp,
 } from 'lucide-react'
 } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import { getLobeIcon } from '@/lib/lobe-icon'
 import { getLobeIcon } from '@/lib/lobe-icon'
 import { cn } from '@/lib/utils'
 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'
 import { ModelLink, VendorLink } from './entity-links'
 
 
 type PulseSectionProps = {
 type PulseSectionProps = {
   movers: RankingMover[]
   movers: RankingMover[]
   droppers: 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) {
 export function PulseSection(props: PulseSectionProps) {
   const { t } = useTranslation()
   const { t } = useTranslation()
 
 
   return (
   return (
-    <section className='grid grid-cols-1 gap-4 lg:grid-cols-3'>
+    <section className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
       <PulseCard
       <PulseCard
         title={t('Trending up')}
         title={t('Trending up')}
         description={t('Models climbing the leaderboard')}
         description={t('Models climbing the leaderboard')}
@@ -59,22 +54,6 @@ export function PulseSection(props: PulseSectionProps) {
           </ul>
           </ul>
         )}
         )}
       </PulseCard>
       </PulseCard>
-
-      <PulseCard
-        title={t('Newly released')}
-        description={t('Recently launched models')}
-        icon={<Sparkles className='size-4 text-amber-500' />}
-      >
-        {props.newModels.length === 0 ? (
-          <PulseEmpty label={t('No new models yet')} />
-        ) : (
-          <ul>
-            {props.newModels.slice(0, 6).map((row) => (
-              <NewModelRow key={row.model_name} row={row} />
-            ))}
-          </ul>
-        )}
-      </PulseCard>
     </section>
     </section>
   )
   )
 }
 }
@@ -145,28 +124,3 @@ function MoverRow(props: { row: RankingMover; intent: 'up' | 'down' }) {
     </li>
     </li>
   )
   )
 }
 }
-
-function NewModelRow(props: { row: NewModelEntry }) {
-  return (
-    <li className='flex items-center gap-3 px-4 py-2'>
-      <span className='shrink-0'>{getLobeIcon(props.row.vendor_icon, 20)}</span>
-      <div className='min-w-0 flex-1'>
-        <ModelLink
-          modelName={props.row.model_name}
-          className='text-foreground block truncate font-mono text-xs font-medium'
-        >
-          {props.row.model_name}
-        </ModelLink>
-        <p className='text-muted-foreground/80 truncate text-[11px]'>
-          {formatReleaseDate(props.row.release_date)} ·{' '}
-          <VendorLink vendor={props.row.vendor}>
-            {props.row.vendor.toLowerCase()}
-          </VendorLink>
-        </p>
-      </div>
-      <span className='text-foreground shrink-0 font-mono text-xs font-semibold tabular-nums'>
-        {formatTokens(props.row.total_tokens)}
-      </span>
-    </li>
-  )
-}

+ 2 - 4
web/default/src/features/rankings/components/rankings-hero.tsx

@@ -17,9 +17,7 @@ type RankingsHeroProps = {
 
 
 /**
 /**
  * Hero strip for the rankings page. Intentionally minimal — title +
  * 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) {
 export function RankingsHero(props: RankingsHeroProps) {
   const { t } = useTranslation()
   const { t } = useTranslation()
@@ -35,7 +33,7 @@ export function RankingsHero(props: RankingsHeroProps) {
         </h1>
         </h1>
         <p className='text-muted-foreground/80 max-w-2xl text-sm'>
         <p className='text-muted-foreground/80 max-w-2xl text-sm'>
           {t(
           {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.'
           )}
           )}
         </p>
         </p>
       </div>
       </div>

+ 9 - 13
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,
+  })
 }
 }

+ 55 - 30
web/default/src/features/rankings/index.tsx

@@ -1,10 +1,9 @@
 import { useNavigate, useSearch } from '@tanstack/react-router'
 import { useNavigate, useSearch } from '@tanstack/react-router'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
+import { Skeleton } from '@/components/ui/skeleton'
 import { PublicLayout } from '@/components/layout'
 import { PublicLayout } from '@/components/layout'
 import { PageTransition } from '@/components/page-transition'
 import { PageTransition } from '@/components/page-transition'
 import {
 import {
-  AppsSection,
-  CategorySections,
   MarketShareSection,
   MarketShareSection,
   ModelsSection,
   ModelsSection,
   PulseSection,
   PulseSection,
@@ -26,7 +25,8 @@ export function Rankings() {
     ? (search.period as RankingPeriod)
     ? (search.period as RankingPeriod)
     : 'week'
     : 'week'
 
 
-  const snapshot = useRankings(period)
+  const rankingsQuery = useRankings(period)
+  const snapshot = rankingsQuery.data?.data
 
 
   const handlePeriodChange = (next: RankingPeriod) => {
   const handlePeriodChange = (next: RankingPeriod) => {
     navigate({
     navigate({
@@ -56,37 +56,62 @@ export function Rankings() {
         <PageTransition className='relative mx-auto w-full max-w-[1280px] space-y-8 px-3 pt-16 pb-10 sm:px-6 sm:pt-20 sm:pb-12 xl:px-8'>
         <PageTransition className='relative mx-auto w-full max-w-[1280px] space-y-8 px-3 pt-16 pb-10 sm:px-6 sm:pt-20 sm:pb-12 xl:px-8'>
           <RankingsHero period={period} onPeriodChange={handlePeriodChange} />
           <RankingsHero period={period} onPeriodChange={handlePeriodChange} />
 
 
-          {/* Overall (all-categories) view ----------------------------- */}
-          <ModelsSection
-            history={snapshot.models_history}
-            rows={snapshot.models}
-            period={period}
-          />
+          {rankingsQuery.isLoading ? (
+            <RankingsLoading />
+          ) : !snapshot ? (
+            <RankingsError
+              message={
+                rankingsQuery.error instanceof Error
+                  ? rankingsQuery.error.message
+                  : t('Unable to load rankings data')
+              }
+            />
+          ) : (
+            <>
+              <ModelsSection
+                history={snapshot.models_history}
+                rows={snapshot.models}
+                period={period}
+              />
 
 
-          <MarketShareSection
-            history={snapshot.vendor_share_history}
-            rows={snapshot.vendors}
-            period={period}
-          />
+              <MarketShareSection
+                history={snapshot.vendor_share_history}
+                rows={snapshot.vendors}
+                period={period}
+              />
 
 
-          <AppsSection rows={snapshot.apps} />
-
-          <PulseSection
-            movers={snapshot.top_movers}
-            droppers={snapshot.top_droppers}
-            newModels={snapshot.new_models}
-          />
-
-          {/* Per-category drill-downs --------------------------------- */}
-          <CategorySections sections={snapshot.category_sections} />
-
-          <p className='text-muted-foreground/60 mx-auto max-w-3xl text-center text-[11px] leading-relaxed'>
-            {t(
-              'Ranking data is currently simulated for preview purposes and will be replaced with live analytics once the backend integration ships.'
-            )}
-          </p>
+              <PulseSection
+                movers={snapshot.top_movers}
+                droppers={snapshot.top_droppers}
+              />
+            </>
+          )}
         </PageTransition>
         </PageTransition>
       </div>
       </div>
     </PublicLayout>
     </PublicLayout>
   )
   )
 }
 }
+
+function RankingsLoading() {
+  return (
+    <div className='space-y-6'>
+      <Skeleton className='h-[420px] w-full rounded-xl' />
+      <Skeleton className='h-[360px] w-full rounded-xl' />
+      <Skeleton className='h-[180px] w-full rounded-xl' />
+    </div>
+  )
+}
+
+function RankingsError(props: { message: string }) {
+  const { t } = useTranslation()
+  return (
+    <div className='bg-card rounded-xl border border-dashed px-6 py-12 text-center'>
+      <h2 className='text-foreground text-base font-semibold'>
+        {t('Unable to load rankings')}
+      </h2>
+      <p className='text-muted-foreground mx-auto mt-2 max-w-md text-sm'>
+        {props.message}
+      </p>
+    </div>
+  )
+}

+ 0 - 1
web/default/src/features/rankings/lib/index.ts

@@ -1,2 +1 @@
 export * from './format'
 export * from './format'
-export * from './mock-rankings'

+ 0 - 1048
web/default/src/features/rankings/lib/mock-rankings.ts

@@ -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<RankingPeriod, number> = {
-  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<RankingMover>((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<RankingPeriod, number> = {
-  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<string, number>()
-
-  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<string, Map<string, number>>()
-  const labelByTs = new Map<string, string>()
-  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<string, number>()
-  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,
-  }
-}

+ 1 - 80
web/default/src/features/rankings/types.ts

@@ -2,11 +2,7 @@
 // Rankings types
 // 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'
 export type RankingPeriod = 'today' | 'week' | 'month' | 'year' | 'all'
 
 
@@ -24,13 +20,6 @@ export type RankingCategoryId =
   | 'productivity'
   | 'productivity'
   | 'multimodal'
   | 'multimodal'
 
 
-export type RankingCategory = {
-  id: RankingCategoryId
-  /** Default English label, fed through i18n at render time. */
-  label: string
-  description: string
-}
-
 export type ModelRanking = {
 export type ModelRanking = {
   rank: number
   rank: number
   /** Previous rank in the same period; undefined means "new". */
   /** Previous rank in the same period; undefined means "new". */
@@ -47,37 +36,6 @@ export type ModelRanking = {
   growth_pct: number
   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 = {
 export type VendorRanking = {
   rank: number
   rank: number
   vendor: string
   vendor: string
@@ -102,17 +60,6 @@ export type RankingMover = {
   growth_pct: number
   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.
  * One sample of a model's token usage at a given timestamp.
  * Flat shape ready to feed VChart's stacked-bar spec.
  * Flat shape ready to feed VChart's stacked-bar spec.
@@ -158,42 +105,16 @@ export type VendorShareSeries = {
   buckets: number
   buckets: number
 }
 }
 
 
-/**
- * Self-contained ranking unit for a single category. Pairs the small
- * stacked-bar chart with the leaderboard data it summarises so
- * `<CategorySection>` 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 = {
 export type RankingsSnapshot = {
   // Overall (all categories) ------------------------------------------------
   // Overall (all categories) ------------------------------------------------
   models: ModelRanking[]
   models: ModelRanking[]
-  apps: AppListing[]
   vendors: VendorRanking[]
   vendors: VendorRanking[]
   /** Largest rank gainers in this period. */
   /** Largest rank gainers in this period. */
   top_movers: RankingMover[]
   top_movers: RankingMover[]
   /** Largest rank losers in this period. */
   /** Largest rank losers in this period. */
   top_droppers: RankingMover[]
   top_droppers: RankingMover[]
-  /** Newly launched / recently added models. */
-  new_models: NewModelEntry[]
   /** Stacked-bar history of token usage by model over the period. */
   /** Stacked-bar history of token usage by model over the period. */
   models_history: ModelHistorySeries
   models_history: ModelHistorySeries
   /** 100%-stacked area history of token share by vendor over the period. */
   /** 100%-stacked area history of token share by vendor over the period. */
   vendor_share_history: VendorShareSeries
   vendor_share_history: VendorShareSeries
-  // Per-category sections ---------------------------------------------------
-  /** Independent ranking sections, one per non-`all` category. */
-  category_sections: CategorySection[]
 }
 }

+ 39 - 16
web/default/src/features/system-settings/maintenance/config.ts

@@ -1,4 +1,4 @@
-export type HeaderNavPricingConfig = {
+export type HeaderNavAccessConfig = {
   enabled: boolean
   enabled: boolean
   requireAuth: boolean
   requireAuth: boolean
 }
 }
@@ -6,10 +6,11 @@ export type HeaderNavPricingConfig = {
 export type HeaderNavModulesConfig = {
 export type HeaderNavModulesConfig = {
   home: boolean
   home: boolean
   console: boolean
   console: boolean
-  pricing: HeaderNavPricingConfig
+  pricing: HeaderNavAccessConfig
+  rankings: HeaderNavAccessConfig
   docs: boolean
   docs: boolean
   about: boolean
   about: boolean
-  [key: string]: boolean | HeaderNavPricingConfig
+  [key: string]: boolean | HeaderNavAccessConfig
 }
 }
 
 
 export type SidebarSectionConfig = {
 export type SidebarSectionConfig = {
@@ -26,6 +27,10 @@ export const HEADER_NAV_DEFAULT: HeaderNavModulesConfig = {
     enabled: true,
     enabled: true,
     requireAuth: false,
     requireAuth: false,
   },
   },
+  rankings: {
+    enabled: true,
+    requireAuth: false,
+  },
   docs: true,
   docs: true,
   about: true,
   about: true,
 }
 }
@@ -74,8 +79,33 @@ const toBoolean = (value: unknown, fallback: boolean): boolean => {
 const cloneHeaderNavDefault = (): HeaderNavModulesConfig => ({
 const cloneHeaderNavDefault = (): HeaderNavModulesConfig => ({
   ...HEADER_NAV_DEFAULT,
   ...HEADER_NAV_DEFAULT,
   pricing: { ...HEADER_NAV_DEFAULT.pricing },
   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<string, unknown>
+    return {
+      enabled: toBoolean(record.enabled, fallback.enabled),
+      requireAuth: toBoolean(record.requireAuth, fallback.requireAuth),
+    }
+  }
+  return { ...fallback }
+}
+
 const cloneSidebarDefault = (): SidebarModulesAdminConfig =>
 const cloneSidebarDefault = (): SidebarModulesAdminConfig =>
   Object.entries(SIDEBAR_MODULES_DEFAULT).reduce<SidebarModulesAdminConfig>(
   Object.entries(SIDEBAR_MODULES_DEFAULT).reduce<SidebarModulesAdminConfig>(
     (acc, [section, config]) => {
     (acc, [section, config]) => {
@@ -97,23 +127,16 @@ export function parseHeaderNavModules(
     const result: HeaderNavModulesConfig = {
     const result: HeaderNavModulesConfig = {
       ...base,
       ...base,
       pricing: { ...base.pricing },
       pricing: { ...base.pricing },
+      rankings: { ...base.rankings },
     }
     }
 
 
     Object.entries(parsed).forEach(([key, raw]) => {
     Object.entries(parsed).forEach(([key, raw]) => {
       if (key === 'pricing') {
       if (key === 'pricing') {
-        if (raw && typeof raw === 'object') {
-          const rawPricing = raw as Record<string, unknown>
-          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
         return
       }
       }
 
 

+ 101 - 55
web/default/src/features/system-settings/maintenance/header-navigation-section.tsx

@@ -27,6 +27,8 @@ const headerNavSchema = z.object({
   console: z.boolean(),
   console: z.boolean(),
   pricingEnabled: z.boolean(),
   pricingEnabled: z.boolean(),
   pricingRequireAuth: z.boolean(),
   pricingRequireAuth: z.boolean(),
+  rankingsEnabled: z.boolean(),
+  rankingsRequireAuth: z.boolean(),
   docs: z.boolean(),
   docs: z.boolean(),
   about: z.boolean(),
   about: z.boolean(),
 })
 })
@@ -53,6 +55,14 @@ const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({
     config.pricing?.requireAuth === undefined
     config.pricing?.requireAuth === undefined
       ? HEADER_NAV_DEFAULT.pricing.requireAuth
       ? HEADER_NAV_DEFAULT.pricing.requireAuth
       : Boolean(config.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:
   docs:
     config.docs === undefined ? HEADER_NAV_DEFAULT.docs : Boolean(config.docs),
     config.docs === undefined ? HEADER_NAV_DEFAULT.docs : Boolean(config.docs),
   about:
   about:
@@ -90,6 +100,11 @@ export function HeaderNavigationSection({
         enabled: values.pricingEnabled,
         enabled: values.pricingEnabled,
         requireAuth: values.pricingRequireAuth,
         requireAuth: values.pricingRequireAuth,
       },
       },
+      rankings: {
+        ...(config.rankings ?? HEADER_NAV_DEFAULT.rankings),
+        enabled: values.rankingsEnabled,
+        requireAuth: values.rankingsRequireAuth,
+      },
     }
     }
 
 
     const serialized = serializeHeaderNavModules(payload)
     const serialized = serializeHeaderNavModules(payload)
@@ -107,7 +122,7 @@ export function HeaderNavigationSection({
     form.reset(toFormValues(HEADER_NAV_DEFAULT))
     form.reset(toFormValues(HEADER_NAV_DEFAULT))
   }
   }
 
 
-  const modules: Array<{
+  const simpleModules: Array<{
     key: keyof HeaderNavFormValues
     key: keyof HeaderNavFormValues
     title: string
     title: string
     description: 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 (
   return (
     <SettingsSection
     <SettingsSection
       title={t('Header navigation')}
       title={t('Header navigation')}
@@ -142,7 +190,7 @@ export function HeaderNavigationSection({
       <Form {...form}>
       <Form {...form}>
         <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
         <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
           <div className='grid gap-4 md:grid-cols-2'>
           <div className='grid gap-4 md:grid-cols-2'>
-            {modules.map((module) => (
+            {simpleModules.map((module) => (
               <FormField
               <FormField
                 key={module.key}
                 key={module.key}
                 control={form.control}
                 control={form.control}
@@ -168,59 +216,57 @@ export function HeaderNavigationSection({
             ))}
             ))}
           </div>
           </div>
 
 
-          <div className='rounded-lg border p-4'>
-            <FormField
-              control={form.control}
-              name='pricingEnabled'
-              render={({ field }) => (
-                <FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
-                  <div className='space-y-0.5 pe-4'>
-                    <FormLabel className='text-base'>
-                      {t('Models directory')}
-                    </FormLabel>
-                    <FormDescription>
-                      {t(
-                        'Exposes the pricing/models catalog in the top navigation.'
-                      )}
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                    />
-                  </FormControl>
-                  <FormMessage />
-                </FormItem>
-              )}
-            />
-
-            <FormField
-              control={form.control}
-              name='pricingRequireAuth'
-              render={({ field }) => (
-                <FormItem className='mt-4 flex flex-row items-start justify-between rounded-lg border border-dashed p-4'>
-                  <div className='space-y-0.5 pe-4'>
-                    <FormLabel className='text-base'>
-                      {t('Require login to view models')}
-                    </FormLabel>
-                    <FormDescription>
-                      {t(
-                        'Visitors must authenticate before accessing the pricing directory.'
-                      )}
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                      disabled={!form.watch('pricingEnabled')}
-                    />
-                  </FormControl>
-                  <FormMessage />
-                </FormItem>
-              )}
-            />
+          <div className='grid gap-4 lg:grid-cols-2'>
+            {accessModules.map((module) => (
+              <div key={module.enabledKey} className='rounded-lg border p-4'>
+                <FormField
+                  control={form.control}
+                  name={module.enabledKey}
+                  render={({ field }) => (
+                    <FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
+                      <div className='space-y-0.5 pe-4'>
+                        <FormLabel className='text-base'>
+                          {module.title}
+                        </FormLabel>
+                        <FormDescription>{module.description}</FormDescription>
+                      </div>
+                      <FormControl>
+                        <Switch
+                          checked={field.value}
+                          onCheckedChange={field.onChange}
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={form.control}
+                  name={module.requireAuthKey}
+                  render={({ field }) => (
+                    <FormItem className='mt-4 flex flex-row items-start justify-between rounded-lg border border-dashed p-4'>
+                      <div className='space-y-0.5 pe-4'>
+                        <FormLabel className='text-base'>
+                          {module.requireAuthTitle}
+                        </FormLabel>
+                        <FormDescription>
+                          {module.requireAuthDescription}
+                        </FormDescription>
+                      </div>
+                      <FormControl>
+                        <Switch
+                          checked={field.value}
+                          onCheckedChange={field.onChange}
+                          disabled={!form.watch(module.requireAuthDependsOn)}
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+              </div>
+            ))}
           </div>
           </div>
 
 
           <div className='flex flex-wrap gap-3'>
           <div className='flex flex-wrap gap-3'>

+ 0 - 5
web/default/src/features/system-settings/models/model-pricing-sheet.tsx

@@ -269,11 +269,6 @@ function getModeBadgeVariant(
   return 'outline'
   return 'outline'
 }
 }
 
 
-function truncateExpr(value: string) {
-  if (!value) return ''
-  return value.length > 110 ? `${value.slice(0, 110)}...` : value
-}
-
 function buildPreviewRows(
 function buildPreviewRows(
   values: ModelPricingFormValues,
   values: ModelPricingFormValues,
   mode: PricingMode,
   mode: PricingMode,

+ 34 - 1
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 { useQueryClient, useIsFetching } from '@tanstack/react-query'
 import { useNavigate, getRouteApi } from '@tanstack/react-router'
 import { useNavigate, getRouteApi } from '@tanstack/react-router'
 import { type Table } from '@tanstack/react-table'
 import { type Table } from '@tanstack/react-table'
+import { Eye, EyeOff } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import { useIsAdmin } from '@/hooks/use-admin'
 import { useIsAdmin } from '@/hooks/use-admin'
+import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipTrigger,
+} from '@/components/ui/tooltip'
 import {
 import {
   Select,
   Select,
   SelectContent,
   SelectContent,
@@ -17,6 +24,7 @@ import { LOG_TYPES } from '../constants'
 import { buildSearchParams } from '../lib/filter'
 import { buildSearchParams } from '../lib/filter'
 import { getDefaultTimeRange } from '../lib/utils'
 import { getDefaultTimeRange } from '../lib/utils'
 import type { CommonLogFilters } from '../types'
 import type { CommonLogFilters } from '../types'
+import { CommonLogsStats } from './common-logs-stats'
 import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
 import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
 import { useUsageLogsContext } from './usage-logs-provider'
 import { useUsageLogsContext } from './usage-logs-provider'
 
 
@@ -41,7 +49,7 @@ export function CommonLogsFilterBar<TData>(
   const queryClient = useQueryClient()
   const queryClient = useQueryClient()
   const searchParams = route.useSearch()
   const searchParams = route.useSearch()
   const isAdmin = useIsAdmin()
   const isAdmin = useIsAdmin()
-  const { sensitiveVisible } = useUsageLogsContext()
+  const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
   const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
   const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
 
 
   const [filters, setFilters] = useState<CommonLogFilters>(() => {
   const [filters, setFilters] = useState<CommonLogFilters>(() => {
@@ -142,9 +150,34 @@ export function CommonLogsFilterBar<TData>(
   const inputClass = 'w-full sm:w-[140px] lg:w-[160px]'
   const inputClass = 'w-full sm:w-[140px] lg:w-[160px]'
   const sensitiveType = sensitiveVisible ? 'text' : 'password'
   const sensitiveType = sensitiveVisible ? 'text' : 'password'
 
 
+  const statsBar = (
+    <div className='flex flex-wrap items-center gap-2'>
+      <CommonLogsStats />
+      <Tooltip>
+        <TooltipTrigger
+          render={
+            <Button
+              variant='ghost'
+              size='icon'
+              onClick={() => setSensitiveVisible(!sensitiveVisible)}
+              aria-label={sensitiveVisible ? t('Hide') : t('Show')}
+              className='text-muted-foreground hover:text-foreground size-7'
+            />
+          }
+        >
+          {sensitiveVisible ? <Eye /> : <EyeOff />}
+        </TooltipTrigger>
+        <TooltipContent>
+          {sensitiveVisible ? t('Hide') : t('Show')}
+        </TooltipContent>
+      </Tooltip>
+    </div>
+  )
+
   return (
   return (
     <DataTableToolbar
     <DataTableToolbar
       table={props.table}
       table={props.table}
+      leftActions={statsBar}
       customSearch={
       customSearch={
         <CompactDateTimeRangePicker
         <CompactDateTimeRangePicker
           start={filters.startTime}
           start={filters.startTime}

+ 0 - 6
web/default/src/features/usage-logs/index.tsx

@@ -6,7 +6,6 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
 import { SectionPageLayout } from '@/components/layout'
 import { SectionPageLayout } from '@/components/layout'
 import type { NavGroup } from '@/components/layout/types'
 import type { NavGroup } from '@/components/layout/types'
 import { CacheStatsDialog } from '@/features/system-settings/general/channel-affinity/cache-stats-dialog'
 import { CacheStatsDialog } from '@/features/system-settings/general/channel-affinity/cache-stats-dialog'
-import { CommonLogsHeaderActions } from './components/common-logs-header-actions'
 import { UserInfoDialog } from './components/dialogs/user-info-dialog'
 import { UserInfoDialog } from './components/dialogs/user-info-dialog'
 import {
 import {
   UsageLogsProvider,
   UsageLogsProvider,
@@ -106,11 +105,6 @@ function UsageLogsContent() {
         <SectionPageLayout.Description>
         <SectionPageLayout.Description>
           {t(pageMeta.descriptionKey)}
           {t(pageMeta.descriptionKey)}
         </SectionPageLayout.Description>
         </SectionPageLayout.Description>
-        {activeCategory === 'common' && (
-          <SectionPageLayout.Actions>
-            <CommonLogsHeaderActions />
-          </SectionPageLayout.Actions>
-        )}
         <SectionPageLayout.Content>
         <SectionPageLayout.Content>
           <div className='space-y-4'>
           <div className='space-y-4'>
             {showTaskSwitcher && (
             {showTaskSwitcher && (

+ 55 - 11
web/default/src/hooks/use-top-nav-links.ts

@@ -20,6 +20,59 @@ const DEFAULT_HEADER_NAV_MODULES = {
   about: true,
   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<string, unknown>
+    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<string, unknown>
+    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
  * Generate top navigation links based on HeaderNavModules configuration from backend /api/status
  * Backend format example (stringified JSON):
  * Backend format example (stringified JSON):
@@ -27,6 +80,7 @@ const DEFAULT_HEADER_NAV_MODULES = {
  *   home: true,
  *   home: true,
  *   console: true,
  *   console: true,
  *   pricing: { enabled: true, requireAuth: false },
  *   pricing: { enabled: true, requireAuth: false },
+ *   rankings: { enabled: true, requireAuth: false },
  *   docs: true,
  *   docs: true,
  *   about: true
  *   about: true
  * }
  * }
@@ -38,17 +92,7 @@ export function useTopNavLinks(): TopNavLink[] {
 
 
   // Parse HeaderNavModules
   // Parse HeaderNavModules
   const modules = useMemo(() => {
   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])
   }, [status?.HeaderNavModules])
 
 
   // Documentation link (may be external)
   // Documentation link (may be external)

+ 4 - 0
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 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-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.",
     "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": "Provider",
     "Provider & data privacy": "Provider & data privacy",
     "Provider & data privacy": "Provider & data privacy",
     "Provider created successfully": "Provider created successfully",
     "Provider created successfully": "Provider created successfully",
@@ -3157,6 +3159,7 @@
     "Require email verification for new accounts": "Require email verification for new accounts",
     "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 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 models": "Require login to view models",
+    "Require login to view rankings": "Require login to view rankings",
     "required": "required",
     "required": "required",
     "Required": "Required",
     "Required": "Required",
     "Required events:": "Required events:",
     "Required events:": "Required events:",
@@ -4125,6 +4128,7 @@
     "Vision, image / video, document chat": "Vision, image / video, document chat",
     "Vision, image / video, document chat": "Vision, image / video, document chat",
     "Visit Settings → General and adjust quota options...": "Visit Settings → General and adjust quota options...",
     "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 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": "Visual",
     "Visual edit": "Visual edit",
     "Visual edit": "Visual edit",
     "Visual editor": "Visual editor",
     "Visual editor": "Visual editor",

+ 4 - 0
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 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-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.",
     "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": "Fournisseur",
     "Provider & data privacy": "Fournisseur & confidentialité",
     "Provider & data privacy": "Fournisseur & confidentialité",
     "Provider created successfully": "Fournisseur créé avec succès",
     "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 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 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 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": "Requis",
     "Required": "Requis",
     "Required events:": "Événements requis :",
     "Required events:": "Événements requis :",
@@ -4125,6 +4128,7 @@
     "Vision, image / video, document chat": "Vision, image / vidéo, conversation sur document",
     "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...",
     "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 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": "Visuel",
     "Visual edit": "Édition visuelle",
     "Visual edit": "Édition visuelle",
     "Visual editor": "Éditeur visuel",
     "Visual editor": "Éditeur visuel",

+ 4 - 0
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 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-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として提供します。拡張コンテキストウィンドウなどのベータ機能を有効にするのに役立ちます。",
     "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": "プロバイダ",
     "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": "ランキングを表示するにはログインを要求する",
     "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.": "訪問者はランキングページにアクセスする前に認証を行う必要があります。",
     "Visual": "ビジュアル",
     "Visual": "ビジュアル",
     "Visual edit": "ビジュアル編集",
     "Visual edit": "ビジュアル編集",
     "Visual editor": "ビジュアルエディター",
     "Visual editor": "ビジュアルエディター",

+ 4 - 0
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 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-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. Полезно для включения бета-функций, таких как расширенные окна контекста.",
     "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": "Провайдер",
     "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": "Требовать вход для просмотра рейтингов",
     "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.": "Посетители должны пройти аутентификацию перед доступом к странице рейтингов.",
     "Visual": "Визуальный",
     "Visual": "Визуальный",
     "Visual edit": "Визуальное редактирование",
     "Visual edit": "Визуальное редактирование",
     "Visual editor": "Визуальный редактор",
     "Visual editor": "Визуальный редактор",

+ 4 - 0
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 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-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.",
     "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": "Nhà cung cấp",
     "Provider & data privacy": "Nhà cung cấp & quyền riêng tư",
     "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",
     "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 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 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 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": "Bắt buộc",
     "Required": "Bắt buộc",
     "Required events:": "Sự kiện 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",
     "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...",
     "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 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": "Trực quan",
     "Visual edit": "Chỉnh sửa trực quan",
     "Visual edit": "Chỉnh sửa trực quan",
     "Visual editor": "Trình sửa trực quan",
     "Visual editor": "Trình sửa trực quan",

+ 4 - 0
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 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-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 格式提供按模型划分的标头覆盖。可用于启用测试功能,例如扩展上下文窗口。",
     "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": "提供商",
     "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": "要求登录才能查看排行榜",
     "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.": "访客必须先进行身份验证才能访问排行榜页面。",
     "Visual": "可视",
     "Visual": "可视",
     "Visual edit": "可视化编辑",
     "Visual edit": "可视化编辑",
     "Visual editor": "可视化编辑器",
     "Visual editor": "可视化编辑器",

+ 2 - 1
web/default/src/i18n/static-keys.ts

@@ -4,7 +4,8 @@ export const STATIC_I18N_KEYS = [
   // Header navigation
   // Header navigation
   'Home',
   'Home',
   'Console',
   'Console',
-  'Pricing',
+  'Model Square',
+  'Rankings',
   'Docs',
   'Docs',
   'About',
   'About',