Ver Fonte

feat(default): add real rankings data

CaIon há 1 semana atrás
pai
commit
f8cf9c57c4
41 ficheiros alterados com 1498 adições e 1912 exclusões
  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 {
 		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"`
 	Group          string `json:"group" gorm:"column:group;size:64;uniqueIndex:idx_perf_model_group_bucket,priority:2"`
 	BucketTs       int64  `json:"bucket_ts" gorm:"uniqueIndex:idx_perf_model_group_bucket,priority:3;index:idx_perf_bucket_ts"`
-	RequestCount   int64  `json:"request_count" gorm:"default:0"`
-	SuccessCount   int64  `json:"success_count" gorm:"default:0"`
-	TotalLatencyMs int64  `json:"total_latency_ms" gorm:"default:0"`
-	TtftSumMs      int64  `json:"ttft_sum_ms" gorm:"default:0"`
-	TtftCount      int64  `json:"ttft_count" gorm:"default:0"`
+	RequestCount   int64  `json:"-" gorm:"default:0"`
+	SuccessCount   int64  `json:"-" gorm:"default:0"`
+	TotalLatencyMs int64  `json:"-" gorm:"default:0"`
+	TtftSumMs      int64  `json:"-" gorm:"default:0"`
+	TtftCount      int64  `json:"-" gorm:"default:0"`
+	OutputTokens   int64  `json:"-" gorm:"default:0"`
+	GenerationMs   int64  `json:"-" gorm:"default:0"`
 }
 
 func (PerfMetric) TableName() string {
@@ -40,6 +42,8 @@ func UpsertPerfMetric(metric *PerfMetric) error {
 			"total_latency_ms": gorm.Expr("total_latency_ms + ?", metric.TotalLatencyMs),
 			"ttft_sum_ms":      gorm.Expr("ttft_sum_ms + ?", metric.TtftSumMs),
 			"ttft_count":       gorm.Expr("ttft_count + ?", metric.TtftCount),
+			"output_tokens":    gorm.Expr("output_tokens + ?", metric.OutputTokens),
+			"generation_ms":    gorm.Expr("generation_ms + ?", metric.GenerationMs),
 		}),
 	}).Create(metric).Error
 }

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

+ 38 - 13
pkg/perf_metrics/metrics.go

@@ -15,13 +15,15 @@ import (
 
 var hotBuckets sync.Map
 
+// seriesSchema is a stable client cache/schema marker. Do not change it when
+// hiding fields or making response-only privacy hardening changes.
 const seriesSchema = "dbcd0a3c01b55203"
 
 func Init() {
 	go flushLoop()
 }
 
-func RecordRelaySample(info *relaycommon.RelayInfo, success bool) {
+func RecordRelaySample(info *relaycommon.RelayInfo, success bool, outputTokens int64) {
 	if info == nil {
 		return
 	}
@@ -31,13 +33,23 @@ func RecordRelaySample(info *relaycommon.RelayInfo, success bool) {
 	if hasTtft {
 		ttftMs = info.FirstResponseTime.Sub(info.StartTime).Milliseconds()
 	}
+	latencyMs := now.Sub(info.StartTime).Milliseconds()
+	generationMs := latencyMs
+	if hasTtft {
+		generationMs = now.Sub(info.FirstResponseTime).Milliseconds()
+	}
+	if generationMs <= 0 {
+		generationMs = latencyMs
+	}
 	Record(Sample{
-		Model:     info.OriginModelName,
-		Group:     info.UsingGroup,
-		LatencyMs: now.Sub(info.StartTime).Milliseconds(),
-		TtftMs:    ttftMs,
-		HasTtft:   hasTtft,
-		Success:   success,
+		Model:        info.OriginModelName,
+		Group:        info.UsingGroup,
+		LatencyMs:    latencyMs,
+		TtftMs:       ttftMs,
+		HasTtft:      hasTtft,
+		Success:      success,
+		OutputTokens: outputTokens,
+		GenerationMs: generationMs,
 	})
 }
 
@@ -89,6 +101,8 @@ func Query(params QueryParams) (QueryResult, error) {
 			totalLatencyMs: row.TotalLatencyMs,
 			ttftSumMs:      row.TtftSumMs,
 			ttftCount:      row.TtftCount,
+			outputTokens:   row.OutputTokens,
+			generationMs:   row.GenerationMs,
 		})
 	}
 
@@ -125,6 +139,8 @@ func mergeCounters(merged map[bucketKey]counters, key bucketKey, value counters)
 	current.totalLatencyMs += value.totalLatencyMs
 	current.ttftSumMs += value.ttftSumMs
 	current.ttftCount += value.ttftCount
+	current.outputTokens += value.outputTokens
+	current.generationMs += value.generationMs
 	merged[key] = current
 }
 
@@ -166,6 +182,8 @@ func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResu
 			total.totalLatencyMs += value.totalLatencyMs
 			total.ttftSumMs += value.ttftSumMs
 			total.ttftCount += value.ttftCount
+			total.outputTokens += value.outputTokens
+			total.generationMs += value.generationMs
 			series = append(series, bucketPoint(ts, value))
 		}
 
@@ -174,9 +192,7 @@ func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResu
 			AvgTtftMs:    avg(total.ttftSumMs, total.ttftCount),
 			AvgLatencyMs: avg(total.totalLatencyMs, total.requestCount),
 			SuccessRate:  successRate(total),
-			RequestCount: total.requestCount,
-			SuccessCount: total.successCount,
-			TtftCount:    total.ttftCount,
+			AvgTps:       avgTps(total),
 			Series:       series,
 		})
 	}
@@ -194,9 +210,7 @@ func bucketPoint(ts int64, value counters) BucketPoint {
 		AvgTtftMs:    avg(value.ttftSumMs, value.ttftCount),
 		AvgLatencyMs: avg(value.totalLatencyMs, value.requestCount),
 		SuccessRate:  successRate(value),
-		Count:        value.requestCount,
-		SuccessCount: value.successCount,
-		TtftCount:    value.ttftCount,
+		AvgTps:       avgTps(value),
 	}
 }
 
@@ -214,6 +228,13 @@ func successRate(value counters) float64 {
 	return float64(value.successCount) / float64(value.requestCount) * 100
 }
 
