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 { if newAPIError != nil {
gopool.Go(func() { 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"` ModelName string `json:"model_name" gorm:"size:128;uniqueIndex:idx_perf_model_group_bucket,priority:1"`
Group string `json:"group" gorm:"column:group;size:64;uniqueIndex:idx_perf_model_group_bucket,priority:2"` Group string `json:"group" gorm:"column:group;size:64;uniqueIndex:idx_perf_model_group_bucket,priority:2"`
BucketTs int64 `json:"bucket_ts" gorm:"uniqueIndex:idx_perf_model_group_bucket,priority:3;index:idx_perf_bucket_ts"` BucketTs int64 `json:"bucket_ts" gorm:"uniqueIndex:idx_perf_model_group_bucket,priority:3;index:idx_perf_bucket_ts"`
RequestCount int64 `json:"request_count" gorm:"default:0"` RequestCount int64 `json:"-" gorm:"default:0"`
SuccessCount int64 `json:"success_count" gorm:"default:0"` SuccessCount int64 `json:"-" gorm:"default:0"`
TotalLatencyMs int64 `json:"total_latency_ms" gorm:"default:0"` TotalLatencyMs int64 `json:"-" gorm:"default:0"`
TtftSumMs int64 `json:"ttft_sum_ms" gorm:"default:0"` TtftSumMs int64 `json:"-" gorm:"default:0"`
TtftCount int64 `json:"ttft_count" gorm:"default:0"` TtftCount int64 `json:"-" gorm:"default:0"`
OutputTokens int64 `json:"-" gorm:"default:0"`
GenerationMs int64 `json:"-" gorm:"default:0"`
} }
func (PerfMetric) TableName() string { func (PerfMetric) TableName() string {
@ -40,6 +42,8 @@ func UpsertPerfMetric(metric *PerfMetric) error {
"total_latency_ms": gorm.Expr("total_latency_ms + ?", metric.TotalLatencyMs), "total_latency_ms": gorm.Expr("total_latency_ms + ?", metric.TotalLatencyMs),
"ttft_sum_ms": gorm.Expr("ttft_sum_ms + ?", metric.TtftSumMs), "ttft_sum_ms": gorm.Expr("ttft_sum_ms + ?", metric.TtftSumMs),
"ttft_count": gorm.Expr("ttft_count + ?", metric.TtftCount), "ttft_count": gorm.Expr("ttft_count + ?", metric.TtftCount),
"output_tokens": gorm.Expr("output_tokens + ?", metric.OutputTokens),
"generation_ms": gorm.Expr("generation_ms + ?", metric.GenerationMs),
}), }),
}).Create(metric).Error }).Create(metric).Error
} }

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

View File

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

View File

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

View File

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

View File

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

