feat(default): add real rankings data

This commit is contained in:
CaIon 2026-05-06 18:20:02 +08:00
parent 0f9f094a48
commit f8cf9c57c4
No known key found for this signature in database
GPG Key ID: 0CFA613529A9921D
41 changed files with 1498 additions and 1912 deletions

24
controller/rankings.go Normal file
View File

@ -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,
})
}

View File

@ -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)
})
}
}

View File

@ -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
model/usedata_rankings.go Normal file
View File

@ -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
}

View File

@ -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"]),
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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
service/rankings.go Normal file
View File

@ -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
}

View File

@ -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))
})
}

View File

@ -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(

View File

@ -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[]
}

View File

@ -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',

View File

@ -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>
)

View File

@ -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>
<TabsContent value='overview' className='space-y-6 outline-none'>
<OverviewSummaryGrid model={props.model} />
<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}
<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>
<ModelDetailsQuickStats metadata={metadata} />
<ModelSignalsSection
capabilities={metadata.capabilities}
input={metadata.input_modalities}
output={metadata.output_modalities}
/>
<ModelDetailsProviderInfo model={props.model} />
<PriceSection
model={props.model}
priceRate={props.priceRate}
usdExchangeRate={props.usdExchangeRate}
tokenUnit={props.tokenUnit}
showRechargePrice={showRechargePrice}
/>
<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}
/>
</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>
)

View File

@ -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
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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'

View File

@ -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: [
{

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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,
})
}

View File

@ -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>
)
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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[]
}

View File

@ -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
}

View File

@ -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>
)}
/>
<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='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>
)}
/>
<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'>

View File

@ -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,

View File

@ -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}

View File

@ -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 && (

View File

@ -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)

View File

@ -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",

View File

@ -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",

View File

@ -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": "ビジュアルエディター",

View File

@ -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": "Визуальный редактор",

View File

@ -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",

View File

@ -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": "可视化编辑器",

View File

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