+func avgTps(value counters) float64 {
+	if value.outputTokens <= 0 || value.generationMs <= 0 {
+		return 0
+	}
+	return float64(value.outputTokens) / (float64(value.generationMs) / 1000)
+}
+
 func recordRedis(key bucketKey, sample Sample) {
 	if !common.RedisEnabled || common.RDB == nil {
 		return
@@ -234,6 +255,10 @@ func recordRedis(key bucketKey, sample Sample) {
 		pipe.HIncrBy(ctx, redisKey, "ttft", sample.TtftMs)
 		pipe.HIncrBy(ctx, redisKey, "ttft_n", 1)
 	}
+	if sample.OutputTokens > 0 && sample.GenerationMs > 0 {
+		pipe.HIncrBy(ctx, redisKey, "out", sample.OutputTokens)
+		pipe.HIncrBy(ctx, redisKey, "gen_ms", sample.GenerationMs)
+	}
 	pipe.Expire(ctx, redisKey, time.Hour)
 	_, _ = pipe.Exec(ctx)
 }

+ 28 - 12
pkg/perf_metrics/types.go

@@ -8,12 +8,14 @@ type Store interface {
 }
 
 type Sample struct {
-	Model     string
-	Group     string
-	LatencyMs int64
-	TtftMs    int64
-	HasTtft   bool
-	Success   bool
+	Model        string
+	Group        string
+	LatencyMs    int64
+	TtftMs       int64
+	HasTtft      bool
+	Success      bool
+	OutputTokens int64
+	GenerationMs int64
 }
 
 type QueryParams struct {
@@ -27,9 +29,7 @@ type BucketPoint struct {
 	AvgTtftMs    int64   `json:"avg_ttft_ms"`
 	AvgLatencyMs int64   `json:"avg_latency_ms"`
 	SuccessRate  float64 `json:"success_rate"`
-	Count        int64   `json:"count"`
-	SuccessCount int64   `json:"success_count"`
-	TtftCount    int64   `json:"ttft_count"`
+	AvgTps       float64 `json:"avg_tps"`
 }
 
 type GroupResult struct {
@@ -37,9 +37,7 @@ type GroupResult struct {
 	AvgTtftMs    int64         `json:"avg_ttft_ms"`
 	AvgLatencyMs int64         `json:"avg_latency_ms"`
 	SuccessRate  float64       `json:"success_rate"`
-	RequestCount int64         `json:"request_count"`
-	SuccessCount int64         `json:"success_count"`
-	TtftCount    int64         `json:"ttft_count"`
+	AvgTps       float64       `json:"avg_tps"`
 	Series       []BucketPoint `json:"series"`
 }
 
@@ -61,6 +59,8 @@ type counters struct {
 	totalLatencyMs int64
 	ttftSumMs      int64
 	ttftCount      int64
+	outputTokens   int64
+	generationMs   int64
 }
 
 type atomicBucket struct {
@@ -69,6 +69,8 @@ type atomicBucket struct {
 	totalLatencyMs atomic.Int64
 	ttftSumMs      atomic.Int64
 	ttftCount      atomic.Int64
+	outputTokens   atomic.Int64
+	generationMs   atomic.Int64
 }
 
 func (b *atomicBucket) add(sample Sample) {
@@ -83,6 +85,10 @@ func (b *atomicBucket) add(sample Sample) {
 		b.ttftSumMs.Add(sample.TtftMs)
 		b.ttftCount.Add(1)
 	}
+	if sample.OutputTokens > 0 && sample.GenerationMs > 0 {
+		b.outputTokens.Add(sample.OutputTokens)
+		b.generationMs.Add(sample.GenerationMs)
+	}
 }
 
 func (b *atomicBucket) snapshot() counters {
@@ -92,6 +98,8 @@ func (b *atomicBucket) snapshot() counters {
 		totalLatencyMs: b.totalLatencyMs.Load(),
 		ttftSumMs:      b.ttftSumMs.Load(),
 		ttftCount:      b.ttftCount.Load(),
+		outputTokens:   b.outputTokens.Load(),
+		generationMs:   b.generationMs.Load(),
 	}
 }
 
@@ -102,6 +110,8 @@ func (b *atomicBucket) drain() counters {
 		totalLatencyMs: b.totalLatencyMs.Swap(0),
 		ttftSumMs:      b.ttftSumMs.Swap(0),
 		ttftCount:      b.ttftCount.Swap(0),
+		outputTokens:   b.outputTokens.Swap(0),
+		generationMs:   b.generationMs.Swap(0),
 	}
 }
 
@@ -121,4 +131,10 @@ func (b *atomicBucket) addCounters(c counters) {
 	if c.ttftCount != 0 {
 		b.ttftCount.Add(c.ttftCount)
 	}
+	if c.outputTokens != 0 {
+		b.outputTokens.Add(c.outputTokens)
+	}
+	if c.generationMs != 0 {
+		b.generationMs.Add(c.generationMs)
+	}
 }

+ 1 - 0
router/api-router.go

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

+ 1 - 1
service/quota.go

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

+ 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,
 	})
 	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.
    */
   hideViewOptions?: boolean
+  /**
+   * Content rendered on the LEFT side of the secondary action row. When
+   * provided the toolbar splits into two visual rows:
+   *   Row 1: search inputs / filter chips …… Expand
+   *   Row 2: expanded filters
+   *   Row 3: leftActions …… Reset / Search / ViewOptions
+   */
+  leftActions?: ReactNode
   /**
    * Outer wrapper className override.
    */
@@ -216,6 +224,39 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
     </Button>
   ) : 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 (
     <div
       className={cn(

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

@@ -16,9 +16,7 @@ export type PerformanceSeriesPoint = {
   avg_ttft_ms: number
   avg_latency_ms: number
   success_rate: number
-  count: number
-  success_count: number
-  ttft_count: number
+  avg_tps: number
 }
 
 export type PerformanceGroup = {
@@ -26,9 +24,7 @@ export type PerformanceGroup = {
   avg_ttft_ms: number
   avg_latency_ms: number
   success_rate: number
-  request_count: number
-  success_count: number
-  ttft_count: number
+  avg_tps: number
   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: {
@@ -52,14 +52,20 @@ export function LatencyTrendChart(props: {
       yField: 'ttft',
       seriesField: 'group',
       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: {
         mark: {
           title: { value: (d: { time: string }) => d.time },
           content: [
             {
-              key: (d: { group: string }) => d.group,
+              key: t('Average TTFT'),
               value: (d: { ttft: number }) => `${Math.round(d.ttft)} ms`,
             },
           ],
@@ -83,7 +89,7 @@ export function LatencyTrendChart(props: {
         },
       ],
     }
-  }, [props.series])
+  }, [props.series, t])
 
   if (props.series.length === 0) {
     return (
@@ -116,10 +122,10 @@ export function LatencyTrendChart(props: {
 }
 
 // ---------------------------------------------------------------------------
-// Uptime bar chart (30 days)
+// Uptime trend chart (24h, point-line chart)
 // ---------------------------------------------------------------------------
 
-export function UptimeBarChart(props: {
+export function UptimeTrendChart(props: {
   series: UptimeDayPoint[]
   className?: string
 }) {
@@ -137,18 +143,25 @@ export function UptimeBarChart(props: {
     }))
 
     return {
-      type: 'bar' as const,
+      type: 'line' as const,
       data: [{ id: 'uptime', values: data }],
       xField: 'date',
       yField: 'uptime',
-      bar: {
+      smooth: true,
+      line: {
+        style: { stroke: '#10b981', lineWidth: 2 },
+      },
+      point: {
+        visible: true,
         style: {
+          size: 5,
+          stroke: '#ffffff',
+          lineWidth: 1.5,
           fill: (datum: { uptime: number }) => {
             if (datum.uptime >= 99.9) return '#10b981'
             if (datum.uptime >= 99.0) return '#f59e0b'
             return '#ef4444'
           },
-          cornerRadius: 2,
         },
       },
       tooltip: {
@@ -210,7 +223,7 @@ export function UptimeBarChart(props: {
     <div className={cn('h-56 sm:h-64', props.className)}>
       {themeReady && spec && (
         <VChart
-          key={`uptime-${resolvedTheme}`}
+          key={`uptime-trend-${resolvedTheme}`}
           spec={{
             ...spec,
             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 { 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 { cn } from '@/lib/utils'
 import {
@@ -21,18 +15,14 @@ import { GroupBadge } from '@/components/group-badge'
 import { getPerfMetrics, type PerformanceGroup } from '../api'
 import {
   formatLatency,
+  formatThroughput,
   formatUptimePct,
   type UptimeDayPoint,
 } from '../lib/mock-stats'
 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'
 
-const COMPACT_NUMBER = new Intl.NumberFormat(undefined, {
-  notation: 'compact',
-  maximumFractionDigits: 1,
-})
-
 function StatCard(props: {
   icon: React.ComponentType<{ className?: string }>
   label: string
@@ -71,39 +61,55 @@ type PerformanceRow = {
   avg_ttft_ms: number
   avg_latency_ms: number
   success_rate: number
-  request_count: number
+  avg_tps: number
 }
 
 function toLatencySeries(groups: PerformanceGroup[]) {
-  return groups.flatMap((group) =>
-    group.series
-      .filter((point) => point.ttft_count > 0 && point.avg_ttft_ms > 0)
-      .map((point) => ({
-        timestamp: new Date(point.ts * 1000).toISOString(),
-        group: group.group,
-        ttft_ms: point.avg_ttft_ms,
-      }))
-  )
+  const byTs = new Map<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[] {
-  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 point of group.series) {
-      const current = byTs.get(point.ts) ?? { count: 0, success: 0 }
-      current.count += point.count
-      current.success += point.success_count
+      const current = byTs.get(point.ts) ?? { rates: [], incidents: 0 }
+      if (Number.isFinite(point.success_rate)) {
+        current.rates.push(point.success_rate)
+        if (point.success_rate < 100) current.incidents += 1
+      }
       byTs.set(point.ts, current)
     }
   }
   return Array.from(byTs.entries())
     .sort(([a], [b]) => a - b)
     .map(([ts, value]) => {
-      const uptime = value.count > 0 ? (value.success / value.count) * 100 : 0
+      const uptime =
+        value.rates.length > 0
+          ? value.rates.reduce((sum, rate) => sum + rate, 0) /
+            value.rates.length
+          : 0
       return {
         date: new Date(ts * 1000).toISOString(),
         uptime_pct: Math.round(uptime * 100) / 100,
-        incidents: value.success < value.count ? 1 : 0,
+        incidents: value.incidents,
         outage_minutes: 0,
       }
     })
@@ -113,23 +119,20 @@ function toGroupUptimeSeries(group: PerformanceGroup): UptimeDayPoint[] {
   return group.series.map((point) => ({
     date: new Date(point.ts * 1000).toISOString(),
     uptime_pct: Math.round(point.success_rate * 100) / 100,
-    incidents: point.success_count < point.count ? 1 : 0,
+    incidents: point.success_rate < 100 ? 1 : 0,
     outage_minutes: 0,
   }))
 }
 
-function weightedAverage(
+function average(
   rows: PerformanceRow[],
   field: 'avg_ttft_ms' | 'avg_latency_ms'
-): number {
-  let total = 0
-  let count = 0
-  for (const row of rows) {
-    if (row[field] <= 0 || row.request_count <= 0) continue
-    total += row[field] * row.request_count
-    count += row.request_count
-  }
-  return count > 0 ? Math.round(total / count) : 0
+) {
+  const values = rows.map((row) => row[field]).filter((value) => value > 0)
+  if (values.length === 0) return 0
+  return Math.round(
+    values.reduce((sum, value) => sum + value, 0) / values.length
+  )
 }
 
 export function ModelDetailsPerformance(props: { model: PricingModel }) {
@@ -147,7 +150,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
         avg_ttft_ms: group.avg_ttft_ms,
         avg_latency_ms: group.avg_latency_ms,
         success_rate: group.success_rate,
-        request_count: group.request_count,
+        avg_tps: group.avg_tps,
       })),
     [groups]
   )
@@ -169,15 +172,22 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
     )
   }
 
-  const ttftValues = performances
-    .map((p) => p.avg_ttft_ms)
+  const tpsValues = performances
+    .map((p) => p.avg_tps)
     .filter((value) => value > 0)
-  const bestTtft = ttftValues.length > 0 ? Math.min(...ttftValues) : 0
-  const avgLatency = weightedAverage(performances, 'avg_latency_ms')
-  const totalRequests = performances.reduce((s, p) => s + p.request_count, 0)
-  const totalSuccess = groups.reduce((s, p) => s + p.success_count, 0)
+  const avgTps =
+    tpsValues.length > 0
+      ? tpsValues.reduce((sum, value) => sum + value, 0) / tpsValues.length
+      : 0
+  const avgLatency = average(performances, 'avg_latency_ms')
+  const successRates = performances
+    .map((perf) => perf.success_rate)
+    .filter((value) => Number.isFinite(value))
   const successRate =
-    totalRequests > 0 ? (totalSuccess / totalRequests) * 100 : 0
+    successRates.length > 0
+      ? successRates.reduce((sum, value) => sum + value, 0) /
+        successRates.length
+      : 0
   const incidentCount = uptimeSeries.reduce((s, p) => s + p.incidents, 0)
   let intent: 'default' | 'warning' | 'success' = 'warning'
   if (successRate >= 99.9) {
@@ -191,18 +201,17 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
 
   return (
     <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
           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
           icon={Timer}
           label={t('Average latency')}
           value={formatLatency(avgLatency)}
-          hint={t('Across all groups')}
         />
         <StatCard
           icon={HeartPulse}
@@ -217,25 +226,22 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
           }
           intent={intent}
         />
-        <StatCard
-          icon={TrendingUp}
-          label={t('Requests (24h)')}
-          value={COMPACT_NUMBER.format(totalRequests)}
-          hint={t('Aggregated across enabled groups')}
-        />
       </div>
 
       <section>
         <SectionHeader
-          icon={Activity}
+          icon={HeartPulse}
           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'>
           <Table className='text-sm'>
             <TableHeader>
               <TableRow className='hover:bg-transparent'>
                 <TableHead className={headerCellClass}>{t('Group')}</TableHead>
+                <TableHead className={`${headerCellClass} text-right`}>
+                  TPS
+                </TableHead>
                 <TableHead className={`${headerCellClass} text-right`}>
                   {t('Average TTFT')}
                 </TableHead>
@@ -243,46 +249,35 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
                   {t('Average latency')}
                 </TableHead>
                 <TableHead
-                  className={`${headerCellClass} min-w-[160px] text-left`}
+                  className={`${headerCellClass} min-w-[180px] text-left`}
                 >
                   {t('Success rate')}
                 </TableHead>
-                <TableHead className={`${headerCellClass} text-right`}>
-                  {t('Request Count')}
-                </TableHead>
               </TableRow>
             </TableHeader>
             <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>
           </Table>
         </div>
@@ -292,7 +287,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
         <SectionHeader
           icon={Timer}
           title={t('Latency trend (last 24h)')}
-          description={t('Average time-to-first-token (TTFT) by group')}
+          description={t('Average TTFT')}
         />
         <LatencyTrendChart series={latencySeries} />
       </section>
@@ -322,7 +317,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
             ) : null
           }
         />
-        <UptimeBarChart series={uptimeSeries} />
+        <UptimeTrendChart series={uptimeSeries} />
       </section>
     </div>
   )

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

@@ -1,16 +1,10 @@
 import { useMemo } from 'react'
+import { useQuery } from '@tanstack/react-query'
 import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
-import {
-  ArrowLeft,
-  Boxes,
-  Code2,
-  HeartPulse,
-  Info,
-  ReceiptText,
-  Rocket,
-} from 'lucide-react'
+import { ArrowLeft, Code2, HeartPulse, Info, Timer } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { getLobeIcon } from '@/lib/lobe-icon'
+import { cn } from '@/lib/utils'
 import { Button } from '@/components/ui/button'
 import {
   Sheet,
@@ -32,6 +26,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
 import { CopyButton } from '@/components/copy-button'
 import { GroupBadge } from '@/components/group-badge'
 import { PublicLayout } from '@/components/layout'
+import { getPerfMetrics } from '../api'
 import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
 import { usePricingData } from '../hooks/use-pricing-data'
 import {
@@ -42,18 +37,23 @@ import {
 } from '../lib/dynamic-price'
 import { parseTags } from '../lib/filters'
 import {
-  getAvailableGroups,
-  isTokenBasedModel,
-  replaceModelInPath,
-} from '../lib/model-helpers'
+  formatLatency,
+  formatThroughput,
+  formatUptimePct,
+} from '../lib/mock-stats'
+import { getAvailableGroups, isTokenBasedModel } from '../lib/model-helpers'
 import { inferModelMetadata } from '../lib/model-metadata'
 import { formatFixedPrice, formatGroupPrice } from '../lib/price'
-import type { PriceType, PricingModel, TokenUnit } from '../types'
+import type {
+  Modality,
+  ModelCapability,
+  PriceType,
+  PricingModel,
+  TokenUnit,
+} from '../types'
 import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown'
 import { ModelDetailsApi, ModelDetailsProviderInfo } from './model-details-api'
-import { ModelDetailsApps } from './model-details-apps'
-import { ModelDetailsCapabilities } from './model-details-capabilities'
-import { ModalitiesMatrix } from './model-details-modalities'
+import { ModalityIcons } from './model-details-modalities'
 import { ModelDetailsPerformance } from './model-details-performance'
 import { ModelDetailsQuickStats } from './model-details-quick-stats'
 
@@ -69,8 +69,181 @@ function SectionTitle(props: { children: React.ReactNode }) {
   )
 }
 
+const CAPABILITY_LABEL_KEYS: Record<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 }) {
@@ -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)
 // ----------------------------------------------------------------------------
@@ -740,17 +864,7 @@ function GroupPricingSection(props: {
   )
 }
 
-// ----------------------------------------------------------------------------
-// Tabbed details content
-// ----------------------------------------------------------------------------
-
-const TAB_VALUES = [
-  'overview',
-  'pricing',
-  'performance',
-  'api',
-  'apps',
-] as const
+const TAB_VALUES = ['overview', 'performance', 'api'] as const
 type TabValue = (typeof TAB_VALUES)[number]
 
 const TAB_META: Record<
@@ -758,10 +872,8 @@ const TAB_META: Record<
   { icon: React.ComponentType<{ className?: string }>; labelKey: string }
 > = {
   overview: { icon: Info, labelKey: 'Overview' },
-  pricing: { icon: ReceiptText, labelKey: 'Pricing' },
   performance: { icon: HeartPulse, labelKey: 'Performance' },
   api: { icon: Code2, labelKey: 'API' },
-  apps: { icon: Rocket, labelKey: 'Apps' },
 }
 
 export interface ModelDetailsContentProps {
@@ -789,8 +901,6 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
     <div className='@container/details space-y-4'>
       <ModelHeader model={props.model} />
 
-      <ModelDetailsQuickStats metadata={metadata} />
-
       <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'>
           {TAB_VALUES.map((value) => {
@@ -808,59 +918,42 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
           })}
         </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>
 
-          <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 value='performance' className='outline-none'>
@@ -873,10 +966,6 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
             endpointMap={props.endpointMap}
           />
         </TabsContent>
-
-        <TabsContent value='apps' className='outline-none'>
-          <ModelDetailsApps model={props.model} />
-        </TabsContent>
       </Tabs>
     </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 './growth-text'
 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',
 }
 
-/** Stable colour palette for vendors, used in both the area chart and the
+/** Stable colour palette for vendors, used in both the share chart and the
  * legend dots. Falls back to a neutral palette for unknown vendors so that
  * future additions still render. */
 const VENDOR_COLOURS: Record<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 list.
  */
@@ -104,18 +104,15 @@ export function MarketShareSection(props: MarketShareSectionProps) {
   const spec = useMemo(() => {
     if (orderedPoints.length === 0) return null
     return {
-      type: 'area' as const,
+      type: 'bar' as const,
       data: [{ id: 'vendor-share', values: orderedPoints }],
       xField: 'label',
       yField: 'share',
       seriesField: 'vendor',
       stack: true,
+      paddingInner: 0.12,
       legends: { visible: false },
-      area: {
-        style: { fillOpacity: 0.85, curveType: 'monotone' },
-      },
-      line: { style: { lineWidth: 0, curveType: 'monotone' } },
-      point: { visible: false },
+      bar: { style: { cornerRadius: 1 } },
       color: { specified: colourMap },
       axes: [
         {

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

@@ -1,33 +1,28 @@
 import {
   ArrowDownRight,
   ArrowUpRight,
-  Sparkles,
   TrendingDown,
   TrendingUp,
 } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { getLobeIcon } from '@/lib/lobe-icon'
 import { cn } from '@/lib/utils'
-import { formatReleaseDate, formatTokens } from '../lib/format'
-import type { NewModelEntry, RankingMover } from '../types'
+import type { RankingMover } from '../types'
 import { ModelLink, VendorLink } from './entity-links'
 
 type PulseSectionProps = {
   movers: RankingMover[]
   droppers: RankingMover[]
-  newModels: NewModelEntry[]
 }
 
 /**
- * Three-up "Pulse" panel: rank gainers, rank losers, and recently released
- * models — the "what's changing" footer of the rankings page. Each card
- * is intentionally compact so the trio fits in one row on desktop.
+ * Rank movement panel: gainers and losers calculated from the previous period.
  */
 export function PulseSection(props: PulseSectionProps) {
   const { t } = useTranslation()
 
   return (
-    <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
         title={t('Trending up')}
         description={t('Models climbing the leaderboard')}
@@ -59,22 +54,6 @@ export function PulseSection(props: PulseSectionProps) {
           </ul>
         )}
       </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>
   )
 }
@@ -145,28 +124,3 @@ function MoverRow(props: { row: RankingMover; intent: 'up' | 'down' }) {
     </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 +
- * subtitle + period tabs only. Category filtering is no longer needed
- * because every category is rendered inline as its own section further
- * down the page.
+ * subtitle + period tabs only.
  */
 export function RankingsHero(props: RankingsHeroProps) {
   const { t } = useTranslation()
@@ -35,7 +33,7 @@ export function RankingsHero(props: RankingsHeroProps) {
         </h1>
         <p className='text-muted-foreground/80 max-w-2xl text-sm'>
           {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>
       </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 { useTranslation } from 'react-i18next'
+import { Skeleton } from '@/components/ui/skeleton'
 import { PublicLayout } from '@/components/layout'
 import { PageTransition } from '@/components/page-transition'
 import {
-  AppsSection,
-  CategorySections,
   MarketShareSection,
   ModelsSection,
   PulseSection,
@@ -26,7 +25,8 @@ export function Rankings() {
     ? (search.period as RankingPeriod)
     : 'week'
 
-  const snapshot = useRankings(period)
+  const rankingsQuery = useRankings(period)
+  const snapshot = rankingsQuery.data?.data
 
   const handlePeriodChange = (next: RankingPeriod) => {
     navigate({
@@ -56,37 +56,62 @@ export function Rankings() {
         <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} />
 
-          {/* 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>
       </div>
     </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 './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
 // ----------------------------------------------------------------------------
 //
-// Shape of the data shown on the /rankings page. The backend has not yet
-// implemented these analytics endpoints, so the helpers in
-// `lib/mock-rankings.ts` produce deterministic mock values seeded from the
-// (period, category) tuple. When the real APIs land, these types double as
-// the response shape the UI expects.
+// Shape of the real data shown on the /rankings page.
 
 export type RankingPeriod = 'today' | 'week' | 'month' | 'year' | 'all'
 
@@ -24,13 +20,6 @@ export type RankingCategoryId =
   | 'productivity'
   | 'multimodal'
 
-export type RankingCategory = {
-  id: RankingCategoryId
-  /** Default English label, fed through i18n at render time. */
-  label: string
-  description: string
-}
-
 export type ModelRanking = {
   rank: number
   /** Previous rank in the same period; undefined means "new". */
@@ -47,37 +36,6 @@ export type ModelRanking = {
   growth_pct: number
 }
 
-export type AppCategory =
-  | 'Coding'
-  | 'Chat'
-  | 'Productivity'
-  | 'Education'
-  | 'Creative'
-  | 'Roleplay'
-  | 'Translation'
-  | 'Marketing'
-  | 'Health'
-  | 'Finance'
-  | 'Research'
-  | 'Other'
-
-export type AppListing = {
-  rank: number
-  previous_rank?: number
-  name: string
-  description: string
-  category: AppCategory
-  url?: string
-  /** Total tokens this app sent through new-api in the period. */
-  total_tokens: number
-  /** Period-over-period change. */
-  growth_pct: number
-  /** Top model used by this app (model_name). */
-  top_model: string
-  /** Logo letter / initial. */
-  initial: string
-}
-
 export type VendorRanking = {
   rank: number
   vendor: string
@@ -102,17 +60,6 @@ export type RankingMover = {
   growth_pct: number
 }
 
-export type NewModelEntry = {
-  model_name: string
-  vendor: string
-  vendor_icon?: string
-  category: RankingCategoryId
-  release_date: string
-  total_tokens: number
-  /** % growth since the model launched. */
-  growth_pct: number
-}
-
 /**
  * One sample of a model's token usage at a given timestamp.
  * Flat shape ready to feed VChart's stacked-bar spec.
@@ -158,42 +105,16 @@ export type VendorShareSeries = {
   buckets: number
 }
 
-/**
- * Self-contained ranking unit for a single category. Pairs the small
- * stacked-bar chart with the leaderboard data it summarises so
- * `<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 = {
   // Overall (all categories) ------------------------------------------------
   models: ModelRanking[]
-  apps: AppListing[]
   vendors: VendorRanking[]
   /** Largest rank gainers in this period. */
   top_movers: RankingMover[]
   /** Largest rank losers in this period. */
   top_droppers: RankingMover[]
-  /** Newly launched / recently added models. */
-  new_models: NewModelEntry[]
   /** Stacked-bar history of token usage by model over the period. */
   models_history: ModelHistorySeries
   /** 100%-stacked area history of token share by vendor over the period. */
   vendor_share_history: VendorShareSeries
-  // Per-category sections ---------------------------------------------------
-  /** Independent ranking sections, one per non-`all` category. */
-  category_sections: CategorySection[]
 }

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

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

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

@@ -27,6 +27,8 @@ const headerNavSchema = z.object({
   console: z.boolean(),
   pricingEnabled: z.boolean(),
   pricingRequireAuth: z.boolean(),
+  rankingsEnabled: z.boolean(),
+  rankingsRequireAuth: z.boolean(),
   docs: z.boolean(),
   about: z.boolean(),
 })
@@ -53,6 +55,14 @@ const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({
     config.pricing?.requireAuth === undefined
       ? HEADER_NAV_DEFAULT.pricing.requireAuth
       : Boolean(config.pricing.requireAuth),
+  rankingsEnabled:
+    config.rankings?.enabled === undefined
+      ? HEADER_NAV_DEFAULT.rankings.enabled
+      : Boolean(config.rankings.enabled),
+  rankingsRequireAuth:
+    config.rankings?.requireAuth === undefined
+      ? HEADER_NAV_DEFAULT.rankings.requireAuth
+      : Boolean(config.rankings.requireAuth),
   docs:
     config.docs === undefined ? HEADER_NAV_DEFAULT.docs : Boolean(config.docs),
   about:
@@ -90,6 +100,11 @@ export function HeaderNavigationSection({
         enabled: values.pricingEnabled,
         requireAuth: values.pricingRequireAuth,
       },
+      rankings: {
+        ...(config.rankings ?? HEADER_NAV_DEFAULT.rankings),
+        enabled: values.rankingsEnabled,
+        requireAuth: values.rankingsRequireAuth,
+      },
     }
 
     const serialized = serializeHeaderNavModules(payload)
@@ -107,7 +122,7 @@ export function HeaderNavigationSection({
     form.reset(toFormValues(HEADER_NAV_DEFAULT))
   }
 
-  const modules: Array<{
+  const simpleModules: Array<{
     key: keyof HeaderNavFormValues
     title: string
     description: string
@@ -134,6 +149,39 @@ export function HeaderNavigationSection({
     },
   ]
 
+  const accessModules: Array<{
+    enabledKey: keyof HeaderNavFormValues
+    requireAuthKey: keyof HeaderNavFormValues
+    requireAuthDependsOn: 'pricingEnabled' | 'rankingsEnabled'
+    title: string
+    description: string
+    requireAuthTitle: string
+    requireAuthDescription: string
+  }> = [
+    {
+      enabledKey: 'pricingEnabled',
+      requireAuthKey: 'pricingRequireAuth',
+      requireAuthDependsOn: 'pricingEnabled',
+      title: t('Model Square'),
+      description: t('Public model catalog and pricing page.'),
+      requireAuthTitle: t('Require login to view models'),
+      requireAuthDescription: t(
+        'Visitors must authenticate before accessing the pricing directory.'
+      ),
+    },
+    {
+      enabledKey: 'rankingsEnabled',
+      requireAuthKey: 'rankingsRequireAuth',
+      requireAuthDependsOn: 'rankingsEnabled',
+      title: t('Rankings'),
+      description: t('Public rankings page based on live usage data.'),
+      requireAuthTitle: t('Require login to view rankings'),
+      requireAuthDescription: t(
+        'Visitors must authenticate before accessing the rankings page.'
+      ),
+    },
+  ]
+
   return (
     <SettingsSection
       title={t('Header navigation')}
@@ -142,7 +190,7 @@ export function HeaderNavigationSection({
       <Form {...form}>
         <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
           <div className='grid gap-4 md:grid-cols-2'>
-            {modules.map((module) => (
+            {simpleModules.map((module) => (
               <FormField
                 key={module.key}
                 control={form.control}
@@ -168,59 +216,57 @@ export function HeaderNavigationSection({
             ))}
           </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 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'
 }
 
-function truncateExpr(value: string) {
-  if (!value) return ''
-  return value.length > 110 ? `${value.slice(0, 110)}...` : value
-}
-
 function buildPreviewRows(
   values: ModelPricingFormValues,
   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 { useNavigate, getRouteApi } from '@tanstack/react-router'
 import { type Table } from '@tanstack/react-table'
+import { Eye, EyeOff } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { useIsAdmin } from '@/hooks/use-admin'
+import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipTrigger,
+} from '@/components/ui/tooltip'
 import {
   Select,
   SelectContent,
@@ -17,6 +24,7 @@ import { LOG_TYPES } from '../constants'
 import { buildSearchParams } from '../lib/filter'
 import { getDefaultTimeRange } from '../lib/utils'
 import type { CommonLogFilters } from '../types'
+import { CommonLogsStats } from './common-logs-stats'
 import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
 import { useUsageLogsContext } from './usage-logs-provider'
 
@@ -41,7 +49,7 @@ export function CommonLogsFilterBar<TData>(
   const queryClient = useQueryClient()
   const searchParams = route.useSearch()
   const isAdmin = useIsAdmin()
-  const { sensitiveVisible } = useUsageLogsContext()
+  const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
   const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
 
   const [filters, setFilters] = useState<CommonLogFilters>(() => {
@@ -142,9 +150,34 @@ export function CommonLogsFilterBar<TData>(
   const inputClass = 'w-full sm:w-[140px] lg:w-[160px]'
   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 (
     <DataTableToolbar
       table={props.table}
+      leftActions={statsBar}
       customSearch={
         <CompactDateTimeRangePicker
           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 type { NavGroup } from '@/components/layout/types'
 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 {
   UsageLogsProvider,
@@ -106,11 +105,6 @@ function UsageLogsContent() {
         <SectionPageLayout.Description>
           {t(pageMeta.descriptionKey)}
         </SectionPageLayout.Description>
-        {activeCategory === 'common' && (
-          <SectionPageLayout.Actions>
-            <CommonLogsHeaderActions />
-          </SectionPageLayout.Actions>
-        )}
         <SectionPageLayout.Content>
           <div className='space-y-4'>
             {showTaskSwitcher && (

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

@@ -20,6 +20,59 @@ const DEFAULT_HEADER_NAV_MODULES = {
   about: true,
 }
 
+function parseAccessModule(
+  raw: unknown,
+  fallback: { enabled: boolean; requireAuth: boolean }
+) {
+  if (
+    typeof raw === 'boolean' ||
+    typeof raw === 'string' ||
+    typeof raw === 'number'
+  ) {
+    return {
+      enabled: raw === true || raw === 'true' || raw === '1' || raw === 1,
+      requireAuth: fallback.requireAuth,
+    }
+  }
+  if (raw && typeof raw === 'object') {
+    const record = raw as Record<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
  * Backend format example (stringified JSON):
@@ -27,6 +80,7 @@ const DEFAULT_HEADER_NAV_MODULES = {
  *   home: true,
  *   console: true,
  *   pricing: { enabled: true, requireAuth: false },
+ *   rankings: { enabled: true, requireAuth: false },
  *   docs: true,
  *   about: true
  * }
@@ -38,17 +92,7 @@ export function useTopNavLinks(): TopNavLink[] {
 
   // Parse HeaderNavModules
   const modules = useMemo(() => {
-    const raw = status?.HeaderNavModules
-    // If empty string, null, or undefined, use default config
-    if (!raw || (raw as string).trim() === '') {
-      return DEFAULT_HEADER_NAV_MODULES
-    }
-    try {
-      return JSON.parse(raw as string)
-    } catch {
-      // Parse failed, use default config
-      return DEFAULT_HEADER_NAV_MODULES
-    }
+    return parseHeaderNavModules(status?.HeaderNavModules)
   }, [status?.HeaderNavModules])
 
   // Documentation link (may be external)

+ 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 per-category safety overrides as JSON. Use `default` for fallback values.": "Provide per-category safety overrides as JSON. Use `default` for fallback values.",
     "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.",
+    "Public model catalog and pricing page.": "Public model catalog and pricing page.",
+    "Public rankings page based on live usage data.": "Public rankings page based on live usage data.",
     "Provider": "Provider",
     "Provider & data privacy": "Provider & data privacy",
     "Provider created successfully": "Provider created successfully",
@@ -3157,6 +3159,7 @@
     "Require email verification for new accounts": "Require email verification for new accounts",
     "Require job success before follow-up actions": "Require job success before follow-up actions",
     "Require login to view models": "Require login to view models",
+    "Require login to view rankings": "Require login to view rankings",
     "required": "required",
     "Required": "Required",
     "Required events:": "Required events:",
@@ -4125,6 +4128,7 @@
     "Vision, image / video, document chat": "Vision, image / video, document chat",
     "Visit Settings → General and adjust quota options...": "Visit Settings → General and adjust quota options...",
     "Visitors must authenticate before accessing the pricing directory.": "Visitors must authenticate before accessing the pricing directory.",
+    "Visitors must authenticate before accessing the rankings page.": "Visitors must authenticate before accessing the rankings page.",
     "Visual": "Visual",
     "Visual edit": "Visual edit",
     "Visual editor": "Visual editor",

+ 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 per-category safety overrides as JSON. Use `default` for fallback values.": "Fournir des remplacements de sécurité par catégorie au format JSON. Utilisez `default` pour les valeurs de secours.",
     "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Fournir des remplacements d'en-tête par modèle au format JSON. Utile pour activer des fonctionnalités bêta telles que les fenêtres de contexte étendues.",
+    "Public model catalog and pricing page.": "Page publique du catalogue des modèles et des tarifs.",
+    "Public rankings page based on live usage data.": "Page publique des classements basée sur les données d'utilisation réelles.",
     "Provider": "Fournisseur",
     "Provider & data privacy": "Fournisseur & confidentialité",
     "Provider created successfully": "Fournisseur créé avec succès",
@@ -3157,6 +3159,7 @@
     "Require email verification for new accounts": "Exiger la vérification de l'e-mail pour les nouveaux comptes",
     "Require job success before follow-up actions": "Exiger le succès de la tâche avant les actions de suivi",
     "Require login to view models": "Exiger la connexion pour voir les modèles",
+    "Require login to view rankings": "Exiger la connexion pour voir les classements",
     "required": "requis",
     "Required": "Requis",
     "Required events:": "Événements requis :",
@@ -4125,6 +4128,7 @@
     "Vision, image / video, document chat": "Vision, image / vidéo, conversation sur document",
     "Visit Settings → General and adjust quota options...": "Visitez Paramètres → Général et ajustez les options de quota...",
     "Visitors must authenticate before accessing the pricing directory.": "Les visiteurs doivent s'authentifier avant d'accéder au répertoire des prix.",
+    "Visitors must authenticate before accessing the rankings page.": "Les visiteurs doivent s'authentifier avant d'accéder à la page des classements.",
     "Visual": "Visuel",
     "Visual edit": "Édition visuelle",
     "Visual editor": "Éditeur visuel",

+ 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 per-category safety overrides as JSON. Use `default` for fallback values.": "カテゴリごとの安全オーバーライドをJSONとして提供します。フォールバック値には`default`を使用してください。",
     "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "モデルごとのヘッダーオーバーライドをJSONとして提供します。拡張コンテキストウィンドウなどのベータ機能を有効にするのに役立ちます。",
+    "Public model catalog and pricing page.": "モデルカタログと料金の公開ページ。",
+    "Public rankings page based on live usage data.": "実際の利用データに基づく公開ランキングページ。",
     "Provider": "プロバイダ",
     "Provider & data privacy": "プロバイダーとデータ保護",
     "Provider created successfully": "プロバイダーの作成に成功しました",
@@ -3157,6 +3159,7 @@
     "Require email verification for new accounts": "新しいアカウントにメール認証を要求する",
     "Require job success before follow-up actions": "フォローアップ アクション前にジョブの成功を要求",
     "Require login to view models": "モデルを表示するにはログインを要求する",
+    "Require login to view rankings": "ランキングを表示するにはログインを要求する",
     "required": "必須",
     "Required": "必須",
     "Required events:": "必須イベント:",
@@ -4125,6 +4128,7 @@
     "Vision, image / video, document chat": "ビジョン・画像/動画・ドキュメントチャット",
     "Visit Settings → General and adjust quota options...": "「設定」→「一般」にアクセスして、クォータオプションを調整してください...",
     "Visitors must authenticate before accessing the pricing directory.": "訪問者は料金ディレクトリにアクセスする前に認証を行う必要があります。",
+    "Visitors must authenticate before accessing the rankings page.": "訪問者はランキングページにアクセスする前に認証を行う必要があります。",
     "Visual": "ビジュアル",
     "Visual edit": "ビジュアル編集",
     "Visual editor": "ビジュアルエディター",

+ 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 per-category safety overrides as JSON. Use `default` for fallback values.": "Предоставьте переопределения безопасности по категориям в формате JSON. Используйте `default` для резервных значений.",
     "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Предоставьте переопределения заголовков для каждой модели в формате JSON. Полезно для включения бета-функций, таких как расширенные окна контекста.",
+    "Public model catalog and pricing page.": "Публичная страница каталога моделей и цен.",
+    "Public rankings page based on live usage data.": "Публичная страница рейтингов на основе реальных данных использования.",
     "Provider": "Провайдер",
     "Provider & data privacy": "Поставщик и конфиденциальность",
     "Provider created successfully": "Поставщик успешно создан",
@@ -3157,6 +3159,7 @@
     "Require email verification for new accounts": "Требовать подтверждение электронной почты для новых учетных записей",
     "Require job success before follow-up actions": "Требовать успеха задания перед последующими действиями",
     "Require login to view models": "Требовать вход для просмотра моделей",
+    "Require login to view rankings": "Требовать вход для просмотра рейтингов",
     "required": "обязателен",
     "Required": "Обязательно",
     "Required events:": "Обязательные события:",
@@ -4125,6 +4128,7 @@
     "Vision, image / video, document chat": "Зрение, изображения / видео, чат по документам",
     "Visit Settings → General and adjust quota options...": "Перейдите в Настройки → Общие и настройте параметры квоты...",
     "Visitors must authenticate before accessing the pricing directory.": "Посетители должны пройти аутентификацию перед доступом к каталогу цен.",
+    "Visitors must authenticate before accessing the rankings page.": "Посетители должны пройти аутентификацию перед доступом к странице рейтингов.",
     "Visual": "Визуальный",
     "Visual edit": "Визуальное редактирование",
     "Visual editor": "Визуальный редактор",

+ 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 per-category safety overrides as JSON. Use `default` for fallback values.": "Cung cấp các ghi đè an toàn theo từng danh mục dưới dạng JSON. Sử dụng `default` cho các giá trị dự phòng.",
     "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Cung cấp các ghi đè tiêu đề theo từng mô hình dưới dạng JSON. Hữu ích để bật các tính năng beta như cửa sổ ngữ cảnh mở rộng.",
+    "Public model catalog and pricing page.": "Trang công khai cho danh mục mô hình và giá.",
+    "Public rankings page based on live usage data.": "Trang bảng xếp hạng công khai dựa trên dữ liệu sử dụng thực.",
     "Provider": "Nhà cung cấp",
     "Provider & data privacy": "Nhà cung cấp & quyền riêng tư",
     "Provider created successfully": "Đã tạo nhà cung cấp thành công",
@@ -3157,6 +3159,7 @@
     "Require email verification for new accounts": "Yêu cầu xác minh email cho tài khoản mới",
     "Require job success before follow-up actions": "Yêu cầu công việc thành công trước các hành động tiếp theo",
     "Require login to view models": "Yêu cầu đăng nhập để xem các mô hình",
+    "Require login to view rankings": "Yêu cầu đăng nhập để xem bảng xếp hạng",
     "required": "bắt buộc",
     "Required": "Bắt buộc",
     "Required events:": "Sự kiện bắt buộc:",
@@ -4125,6 +4128,7 @@
     "Vision, image / video, document chat": "Thị giác, ảnh / video, hỏi đáp tài liệu",
     "Visit Settings → General and adjust quota options...": "Truy cập Cài đặt → Chung và điều chỉnh tùy chọn hạn mức...",
     "Visitors must authenticate before accessing the pricing directory.": "Khách truy cập phải xác thực trước khi truy cập thư mục giá.",
+    "Visitors must authenticate before accessing the rankings page.": "Khách truy cập phải xác thực trước khi truy cập trang bảng xếp hạng.",
     "Visual": "Trực quan",
     "Visual edit": "Chỉnh sửa trực quan",
     "Visual editor": "Trình sửa trực quan",

+ 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 per-category safety overrides as JSON. Use `default` for fallback values.": "以 JSON 格式提供按类别划分的安全覆盖。使用 `default` 作为回退值。",
     "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "以 JSON 格式提供按模型划分的标头覆盖。可用于启用测试功能,例如扩展上下文窗口。",
+    "Public model catalog and pricing page.": "公开模型目录和价格页面。",
+    "Public rankings page based on live usage data.": "基于真实用量数据的公开排行榜页面。",
     "Provider": "提供商",
     "Provider & data privacy": "厂商与数据隐私",
     "Provider created successfully": "提供商创建成功",
@@ -3157,6 +3159,7 @@
     "Require email verification for new accounts": "要求新账户验证邮箱",
     "Require job success before follow-up actions": "在后续操作前要求任务成功",
     "Require login to view models": "要求登录才能查看模型",
+    "Require login to view rankings": "要求登录才能查看排行榜",
     "required": "必填",
     "Required": "必需",
     "Required events:": "必需事件:",
@@ -4125,6 +4128,7 @@
     "Vision, image / video, document chat": "视觉理解、图像 / 视频、文档对话",
     "Visit Settings → General and adjust quota options...": "访问设置 → 通用并调整配额选项...",
     "Visitors must authenticate before accessing the pricing directory.": "访客必须先进行身份验证才能访问定价目录。",
+    "Visitors must authenticate before accessing the rankings page.": "访客必须先进行身份验证才能访问排行榜页面。",
     "Visual": "可视",
     "Visual edit": "可视化编辑",
     "Visual editor": "可视化编辑器",

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

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