599
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, Other: other,
}) })
gopool.Go(func() { 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. * Hide the View Options (column visibility) dropdown.
*/ */
hideViewOptions?: boolean hideViewOptions?: boolean
/**
* Content rendered on the LEFT side of the secondary action row. When
* provided the toolbar splits into two visual rows:
* Row 1: search inputs / filter chips Expand
* Row 2: expanded filters
* Row 3: leftActions Reset / Search / ViewOptions
*/
leftActions?: ReactNode
/** /**
* Outer wrapper className override. * Outer wrapper className override.
*/ */
@ -216,6 +224,39 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
</Button> </Button>
) : null ) : null
const hasLeftActions = props.leftActions != null
if (hasLeftActions) {
return (
<div className={cn('flex flex-col gap-2', props.className)}>
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
{props.customSearch !== undefined ? props.customSearch : searchInput}
{props.additionalSearch}
{filterChips}
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
{expandToggle}
</div>
</div>
{expanded && hasExpandable && (
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
{props.expandable}
</div>
)}
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
{props.leftActions}
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
{props.preActions}
{resetButton}
{searchButton}
{viewOptionsNode}
</div>
</div>
</div>
)
}
return ( return (
<div <div
className={cn( className={cn(

View File

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

View File

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

View File

@ -1,16 +1,10 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useParams, useSearch } from '@tanstack/react-router' import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
import { import { ArrowLeft, Code2, HeartPulse, Info, Timer } from 'lucide-react'
ArrowLeft,
Boxes,
Code2,
HeartPulse,
Info,
ReceiptText,
Rocket,
} from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon' import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Sheet, Sheet,
@ -32,6 +26,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { CopyButton } from '@/components/copy-button' import { CopyButton } from '@/components/copy-button'
import { GroupBadge } from '@/components/group-badge' import { GroupBadge } from '@/components/group-badge'
import { PublicLayout } from '@/components/layout' import { PublicLayout } from '@/components/layout'
import { getPerfMetrics } from '../api'
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants' import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
import { usePricingData } from '../hooks/use-pricing-data' import { usePricingData } from '../hooks/use-pricing-data'
import { import {
@ -42,18 +37,23 @@ import {
} from '../lib/dynamic-price' } from '../lib/dynamic-price'
import { parseTags } from '../lib/filters' import { parseTags } from '../lib/filters'
import { import {
getAvailableGroups, formatLatency,
isTokenBasedModel, formatThroughput,
replaceModelInPath, formatUptimePct,
} from '../lib/model-helpers' } from '../lib/mock-stats'
import { getAvailableGroups, isTokenBasedModel } from '../lib/model-helpers'
import { inferModelMetadata } from '../lib/model-metadata' import { inferModelMetadata } from '../lib/model-metadata'
import { formatFixedPrice, formatGroupPrice } from '../lib/price' import { formatFixedPrice, formatGroupPrice } from '../lib/price'
import type { PriceType, PricingModel, TokenUnit } from '../types' import type {
Modality,
ModelCapability,
PriceType,
PricingModel,
TokenUnit,
} from '../types'
import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown' import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown'
import { ModelDetailsApi, ModelDetailsProviderInfo } from './model-details-api' import { ModelDetailsApi, ModelDetailsProviderInfo } from './model-details-api'
import { ModelDetailsApps } from './model-details-apps' import { ModalityIcons } from './model-details-modalities'
import { ModelDetailsCapabilities } from './model-details-capabilities'
import { ModalitiesMatrix } from './model-details-modalities'
import { ModelDetailsPerformance } from './model-details-performance' import { ModelDetailsPerformance } from './model-details-performance'
import { ModelDetailsQuickStats } from './model-details-quick-stats' import { ModelDetailsQuickStats } from './model-details-quick-stats'
@ -69,8 +69,181 @@ function SectionTitle(props: { children: React.ReactNode }) {
) )
} }
const CAPABILITY_LABEL_KEYS: Record<ModelCapability, string> = {
function_calling: 'Function calling',
streaming: 'Streaming',
vision: 'Vision',
json_mode: 'JSON mode',
structured_output: 'Structured output',
reasoning: 'Reasoning',
tools: 'Tools',
system_prompt: 'System prompt',
web_search: 'Web search',
code_interpreter: 'Code interpreter',
caching: 'Prompt caching',
embeddings: 'Embeddings',
}
function CompactCapabilityList(props: { capabilities: ModelCapability[] }) {
const { t } = useTranslation()
if (props.capabilities.length === 0) {
return (
<span className='text-muted-foreground text-xs'>
{t('No capabilities reported for this model.')}
</span>
)
}
return (
<div className='flex flex-wrap gap-1.5'>
{props.capabilities.map((capability) => (
<span
key={capability}
className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs font-medium'
>
{t(CAPABILITY_LABEL_KEYS[capability] ?? capability)}
</span>
))}
</div>
)
}
function CompactModalities(props: { input: Modality[]; output: Modality[] }) {
const { t } = useTranslation()
return (
<div className='grid gap-2 sm:grid-cols-2'>
<div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Input')}
</span>
<ModalityIcons modalities={props.input} />
</div>
<div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Output')}
</span>
<ModalityIcons modalities={props.output} />
</div>
</div>
)
}
function ModelSignalsSection(props: {
capabilities: ModelCapability[]
input: Modality[]
output: Modality[]
}) {
const { t } = useTranslation()
return (
<section>
<SectionTitle>
{t('Capabilities')} / {t('Supported modalities')}
</SectionTitle>
<div className='grid gap-3 rounded-xl border p-3 @2xl/details:grid-cols-[minmax(0,1.5fr)_minmax(260px,1fr)]'>
<CompactCapabilityList capabilities={props.capabilities} />
<CompactModalities input={props.input} output={props.output} />
</div>
</section>
)
}
function OverviewMetric(props: {
icon: React.ComponentType<{ className?: string }>
label: string
value: React.ReactNode
intent?: 'default' | 'warning' | 'success'
}) {
const Icon = props.icon
const intent = props.intent ?? 'default'
return (
<div className='flex min-w-0 items-center gap-2 px-3 py-2'>
<Icon className='text-muted-foreground/70 size-3.5 shrink-0' />
<div className='min-w-0 flex-1'>
<div className='text-muted-foreground truncate text-[10px] font-medium tracking-wider uppercase'>
{props.label}
</div>
<div
className={cn(
'text-foreground truncate font-mono text-sm font-semibold tabular-nums',
intent === 'warning' && 'text-amber-600 dark:text-amber-400',
intent === 'success' && 'text-emerald-600 dark:text-emerald-400'
)}
>
{props.value}
</div>
</div>
</div>
)
}
function OverviewSummaryGrid(props: { model: PricingModel }) {
const { t } = useTranslation()
const metricsQuery = useQuery({
queryKey: ['perf-metrics', props.model.model_name],
queryFn: () => getPerfMetrics(props.model.model_name, 24),
staleTime: 60 * 1000,
})
const groups = metricsQuery.data?.data.groups ?? []
const successRates = groups
.map((group) => group.success_rate)
.filter((rate) => Number.isFinite(rate))
const successRate =
successRates.length > 0
? successRates.reduce((sum, rate) => sum + rate, 0) / successRates.length
: Number.NaN
let successIntent: 'default' | 'warning' | 'success' = 'warning'
if (successRate >= 99.9) {
successIntent = 'success'
} else if (successRate >= 99) {
successIntent = 'default'
}
const tpsValues = groups
.map((group) => group.avg_tps)
.filter((value) => value > 0)
const avgTps =
tpsValues.length > 0
? tpsValues.reduce((sum, value) => sum + value, 0) / tpsValues.length
: 0
const latencyValues = groups
.map((group) => group.avg_latency_ms)
.filter((value) => value > 0)
const avgLatency =
latencyValues.length > 0
? Math.round(
latencyValues.reduce((sum, value) => sum + value, 0) /
latencyValues.length
)
: 0
return (
<div className='bg-muted/20 grid overflow-hidden rounded-lg border sm:grid-cols-3 sm:divide-x'>
<OverviewMetric
icon={Timer}
label='TPS'
value={formatThroughput(avgTps)}
/>
<OverviewMetric
icon={Timer}
label={t('Average latency')}
value={formatLatency(avgLatency)}
/>
<OverviewMetric
icon={HeartPulse}
label={t('Success rate')}
value={formatUptimePct(successRate)}
intent={successIntent}
/>
</div>
)
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Model header (always visible above the tabs) // Model header (always visible above the detail sections)
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
function ModelHeader(props: { model: PricingModel }) { function ModelHeader(props: { model: PricingModel }) {
@ -362,55 +535,6 @@ function PriceSection(props: {
) )
} }
// ----------------------------------------------------------------------------
// API endpoints list
// ----------------------------------------------------------------------------
function EndpointsSection(props: {
model: PricingModel
endpointMap: Record<string, { path?: string; method?: string }>
}) {
const { t } = useTranslation()
const endpoints = useMemo(() => {
const types = props.model.supported_endpoint_types || []
return types.map((type) => {
const info = props.endpointMap[type] || {}
let path = info.path || ''
if (path.includes('{model}')) {
path = replaceModelInPath(path, props.model.model_name || '')
}
return { type, path, method: info.method || 'POST' }
})
}, [props.model, props.endpointMap])
if (endpoints.length === 0) return null
return (
<section>
<SectionTitle>{t('API Endpoints')}</SectionTitle>
<div className='space-y-1'>
{endpoints.map(({ type, path, method }) => (
<div key={type} className='flex items-center justify-between py-1'>
<div className='flex items-center gap-2'>
<span className='text-sm font-medium'>{type}</span>
{path && (
<code className='text-muted-foreground/60 text-xs break-all'>
{path}
</code>
)}
</div>
{path && (
<span className='bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-[10px] font-medium uppercase'>
{method}
</span>
)}
</div>
))}
</div>
</section>
)
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Auto group chain (used inside group pricing section) // Auto group chain (used inside group pricing section)
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@ -740,17 +864,7 @@ function GroupPricingSection(props: {
) )
} }
// ---------------------------------------------------------------------------- const TAB_VALUES = ['overview', 'performance', 'api'] as const
// Tabbed details content
// ----------------------------------------------------------------------------
const TAB_VALUES = [
'overview',
'pricing',
'performance',
'api',
'apps',
] as const
type TabValue = (typeof TAB_VALUES)[number] type TabValue = (typeof TAB_VALUES)[number]
const TAB_META: Record< const TAB_META: Record<
@ -758,10 +872,8 @@ const TAB_META: Record<
{ icon: React.ComponentType<{ className?: string }>; labelKey: string } { icon: React.ComponentType<{ className?: string }>; labelKey: string }
> = { > = {
overview: { icon: Info, labelKey: 'Overview' }, overview: { icon: Info, labelKey: 'Overview' },
pricing: { icon: ReceiptText, labelKey: 'Pricing' },
performance: { icon: HeartPulse, labelKey: 'Performance' }, performance: { icon: HeartPulse, labelKey: 'Performance' },
api: { icon: Code2, labelKey: 'API' }, api: { icon: Code2, labelKey: 'API' },
apps: { icon: Rocket, labelKey: 'Apps' },
} }
export interface ModelDetailsContentProps { export interface ModelDetailsContentProps {
@ -789,8 +901,6 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
<div className='@container/details space-y-4'> <div className='@container/details space-y-4'>
<ModelHeader model={props.model} /> <ModelHeader model={props.model} />
<ModelDetailsQuickStats metadata={metadata} />
<Tabs defaultValue='overview' className='gap-4'> <Tabs defaultValue='overview' className='gap-4'>
<TabsList className='bg-muted/60 h-auto w-full justify-start gap-1 overflow-x-auto rounded-lg p-1'> <TabsList className='bg-muted/60 h-auto w-full justify-start gap-1 overflow-x-auto rounded-lg p-1'>
{TAB_VALUES.map((value) => { {TAB_VALUES.map((value) => {
@ -808,59 +918,42 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
})} })}
</TabsList> </TabsList>
<TabsContent value='overview' className='space-y-5 outline-none'> <TabsContent value='overview' className='space-y-6 outline-none'>
<section> <OverviewSummaryGrid model={props.model} />
<div className='mb-3 flex items-center gap-2'>
<Boxes className='text-muted-foreground/70 size-3.5' />
<h3 className='text-foreground text-sm font-semibold'>
{t('Capabilities')}
</h3>
</div>
<ModelDetailsCapabilities capabilities={metadata.capabilities} />
</section>
<section> <section className='bg-card/60 space-y-5 rounded-xl border p-4 shadow-sm'>
<div className='mb-3 flex items-center gap-2'> <SectionTitle>{t('Pricing')}</SectionTitle>
<h3 className='text-foreground text-sm font-semibold'> <PriceSection
{t('Supported modalities')} model={props.model}
</h3> priceRate={props.priceRate}
</div> usdExchangeRate={props.usdExchangeRate}
<ModalitiesMatrix tokenUnit={props.tokenUnit}
input={metadata.input_modalities} showRechargePrice={showRechargePrice}
output={metadata.output_modalities} />
{isDynamic && (
<DynamicPricingBreakdown billingExpr={props.model.billing_expr} />
)}
<GroupPricingSection
model={props.model}
groupRatio={props.groupRatio}
usableGroup={props.usableGroup}
autoGroups={props.autoGroups}
priceRate={props.priceRate}
usdExchangeRate={props.usdExchangeRate}
tokenUnit={props.tokenUnit}
showRechargePrice={showRechargePrice}
/> />
</section> </section>
<ModelDetailsQuickStats metadata={metadata} />
<ModelSignalsSection
capabilities={metadata.capabilities}
input={metadata.input_modalities}
output={metadata.output_modalities}
/>
<ModelDetailsProviderInfo model={props.model} /> <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>
<TabsContent value='performance' className='outline-none'> <TabsContent value='performance' className='outline-none'>
@ -873,10 +966,6 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
endpointMap={props.endpointMap} endpointMap={props.endpointMap}
/> />
</TabsContent> </TabsContent>
<TabsContent value='apps' className='outline-none'>
<ModelDetailsApps model={props.model} />
</TabsContent>
</Tabs> </Tabs>
</div> </div>
) )

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 './entity-links'
export * from './growth-text' export * from './growth-text'
export * from './market-share-section' 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', all: 'Token share by model author since launch',
} }
/** Stable colour palette for vendors, used in both the area chart and the /** Stable colour palette for vendors, used in both the share chart and the
* legend dots. Falls back to a neutral palette for unknown vendors so that * legend dots. Falls back to a neutral palette for unknown vendors so that
* future additions still render. */ * future additions still render. */
const VENDOR_COLOURS: Record<string, string> = { const VENDOR_COLOURS: Record<string, string> = {
@ -77,7 +77,7 @@ type MarketShareSectionProps = {
} }
/** /**
* Combined "Market Share" card: a 100%-stacked area chart showing each * Combined "Market Share" card: a 100%-stacked bar chart showing each
* vendor's slice of total token volume, paired below with a two-column * vendor's slice of total token volume, paired below with a two-column
* vendor list. * vendor list.
*/ */
@ -104,18 +104,15 @@ export function MarketShareSection(props: MarketShareSectionProps) {
const spec = useMemo(() => { const spec = useMemo(() => {
if (orderedPoints.length === 0) return null if (orderedPoints.length === 0) return null
return { return {
type: 'area' as const, type: 'bar' as const,
data: [{ id: 'vendor-share', values: orderedPoints }], data: [{ id: 'vendor-share', values: orderedPoints }],
xField: 'label', xField: 'label',
yField: 'share', yField: 'share',
seriesField: 'vendor', seriesField: 'vendor',
stack: true, stack: true,
paddingInner: 0.12,
legends: { visible: false }, legends: { visible: false },
area: { bar: { style: { cornerRadius: 1 } },
style: { fillOpacity: 0.85, curveType: 'monotone' },
},
line: { style: { lineWidth: 0, curveType: 'monotone' } },
point: { visible: false },
color: { specified: colourMap }, color: { specified: colourMap },
axes: [ axes: [
{ {

View File

@ -1,33 +1,28 @@
import { import {
ArrowDownRight, ArrowDownRight,
ArrowUpRight, ArrowUpRight,
Sparkles,
TrendingDown, TrendingDown,
TrendingUp, TrendingUp,
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon' import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { formatReleaseDate, formatTokens } from '../lib/format' import type { RankingMover } from '../types'
import type { NewModelEntry, RankingMover } from '../types'
import { ModelLink, VendorLink } from './entity-links' import { ModelLink, VendorLink } from './entity-links'
type PulseSectionProps = { type PulseSectionProps = {
movers: RankingMover[] movers: RankingMover[]
droppers: RankingMover[] droppers: RankingMover[]
newModels: NewModelEntry[]
} }
/** /**
* Three-up "Pulse" panel: rank gainers, rank losers, and recently released * Rank movement panel: gainers and losers calculated from the previous period.
* models the "what's changing" footer of the rankings page. Each card
* is intentionally compact so the trio fits in one row on desktop.
*/ */
export function PulseSection(props: PulseSectionProps) { export function PulseSection(props: PulseSectionProps) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<section className='grid grid-cols-1 gap-4 lg:grid-cols-3'> <section className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
<PulseCard <PulseCard
title={t('Trending up')} title={t('Trending up')}
description={t('Models climbing the leaderboard')} description={t('Models climbing the leaderboard')}
@ -59,22 +54,6 @@ export function PulseSection(props: PulseSectionProps) {
</ul> </ul>
)} )}
</PulseCard> </PulseCard>
<PulseCard
title={t('Newly released')}
description={t('Recently launched models')}
icon={<Sparkles className='size-4 text-amber-500' />}
>
{props.newModels.length === 0 ? (
<PulseEmpty label={t('No new models yet')} />
) : (
<ul>
{props.newModels.slice(0, 6).map((row) => (
<NewModelRow key={row.model_name} row={row} />
))}
</ul>
)}
</PulseCard>
</section> </section>
) )
} }
@ -145,28 +124,3 @@ function MoverRow(props: { row: RankingMover; intent: 'up' | 'down' }) {
</li> </li>
) )
} }
function NewModelRow(props: { row: NewModelEntry }) {
return (
<li className='flex items-center gap-3 px-4 py-2'>
<span className='shrink-0'>{getLobeIcon(props.row.vendor_icon, 20)}</span>
<div className='min-w-0 flex-1'>
<ModelLink
modelName={props.row.model_name}
className='text-foreground block truncate font-mono text-xs font-medium'
>
{props.row.model_name}
</ModelLink>
<p className='text-muted-foreground/80 truncate text-[11px]'>
{formatReleaseDate(props.row.release_date)} ·{' '}
<VendorLink vendor={props.row.vendor}>
{props.row.vendor.toLowerCase()}
</VendorLink>
</p>
</div>
<span className='text-foreground shrink-0 font-mono text-xs font-semibold tabular-nums'>
{formatTokens(props.row.total_tokens)}
</span>
</li>
)
}

View File

@ -17,9 +17,7 @@ type RankingsHeroProps = {
/** /**
* Hero strip for the rankings page. Intentionally minimal title + * Hero strip for the rankings page. Intentionally minimal title +
* subtitle + period tabs only. Category filtering is no longer needed * subtitle + period tabs only.
* because every category is rendered inline as its own section further
* down the page.
*/ */
export function RankingsHero(props: RankingsHeroProps) { export function RankingsHero(props: RankingsHeroProps) {
const { t } = useTranslation() const { t } = useTranslation()
@ -35,7 +33,7 @@ export function RankingsHero(props: RankingsHeroProps) {
</h1> </h1>
<p className='text-muted-foreground/80 max-w-2xl text-sm'> <p className='text-muted-foreground/80 max-w-2xl text-sm'>
{t( {t(
'Discover the most-used models, top apps, and rising vendors on the platform — updated continuously across every category.' 'Discover the most-used models and rising vendors on the platform, updated from live usage data.'
)} )}
</p> </p>
</div> </div>

View File

@ -1,15 +1,11 @@
import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query'
import { buildRankingsSnapshot } from '../lib/mock-rankings' import { getRankings } from '../api'
import type { RankingPeriod, RankingsSnapshot } from '../types' import type { RankingPeriod } from '../types'
/** export function useRankings(period: RankingPeriod) {
* Memoised rankings snapshot for a period. return useQuery({
* queryKey: ['rankings', period],
* Currently this synchronously builds deterministic mock data. When the queryFn: () => getRankings(period),
* backend ships real analytics endpoints, swap the body to a staleTime: 5 * 60 * 1000,
* `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])
} }

View File

@ -1,10 +1,9 @@
import { useNavigate, useSearch } from '@tanstack/react-router' import { useNavigate, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Skeleton } from '@/components/ui/skeleton'
import { PublicLayout } from '@/components/layout' import { PublicLayout } from '@/components/layout'
import { PageTransition } from '@/components/page-transition' import { PageTransition } from '@/components/page-transition'
import { import {
AppsSection,
CategorySections,
MarketShareSection, MarketShareSection,
ModelsSection, ModelsSection,
PulseSection, PulseSection,
@ -26,7 +25,8 @@ export function Rankings() {
? (search.period as RankingPeriod) ? (search.period as RankingPeriod)
: 'week' : 'week'
const snapshot = useRankings(period) const rankingsQuery = useRankings(period)
const snapshot = rankingsQuery.data?.data
const handlePeriodChange = (next: RankingPeriod) => { const handlePeriodChange = (next: RankingPeriod) => {
navigate({ navigate({
@ -56,37 +56,62 @@ export function Rankings() {
<PageTransition className='relative mx-auto w-full max-w-[1280px] space-y-8 px-3 pt-16 pb-10 sm:px-6 sm:pt-20 sm:pb-12 xl:px-8'> <PageTransition className='relative mx-auto w-full max-w-[1280px] space-y-8 px-3 pt-16 pb-10 sm:px-6 sm:pt-20 sm:pb-12 xl:px-8'>
<RankingsHero period={period} onPeriodChange={handlePeriodChange} /> <RankingsHero period={period} onPeriodChange={handlePeriodChange} />
{/* Overall (all-categories) view ----------------------------- */} {rankingsQuery.isLoading ? (
<ModelsSection <RankingsLoading />
history={snapshot.models_history} ) : !snapshot ? (
rows={snapshot.models} <RankingsError
period={period} 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 <MarketShareSection
history={snapshot.vendor_share_history} history={snapshot.vendor_share_history}
rows={snapshot.vendors} rows={snapshot.vendors}
period={period} period={period}
/> />
<AppsSection rows={snapshot.apps} /> <PulseSection
movers={snapshot.top_movers}
<PulseSection droppers={snapshot.top_droppers}
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>
</PageTransition> </PageTransition>
</div> </div>
</PublicLayout> </PublicLayout>
) )
} }
function RankingsLoading() {
return (
<div className='space-y-6'>
<Skeleton className='h-[420px] w-full rounded-xl' />
<Skeleton className='h-[360px] w-full rounded-xl' />
<Skeleton className='h-[180px] w-full rounded-xl' />
</div>
)
}
function RankingsError(props: { message: string }) {
const { t } = useTranslation()
return (
<div className='bg-card rounded-xl border border-dashed px-6 py-12 text-center'>
<h2 className='text-foreground text-base font-semibold'>
{t('Unable to load rankings')}
</h2>
<p className='text-muted-foreground mx-auto mt-2 max-w-md text-sm'>
{props.message}
</p>
</div>
)
}

View File

@ -1,2 +1 @@
export * from './format' 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 // Rankings types
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// //
// Shape of the data shown on the /rankings page. The backend has not yet // Shape of the real data shown on the /rankings page.
// 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.
export type RankingPeriod = 'today' | 'week' | 'month' | 'year' | 'all' export type RankingPeriod = 'today' | 'week' | 'month' | 'year' | 'all'
@ -24,13 +20,6 @@ export type RankingCategoryId =
| 'productivity' | 'productivity'
| 'multimodal' | 'multimodal'
export type RankingCategory = {
id: RankingCategoryId
/** Default English label, fed through i18n at render time. */
label: string
description: string
}
export type ModelRanking = { export type ModelRanking = {
rank: number rank: number
/** Previous rank in the same period; undefined means "new". */ /** Previous rank in the same period; undefined means "new". */
@ -47,37 +36,6 @@ export type ModelRanking = {
growth_pct: number growth_pct: number
} }
export type AppCategory =
| 'Coding'
| 'Chat'
| 'Productivity'
| 'Education'
| 'Creative'
| 'Roleplay'
| 'Translation'
| 'Marketing'
| 'Health'
| 'Finance'
| 'Research'
| 'Other'
export type AppListing = {
rank: number
previous_rank?: number
name: string
description: string
category: AppCategory
url?: string
/** Total tokens this app sent through new-api in the period. */
total_tokens: number
/** Period-over-period change. */
growth_pct: number
/** Top model used by this app (model_name). */
top_model: string
/** Logo letter / initial. */
initial: string
}
export type VendorRanking = { export type VendorRanking = {
rank: number rank: number
vendor: string vendor: string
@ -102,17 +60,6 @@ export type RankingMover = {
growth_pct: number growth_pct: number
} }
export type NewModelEntry = {
model_name: string
vendor: string
vendor_icon?: string
category: RankingCategoryId
release_date: string
total_tokens: number
/** % growth since the model launched. */
growth_pct: number
}
/** /**
* One sample of a model's token usage at a given timestamp. * One sample of a model's token usage at a given timestamp.
* Flat shape ready to feed VChart's stacked-bar spec. * Flat shape ready to feed VChart's stacked-bar spec.
@ -158,42 +105,16 @@ export type VendorShareSeries = {
buckets: number buckets: number
} }
/**
* Self-contained ranking unit for a single category. Pairs the small
* stacked-bar chart with the leaderboard data it summarises so
* `<CategorySection>` can render both halves from one prop. Every
* category gets one of these rendered inline on the rankings page.
*/
export type CategorySection = {
category: RankingCategoryId
/** English source label, fed through i18n at render time. */
label: string
/** English source description, fed through i18n at render time. */
description: string
/** Top models in this category, ordered by total tokens desc. */
models: ModelRanking[]
/** Stacked-bar history of token usage by model in this category. */
models_history: ModelHistorySeries
/** Sum of all `models[].total_tokens` (cached for the section header). */
total_tokens: number
}
export type RankingsSnapshot = { export type RankingsSnapshot = {
// Overall (all categories) ------------------------------------------------ // Overall (all categories) ------------------------------------------------
models: ModelRanking[] models: ModelRanking[]
apps: AppListing[]
vendors: VendorRanking[] vendors: VendorRanking[]
/** Largest rank gainers in this period. */ /** Largest rank gainers in this period. */
top_movers: RankingMover[] top_movers: RankingMover[]
/** Largest rank losers in this period. */ /** Largest rank losers in this period. */
top_droppers: RankingMover[] top_droppers: RankingMover[]
/** Newly launched / recently added models. */
new_models: NewModelEntry[]
/** Stacked-bar history of token usage by model over the period. */ /** Stacked-bar history of token usage by model over the period. */
models_history: ModelHistorySeries models_history: ModelHistorySeries
/** 100%-stacked area history of token share by vendor over the period. */ /** 100%-stacked area history of token share by vendor over the period. */
vendor_share_history: VendorShareSeries vendor_share_history: VendorShareSeries
// Per-category sections ---------------------------------------------------
/** Independent ranking sections, one per non-`all` category. */
category_sections: CategorySection[]
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,6 +20,59 @@ const DEFAULT_HEADER_NAV_MODULES = {
about: true, about: true,
} }
function parseAccessModule(
raw: unknown,
fallback: { enabled: boolean; requireAuth: boolean }
) {
if (
typeof raw === 'boolean' ||
typeof raw === 'string' ||
typeof raw === 'number'
) {
return {
enabled: raw === true || raw === 'true' || raw === '1' || raw === 1,
requireAuth: fallback.requireAuth,
}
}
if (raw && typeof raw === 'object') {
const record = raw as Record<string, unknown>
return {
enabled:
typeof record.enabled === 'boolean' ? record.enabled : fallback.enabled,
requireAuth:
typeof record.requireAuth === 'boolean'
? record.requireAuth
: fallback.requireAuth,
}
}
return { ...fallback }
}
function parseHeaderNavModules(
raw: unknown
): typeof DEFAULT_HEADER_NAV_MODULES {
if (!raw || String(raw).trim() === '') {
return DEFAULT_HEADER_NAV_MODULES
}
try {
const parsed = JSON.parse(String(raw)) as Record<string, unknown>
return {
...DEFAULT_HEADER_NAV_MODULES,
...parsed,
pricing: parseAccessModule(
parsed.pricing,
DEFAULT_HEADER_NAV_MODULES.pricing
),
rankings: parseAccessModule(
parsed.rankings,
DEFAULT_HEADER_NAV_MODULES.rankings
),
}
} catch {
return DEFAULT_HEADER_NAV_MODULES
}
}
/** /**
* Generate top navigation links based on HeaderNavModules configuration from backend /api/status * Generate top navigation links based on HeaderNavModules configuration from backend /api/status
* Backend format example (stringified JSON): * Backend format example (stringified JSON):
@ -27,6 +80,7 @@ const DEFAULT_HEADER_NAV_MODULES = {
* home: true, * home: true,
* console: true, * console: true,
* pricing: { enabled: true, requireAuth: false }, * pricing: { enabled: true, requireAuth: false },
* rankings: { enabled: true, requireAuth: false },
* docs: true, * docs: true,
* about: true * about: true
* } * }
@ -38,17 +92,7 @@ export function useTopNavLinks(): TopNavLink[] {
// Parse HeaderNavModules // Parse HeaderNavModules
const modules = useMemo(() => { const modules = useMemo(() => {
const raw = status?.HeaderNavModules return parseHeaderNavModules(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
}
}, [status?.HeaderNavModules]) }, [status?.HeaderNavModules])
// Documentation link (may be external) // 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 Markdown, HTML, or an external URL for the user agreement": "Provide Markdown, HTML, or an external URL for the user agreement",
"Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Provide per-category safety overrides as JSON. Use `default` for fallback values.", "Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Provide per-category safety overrides as JSON. Use `default` for fallback values.",
"Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.", "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.",
"Public model catalog and pricing page.": "Public model catalog and pricing page.",
"Public rankings page based on live usage data.": "Public rankings page based on live usage data.",
"Provider": "Provider", "Provider": "Provider",
"Provider & data privacy": "Provider & data privacy", "Provider & data privacy": "Provider & data privacy",
"Provider created successfully": "Provider created successfully", "Provider created successfully": "Provider created successfully",
@ -3157,6 +3159,7 @@
"Require email verification for new accounts": "Require email verification for new accounts", "Require email verification for new accounts": "Require email verification for new accounts",
"Require job success before follow-up actions": "Require job success before follow-up actions", "Require job success before follow-up actions": "Require job success before follow-up actions",
"Require login to view models": "Require login to view models", "Require login to view models": "Require login to view models",
"Require login to view rankings": "Require login to view rankings",
"required": "required", "required": "required",
"Required": "Required", "Required": "Required",
"Required events:": "Required events:", "Required events:": "Required events:",
@ -4125,6 +4128,7 @@
"Vision, image / video, document chat": "Vision, image / video, document chat", "Vision, image / video, document chat": "Vision, image / video, document chat",
"Visit Settings → General and adjust quota options...": "Visit Settings → General and adjust quota options...", "Visit Settings → General and adjust quota options...": "Visit Settings → General and adjust quota options...",
"Visitors must authenticate before accessing the pricing directory.": "Visitors must authenticate before accessing the pricing directory.", "Visitors must authenticate before accessing the pricing directory.": "Visitors must authenticate before accessing the pricing directory.",
"Visitors must authenticate before accessing the rankings page.": "Visitors must authenticate before accessing the rankings page.",
"Visual": "Visual", "Visual": "Visual",
"Visual edit": "Visual edit", "Visual edit": "Visual edit",
"Visual editor": "Visual editor", "Visual editor": "Visual editor",

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

View File

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

View File

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

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

View File

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

View File

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