From ecd8723b8134ca56f9fffb447d1e1f1fe59ca10f Mon Sep 17 00:00:00 2001 From: t Date: Tue, 23 Jun 2026 17:20:19 +0800 Subject: [PATCH 01/11] Fix: replace all model display names with CPA model alias - Request event log: show model_alias in the model column, fall back to original name - Model filter dropdown: returns {value, label} pairs, label prefers alias - Realtime panel Top5: uses model_alias as display label - Analysis page (pie chart, efficiency scatter, heatmap): replace original model name with alias via loadModelAliasMap() post-processing - Cost calculation: new lookupPricingByModelOrAlias() falls back to alias when original model name has no price entry - Window stats (quota window cost): includes model_alias in GROUP BY and pricing lookup - All backend layers (repo DTO, service DTO, API payload) carry ModelAlias through Also fixes duplicate ServiceTier field in usageEventProjectionToRecord. --- internal/api/usage_events.go | 27 +++- internal/api/usage_events_test.go | 2 +- internal/repository/dto/usage_events.go | 4 +- internal/repository/usage.go | 134 +++++++++++++++--- internal/repository/usage_window_stats.go | 29 ++-- internal/service/dto/usage.go | 4 +- internal/service/usage.go | 3 +- .../usage/RequestEventsDetailsCard.test.tsx | 2 +- .../usage/RequestEventsDetailsCard.tsx | 9 +- web/src/lib/types.ts | 8 +- web/src/pages/UsagePage.logic.test.ts | 4 +- web/src/pages/UsagePage.tsx | 8 +- 12 files changed, 181 insertions(+), 53 deletions(-) diff --git a/internal/api/usage_events.go b/internal/api/usage_events.go index faa33238..4cb87c13 100644 --- a/internal/api/usage_events.go +++ b/internal/api/usage_events.go @@ -29,6 +29,11 @@ type usageSourceFilterOption struct { DisplayName string `json:"displayName"` } +type modelFilterOption struct { + Value string `json:"value"` + Label string `json:"label"` +} + type usageEventFilterOptionsResponse struct { Models []string `json:"models"` Sources []usageSourceFilterOption `json:"sources"` @@ -39,6 +44,7 @@ type usageEventPayload struct { Timestamp string `json:"timestamp"` APIKey string `json:"api_key,omitempty"` Model string `json:"model"` + ModelAlias *string `json:"model_alias,omitempty"` ReasoningEffort string `json:"reasoning_effort,omitempty"` ServiceTier string `json:"service_tier,omitempty"` ExecutorType string `json:"executor_type,omitempty"` @@ -75,12 +81,20 @@ func registerUsageEventsRoute( cpaAPIKeyProvider service.CPAAPIKeyProvider, ) { router.GET("/usage/events/filters/models", func(c *gin.Context) { - models, err := loadUsageEventModelFilterOptions(c, usageProvider) + models, labels, err := loadUsageEventModelFilterOptions(c, usageProvider) if err != nil { writeInternalError(c, "list usage event model filter options failed", err) return } - c.JSON(http.StatusOK, gin.H{"models": models}) + options := make([]modelFilterOption, 0, len(models)) + for _, model := range models { + label := model + if alias, ok := labels[model]; ok { + label = alias + } + options = append(options, modelFilterOption{Value: model, Label: label}) + } + c.JSON(http.StatusOK, gin.H{"models": options}) }) router.GET("/usage/events/filters/sources", func(c *gin.Context) { @@ -165,6 +179,7 @@ func buildUsageEventsPayload(rows []servicedto.UsageEventRecord, resolver usageI ID: id, Timestamp: timeutil.FormatStorageTime(row.Timestamp), APIKey: usageEventAPIKeyLabel(row.APIGroupKey, apiKeyInfos), + ModelAlias: row.ModelAlias, Model: row.Model, ReasoningEffort: strings.TrimSpace(row.ReasoningEffort), ServiceTier: strings.TrimSpace(row.ServiceTier), @@ -231,15 +246,15 @@ func usageEventPublicSource(row servicedto.UsageEventRecord, identity resolvedUs } } -func loadUsageEventModelFilterOptions(c *gin.Context, usageProvider service.UsageProvider) ([]string, error) { +func loadUsageEventModelFilterOptions(c *gin.Context, usageProvider service.UsageProvider) ([]string, map[string]string, error) { if usageProvider == nil { - return []string{}, nil + return []string{}, nil, nil } options, err := usageProvider.ListUsageEventFilterOptions(c.Request.Context(), servicedto.UsageFilter{}) if err != nil { - return nil, err + return nil, nil, err } - return options.Models, nil + return options.Models, options.ModelLabels, nil } func loadUsageEventSourceFilterOptions(c *gin.Context, usageIdentityProvider service.UsageIdentityProvider) ([]usageSourceFilterOption, error) { diff --git a/internal/api/usage_events_test.go b/internal/api/usage_events_test.go index 2955bf46..bf2ed81f 100644 --- a/internal/api/usage_events_test.go +++ b/internal/api/usage_events_test.go @@ -525,7 +525,7 @@ func TestUsageEventModelFilterOptionsReturnsStableModels(t *testing.T) { t.Fatalf("expected model filters endpoint to ignore query filters, got %+v", provider.lastFilter) } body := resp.Body.String() - if body != `{"models":["claude-sonnet","gpt-5"]}` { + if body != `{"models":[{"value":"claude-sonnet","label":"claude-sonnet"},{"value":"gpt-5","label":"gpt-5"}]}` { t.Fatalf("expected stable model filter options, got %s", body) } } diff --git a/internal/repository/dto/usage_events.go b/internal/repository/dto/usage_events.go index f1e240d1..b0db8033 100644 --- a/internal/repository/dto/usage_events.go +++ b/internal/repository/dto/usage_events.go @@ -14,7 +14,8 @@ type UsageEventsPageRecord struct { // UsageEventFilterOptionsRecord 是 usage events 筛选项的仓储查询结果。 type UsageEventFilterOptionsRecord struct { - Models []string + Models []string + ModelLabels map[string]string } // UsageEventRecord 是单条 usage event 的查询结果。 @@ -23,6 +24,7 @@ type UsageEventRecord struct { Timestamp time.Time APIGroupKey string Model string + ModelAlias *string ReasoningEffort string ServiceTier string ExecutorType string diff --git a/internal/repository/usage.go b/internal/repository/usage.go index beb68ca8..08a30695 100644 --- a/internal/repository/usage.go +++ b/internal/repository/usage.go @@ -16,7 +16,7 @@ import ( ) // usageEventProjectionColumns 限制 usage_events 查询列,避免 Overview 和列表页把 RawJSON 等大字段读入内存。 -const usageEventProjectionColumns = "id, api_group_key, provider, auth_type, model, reasoning_effort, service_tier, executor_type, endpoint, timestamp, source, auth_index, failed, latency_ms, ttft_ms, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, total_tokens" +const usageEventProjectionColumns = "id, api_group_key, provider, auth_type, model, model_alias, reasoning_effort, service_tier, executor_type, endpoint, timestamp, source, auth_index, failed, latency_ms, ttft_ms, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, total_tokens" const analysisLatencyMaxDisplayPoints = 2500 // usageOverviewRawEventProjectionColumns 是 Overview 边界补偿和 realtime DB 兜底的最小事件投影。 @@ -29,6 +29,7 @@ type usageEventProjection struct { Provider string AuthType string Model string + ModelAlias *string ReasoningEffort string ServiceTier string ExecutorType string @@ -123,7 +124,8 @@ func ListUsageEventFilterOptionsWithFilter(db *gorm.DB, filter dto.UsageQueryFil if err != nil { return nil, err } - return &dto.UsageEventFilterOptionsRecord{Models: models}, nil + modelLabels := loadModelAliasMap(db) + return &dto.UsageEventFilterOptionsRecord{Models: models, ModelLabels: modelLabels}, nil } func listUsageEventModelFilterOptions(db *gorm.DB, filter dto.UsageQueryFilter) ([]string, error) { @@ -146,11 +148,21 @@ func queryUsageEvents(db *gorm.DB) *gorm.DB { // usageEventProjectionToRecord 把数据库投影转换成 Request Event Log 的外部 DTO。 func usageEventProjectionToRecord(event usageEventProjection) dto.UsageEventRecord { // 对前端展示字段统一 trim,避免历史脏数据影响筛选和展示一致性。 + modelAlias := event.ModelAlias + if modelAlias != nil { + trimmed := strings.TrimSpace(*modelAlias) + if trimmed == "" { + modelAlias = nil + } else { + modelAlias = &trimmed + } + } return dto.UsageEventRecord{ ID: event.ID, Timestamp: timeutil.NormalizeStorageTime(event.Timestamp), APIGroupKey: strings.TrimSpace(event.APIGroupKey), Model: strings.TrimSpace(event.Model), + ModelAlias: modelAlias, ReasoningEffort: strings.TrimSpace(event.ReasoningEffort), ServiceTier: strings.TrimSpace(event.ServiceTier), ExecutorType: strings.TrimSpace(event.ExecutorType), @@ -173,7 +185,8 @@ func usageEventProjectionToRecord(event usageEventProjection) dto.UsageEventReco } func usageEventRecordCost(record dto.UsageEventRecord, pricingByModel map[string]entities.ModelPriceSetting) (float64, bool, string) { - pricing, ok := pricingByModel[strings.TrimSpace(record.Model)] + model := strings.TrimSpace(record.Model) + pricing, ok := lookupPricingByModelOrAlias(pricingByModel, model, record.ModelAlias) input := helper.UsageTokenCostInput{ InputTokens: record.InputTokens, OutputTokens: record.OutputTokens, @@ -305,6 +318,24 @@ func BuildAnalysisWithFilter(db *gorm.DB, filter dto.UsageQueryFilter) (*dto.Ana } record.LatencyDiagnostics = latencyDiagnostics + // 把分析结果中的 model 原名替换成 alias 展示名。 + aliasMap := loadModelAliasMap(db) + for i, item := range record.ModelComposition { + if alias, ok := aliasMap[item.Key]; ok { + record.ModelComposition[i].Label = alias + } + } + for i, item := range record.ModelEfficiency { + if alias, ok := aliasMap[item.Model]; ok { + record.ModelEfficiency[i].Model = alias + } + } + for i, cell := range record.Heatmap { + if alias, ok := aliasMap[cell.Model]; ok { + record.Heatmap[i].Model = alias + } + } + fullStart, fullEnd := usageOverviewFullHourWindow(*filter.StartTime, *filter.EndTime) fullEnd = analysisHourlyStatsEnd(filter, fullEnd) if !fullEnd.After(fullStart) { @@ -553,7 +584,8 @@ func applyAnalysisHourlyRows(record *dto.AnalysisRecord, rows []entities.UsageOv heatmapTotals := map[analysisHeatmapKey]*dto.AnalysisHeatmapRecord{} for _, row := range rows { bucket := timeutil.NormalizeStorageTime(row.BucketStart).Truncate(time.Hour) - cost, costAvailable := analysisRowCost(row.Model, row.InputTokens, row.OutputTokens, row.CachedTokens, row.CacheReadTokens, row.CacheCreationTokens, pricingByModel) + modelAlias := usageOverviewModelAliasPtr(row.ModelAlias) + cost, costAvailable := analysisRowCost(row.Model, modelAlias, row.InputTokens, row.OutputTokens, row.CachedTokens, row.CacheReadTokens, row.CacheCreationTokens, pricingByModel) applyAnalysisRow(record, bucketTotals, apiTotals, modelTotals, heatmapTotals, bucket, row.APIGroupKey, row.Model, row.RequestCount, row.InputTokens, row.OutputTokens, row.CachedTokens, row.ReasoningTokens, row.TotalTokens, cost, costAvailable) applyAnalysisIdentityComposition(identityLookup, authFileTotals, aiProviderTotals, row.AuthIndex, row.RequestCount, row.InputTokens, row.OutputTokens, row.CachedTokens, row.ReasoningTokens, row.TotalTokens, cost, costAvailable) } @@ -569,21 +601,21 @@ func applyAnalysisDailyAndBoundaryHourlyRows(record *dto.AnalysisRecord, dailyRo heatmapTotals := map[analysisHeatmapKey]*dto.AnalysisHeatmapRecord{} for _, row := range dailyRows { bucket := timeutil.NormalizeStorageTime(row.BucketStart) - cost, costAvailable := analysisRowCost(row.Model, row.InputTokens, row.OutputTokens, row.CachedTokens, row.CacheReadTokens, row.CacheCreationTokens, pricingByModel) + cost, costAvailable := analysisRowCost(row.Model, usageOverviewModelAliasPtr(row.ModelAlias), row.InputTokens, row.OutputTokens, row.CachedTokens, row.CacheReadTokens, row.CacheCreationTokens, pricingByModel) applyAnalysisRow(record, bucketTotals, apiTotals, modelTotals, heatmapTotals, bucket, row.APIGroupKey, row.Model, row.RequestCount, row.InputTokens, row.OutputTokens, row.CachedTokens, row.ReasoningTokens, row.TotalTokens, cost, costAvailable) applyAnalysisIdentityComposition(dailyIdentityLookup, authFileTotals, aiProviderTotals, row.AuthIndex, row.RequestCount, row.InputTokens, row.OutputTokens, row.CachedTokens, row.ReasoningTokens, row.TotalTokens, cost, costAvailable) } for _, row := range hourlyRows { bucketStart := timeutil.NormalizeStorageTime(row.BucketStart) bucket := time.Date(bucketStart.Year(), bucketStart.Month(), bucketStart.Day(), 0, 0, 0, 0, bucketStart.Location()) - cost, costAvailable := analysisRowCost(row.Model, row.InputTokens, row.OutputTokens, row.CachedTokens, row.CacheReadTokens, row.CacheCreationTokens, pricingByModel) + cost, costAvailable := analysisRowCost(row.Model, usageOverviewModelAliasPtr(row.ModelAlias), row.InputTokens, row.OutputTokens, row.CachedTokens, row.CacheReadTokens, row.CacheCreationTokens, pricingByModel) applyAnalysisRow(record, bucketTotals, apiTotals, modelTotals, heatmapTotals, bucket, row.APIGroupKey, row.Model, row.RequestCount, row.InputTokens, row.OutputTokens, row.CachedTokens, row.ReasoningTokens, row.TotalTokens, cost, costAvailable) applyAnalysisIdentityComposition(hourlyIdentityLookup, authFileTotals, aiProviderTotals, row.AuthIndex, row.RequestCount, row.InputTokens, row.OutputTokens, row.CachedTokens, row.ReasoningTokens, row.TotalTokens, cost, costAvailable) } finalizeAnalysisRecord(record, bucketTotals, apiTotals, modelTotals, authFileTotals, aiProviderTotals, heatmapTotals) } -func analysisRowCost(model string, inputTokens, outputTokens, cachedTokens, cacheReadTokens, cacheCreationTokens int64, pricingByModel map[string]entities.ModelPriceSetting) (helper.UsageTokenCostBreakdown, bool) { +func analysisRowCost(model string, modelAlias *string, inputTokens, outputTokens, cachedTokens, cacheReadTokens, cacheCreationTokens int64, pricingByModel map[string]entities.ModelPriceSetting) (helper.UsageTokenCostBreakdown, bool) { costInput := helper.UsageTokenCostInput{ InputTokens: inputTokens, OutputTokens: outputTokens, @@ -591,13 +623,30 @@ func analysisRowCost(model string, inputTokens, outputTokens, cachedTokens, cach CacheReadTokens: cacheReadTokens, CacheCreationTokens: cacheCreationTokens, } - pricing, ok := pricingByModel[strings.TrimSpace(model)] + pricing, ok := lookupPricingByModelOrAlias(pricingByModel, model, modelAlias) if !ok { return helper.UsageTokenCostBreakdown{}, !helper.UsageTokenInputRequiresPricing(costInput) } return helper.CalculateUsageTokenCostBreakdown(costInput, pricing), true } +// lookupPricingByModelOrAlias 按 model 查价格;如果找不到且 modelAlias 非空,则按 alias 再查一次。 +func lookupPricingByModelOrAlias(pricingByModel map[string]entities.ModelPriceSetting, model string, modelAlias *string) (entities.ModelPriceSetting, bool) { + pricing, ok := pricingByModel[strings.TrimSpace(model)] + if ok { + return pricing, true + } + if modelAlias != nil { + if alias := strings.TrimSpace(*modelAlias); alias != "" { + pricing, ok = pricingByModel[alias] + if ok { + return pricing, true + } + } + } + return entities.ModelPriceSetting{}, false +} + func applyAnalysisRow(record *dto.AnalysisRecord, bucketTotals map[time.Time]*dto.AnalysisTokenUsageBucketRecord, apiTotals, modelTotals map[string]*dto.AnalysisCompositionRecord, heatmapTotals map[analysisHeatmapKey]*dto.AnalysisHeatmapRecord, bucket time.Time, apiGroupKey, model string, requests, inputTokens, outputTokens, cachedTokens, reasoningTokens, totalTokens int64, cost helper.UsageTokenCostBreakdown, costAvailable bool) { apiKey := normalizeUsageOverviewDimension(apiGroupKey) modelName := normalizeUsageOverviewDimension(model) @@ -1354,8 +1403,9 @@ func applyUsageOverviewHourlyStatToOverview(overview *dto.UsageOverviewRecord, r applyUsageOverviewHourlyStatToSnapshot(overview.Usage, row) // cost 不入 stats 表,必须在读取时按当前价格表重新计算。 costInput := helper.UsageTokenCostInput{InputTokens: row.InputTokens, OutputTokens: row.OutputTokens, CachedTokens: row.CachedTokens, CacheReadTokens: row.CacheReadTokens, CacheCreationTokens: row.CacheCreationTokens} - rowCost := helper.CalculateUsageTokenCost(costInput, pricingByModel[strings.TrimSpace(row.Model)]) - if _, ok := pricingByModel[strings.TrimSpace(row.Model)]; !ok && helper.UsageTokenInputRequiresPricing(costInput) { + pricing, pricingFound := lookupPricingByModelOrAlias(pricingByModel, row.Model, usageOverviewModelAliasPtr(row.ModelAlias)) + rowCost := helper.CalculateUsageTokenCost(costInput, pricing) + if !pricingFound && helper.UsageTokenInputRequiresPricing(costInput) { overview.Summary.CostAvailable = false } applyUsageOverviewStatToSummary(overview, row.RequestCount, row.InputTokens, row.CachedTokens, row.ReasoningTokens, rowCost) @@ -1370,8 +1420,9 @@ func applyUsageOverviewDailyStatToOverview(overview *dto.UsageOverviewRecord, ro // 天 stats 只覆盖完整本地天,不能用于非整天边界。 applyUsageOverviewDailyStatToSnapshot(overview.Usage, row) costInput := helper.UsageTokenCostInput{InputTokens: row.InputTokens, OutputTokens: row.OutputTokens, CachedTokens: row.CachedTokens, CacheReadTokens: row.CacheReadTokens, CacheCreationTokens: row.CacheCreationTokens} - rowCost := helper.CalculateUsageTokenCost(costInput, pricingByModel[strings.TrimSpace(row.Model)]) - if _, ok := pricingByModel[strings.TrimSpace(row.Model)]; !ok && helper.UsageTokenInputRequiresPricing(costInput) { + pricing, pricingFound := lookupPricingByModelOrAlias(pricingByModel, row.Model, usageOverviewModelAliasPtr(row.ModelAlias)) + rowCost := helper.CalculateUsageTokenCost(costInput, pricing) + if !pricingFound && helper.UsageTokenInputRequiresPricing(costInput) { overview.Summary.CostAvailable = false } applyUsageOverviewStatToSummary(overview, row.RequestCount, row.InputTokens, row.CachedTokens, row.ReasoningTokens, rowCost) @@ -1787,6 +1838,16 @@ func usageOverviewRealtimeBucketIndex(timestamp, start time.Time, span time.Dura return index } +// usageOverviewRealtimeModelLabel 优先取 model_alias 作为实时模型展示名,没有 alias 时回退到 model。 +func usageOverviewRealtimeModelLabel(model string, modelAlias *string) string { + if modelAlias != nil { + if alias := strings.TrimSpace(*modelAlias); alias != "" { + return alias + } + } + return normalizeUsageOverviewDimension(model) +} + func collectRealtimeAuthIndexes(events []usageOverviewRealtimeEvent, visibleStart time.Time) []string { // usage_identities 查询只需要 auth_index,先去重减少 IN 参数数量。 seen := map[string]struct{}{} @@ -1813,8 +1874,9 @@ func collectRealtimeAuthIndexes(events []usageOverviewRealtimeEvent, visibleStar func applyUsageOverviewRealtimeRequest(realtimeEvent usageOverviewRealtimeEvent, modelUsage, apiKeyUsage, authFileUsage, aiProviderUsage map[string]*usageOverviewRealtimeTopAccumulator, identityLookup analysisIdentityLookup) { event := realtimeEvent.event - // 模型维度的请求数不区分成功失败。 - applyUsageOverviewRealtimeRequestToTotals(modelUsage, normalizeUsageOverviewDimension(event.Model), normalizeUsageOverviewDimension(event.Model)) + // 模型维度的请求数不区分成功失败,label 优先使用 model_alias。 + modelLabel := usageOverviewRealtimeModelLabel(event.Model, event.ModelAlias) + applyUsageOverviewRealtimeRequestToTotals(modelUsage, normalizeUsageOverviewDimension(event.Model), modelLabel) // API Key 维度使用 api_group_key,KeyOverview 前端会隐藏这个 tab。 applyUsageOverviewRealtimeRequestToTotals(apiKeyUsage, normalizeUsageOverviewDimension(event.APIGroupKey), normalizeUsageOverviewDimension(event.APIGroupKey)) // Auth File / AI Provider 维度先走身份表,缺失时再用缓存 fallback。 @@ -1829,8 +1891,9 @@ func applyUsageOverviewRealtimeRequestToTotals(totals map[string]*usageOverviewR func applyUsageOverviewRealtimeTokenUsage(realtimeEvent usageOverviewRealtimeEvent, cost float64, costAvailable bool, modelUsage, apiKeyUsage, authFileUsage, aiProviderUsage map[string]*usageOverviewRealtimeTopAccumulator, identityLookup analysisIdentityLookup) { event := realtimeEvent.event - // token share 的模型维度只统计成功且有 token 的请求。 - applyUsageOverviewRealtimeTokenUsageToTotals(modelUsage, normalizeUsageOverviewDimension(event.Model), normalizeUsageOverviewDimension(event.Model), event.TotalTokens, cost, costAvailable) + // token share 的模型维度只统计成功且有 token 的请求,label 优先使用 model_alias。 + modelLabel := usageOverviewRealtimeModelLabel(event.Model, event.ModelAlias) + applyUsageOverviewRealtimeTokenUsageToTotals(modelUsage, normalizeUsageOverviewDimension(event.Model), modelLabel, event.TotalTokens, cost, costAvailable) // token share 的 API Key 维度同样按 api_group_key 聚合。 applyUsageOverviewRealtimeTokenUsageToTotals(apiKeyUsage, normalizeUsageOverviewDimension(event.APIGroupKey), normalizeUsageOverviewDimension(event.APIGroupKey), event.TotalTokens, cost, costAvailable) // 身份维度 token 聚合保持和请求数相同的身份解析策略。 @@ -2249,7 +2312,7 @@ func applyUsageEventToOverview(overview *dto.UsageOverviewRecord, event entities overview.Health.TotalSuccess++ } // 边界事件也按当前价格表计算 cost;缺价格且有计费 token 时标记 cost 不完整。 - pricing, ok := pricingByModel[strings.TrimSpace(event.Model)] + pricing, ok := lookupPricingByModelOrAlias(pricingByModel, event.Model, event.ModelAlias) if !ok && helper.UsageEventRequiresPricing(event) { overview.Summary.CostAvailable = false } @@ -2307,6 +2370,35 @@ func normalizeUsageOverviewDimension(value string) string { return trimmed } +// loadModelAliasMap 从 usage_events 读取最新一条 alias 映射,供分析页和筛选项使用 alias 代替原名展示。 +func loadModelAliasMap(db *gorm.DB) map[string]string { + if db == nil { + return nil + } + type modelAliasRow struct { + Model string + ModelAlias *string `gorm:"column:model_alias"` + } + var rows []modelAliasRow + subQuery := db.Model(&entities.UsageEvent{}). + Select("MAX(id)"). + Where("model_alias IS NOT NULL AND model_alias != ''"). + Group("model") + if err := db.Model(&entities.UsageEvent{}). + Select("model, model_alias"). + Where("id IN (?)", subQuery). + Find(&rows).Error; err != nil { + return nil + } + result := make(map[string]string, len(rows)) + for _, row := range rows { + if row.ModelAlias != nil && strings.TrimSpace(*row.ModelAlias) != "" { + result[strings.TrimSpace(row.Model)] = strings.TrimSpace(*row.ModelAlias) + } + } + return result +} + // loadPriceSettingsByModel 把当前价格配置转成按 model 查找的 map。 func loadPriceSettingsByModel(db *gorm.DB) (map[string]entities.ModelPriceSetting, error) { settings, err := ListModelPriceSettings(db) @@ -2320,6 +2412,14 @@ func loadPriceSettingsByModel(db *gorm.DB) (map[string]entities.ModelPriceSettin return result, nil } +// usageOverviewModelAliasPtr 把 stats 行的 ModelAlias string 转成 *string,便于传递给 lookupPricingByModelOrAlias。 +func usageOverviewModelAliasPtr(alias string) *string { + if strings.TrimSpace(alias) == "" { + return nil + } + return &alias +} + const ( usageOverviewDailyAverageDayMinutes int64 = 24 * 60 usageOverviewDailyBucketThresholdMinutes int64 = 7 * 24 * 60 diff --git a/internal/repository/usage_window_stats.go b/internal/repository/usage_window_stats.go index dd02f21d..6121aa7e 100644 --- a/internal/repository/usage_window_stats.go +++ b/internal/repository/usage_window_stats.go @@ -26,13 +26,14 @@ type UsageWindowStatsCalculator struct { } type usageWindowTokenStats struct { - Model string `gorm:"column:model"` - TotalTokens int64 `gorm:"column:total_tokens"` - InputTokens int64 `gorm:"column:input_tokens"` - OutputTokens int64 `gorm:"column:output_tokens"` - CachedTokens int64 `gorm:"column:cached_tokens"` - CacheReadTokens int64 `gorm:"column:cache_read_tokens"` - CacheCreationTokens int64 `gorm:"column:cache_creation_tokens"` + Model string `gorm:"column:model"` + ModelAlias *string `gorm:"column:model_alias"` + TotalTokens int64 `gorm:"column:total_tokens"` + InputTokens int64 `gorm:"column:input_tokens"` + OutputTokens int64 `gorm:"column:output_tokens"` + CachedTokens int64 `gorm:"column:cached_tokens"` + CacheReadTokens int64 `gorm:"column:cache_read_tokens"` + CacheCreationTokens int64 `gorm:"column:cache_creation_tokens"` } func NewUsageWindowStatsCalculator(ctx context.Context, db *gorm.DB) (*UsageWindowStatsCalculator, error) { @@ -208,11 +209,11 @@ func sumRawUsageWindowTokenStats(db *gorm.DB, authIndex string, start time.Time, // raw 查询只取 model 级汇总字段,避免把大量 usage_events 行读进 Go 内存。 query := db.Model(&entities.UsageEvent{}). // SELECT 中只聚合 token/cost 需要的字段,不读取 raw_json 等大字段。 - Select("model, COALESCE(SUM(total_tokens), 0) AS total_tokens, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens, COALESCE(SUM(cached_tokens), 0) AS cached_tokens, COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation_tokens"). + Select("model, model_alias, COALESCE(SUM(total_tokens), 0) AS total_tokens, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens, COALESCE(SUM(cached_tokens), 0) AS cached_tokens, COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation_tokens"). // auth_index 已经是唯一身份维度,这里不再额外按 auth_type 过滤。 Where("auth_index = ? AND timestamp >= ?", authIndex, timeutil.FormatStorageTime(start)). - // 按 model 分组,后续按 model 价格表计算 cost。 - Group("model") + // 按 model + model_alias 分组,后续按 model/alias 价格表计算 cost。 + Group("model, model_alias") // 如果调用方传入结束时间,就用半开区间避免边界重复累计。 if end != nil { // end 统一格式化为 storage time,确保 SQLite 文本比较稳定。 @@ -233,11 +234,11 @@ func sumHourlyUsageWindowTokenStats(db *gorm.DB, authIndex string, start time.Ti // hourly 查询直接读取 overview 已经维护好的小时增量表。 query := db.Model(&entities.UsageOverviewHourlyStat{}). // SELECT 中聚合 token/cost 需要的字段,保持和 raw 查询返回结构一致。 - Select("model, COALESCE(SUM(total_tokens), 0) AS total_tokens, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens, COALESCE(SUM(cached_tokens), 0) AS cached_tokens, COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation_tokens"). + Select("model, model_alias, COALESCE(SUM(total_tokens), 0) AS total_tokens, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens, COALESCE(SUM(cached_tokens), 0) AS cached_tokens, COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation_tokens"). // auth_index + bucket_start 范围可以使用现有 hourly auth_bucket 索引。 Where("auth_index = ? AND bucket_start >= ? AND bucket_start < ?", authIndex, timeutil.FormatStorageTime(start), timeutil.FormatStorageTime(end)). - // 按 model 分组,后续按 model 价格表计算 cost。 - Group("model") + // 按 model + model_alias 分组,后续按 model/alias 价格表计算 cost。 + Group("model, model_alias") // rows 只承接聚合后的少量 model 行。 var rows []usageWindowTokenStats // 执行 hourly 聚合查询。 @@ -295,7 +296,7 @@ func usageWindowStatsFromTokenStats(rows []usageWindowTokenStats, pricingByModel // total_tokens 直接累计到前端展示的窗口 token。 stats.Tokens += row.TotalTokens // model 名称按 trim 后查价格,保持和其它 Overview/Usage cost 逻辑一致。 - pricing := pricingByModel[strings.TrimSpace(row.Model)] + pricing, _ := lookupPricingByModelOrAlias(pricingByModel, row.Model, row.ModelAlias) // 使用统一 helper 按当前价格表计算该 model 的 cost。 stats.Cost += helper.CalculateUsageTokenCost(helper.UsageTokenCostInput{ InputTokens: row.InputTokens, diff --git a/internal/service/dto/usage.go b/internal/service/dto/usage.go index 9c616d92..920ade41 100644 --- a/internal/service/dto/usage.go +++ b/internal/service/dto/usage.go @@ -41,7 +41,8 @@ type UsageEventsPage struct { // UsageEventFilterOptions 是 usage events 筛选项的服务层结果。 type UsageEventFilterOptions struct { - Models []string + Models []string + ModelLabels map[string]string } // UsageEventRecord 是单条 usage event 的服务层结果。 @@ -50,6 +51,7 @@ type UsageEventRecord struct { Timestamp time.Time APIGroupKey string Model string + ModelAlias *string ReasoningEffort string ServiceTier string ExecutorType string diff --git a/internal/service/usage.go b/internal/service/usage.go index a0a603ec..5873553d 100644 --- a/internal/service/usage.go +++ b/internal/service/usage.go @@ -436,6 +436,7 @@ func (s *usageService) ListUsageEvents(_ context.Context, filter servicedto.Usag Timestamp: row.Timestamp, APIGroupKey: row.APIGroupKey, Model: row.Model, + ModelAlias: row.ModelAlias, ReasoningEffort: row.ReasoningEffort, ServiceTier: row.ServiceTier, ExecutorType: row.ExecutorType, @@ -471,5 +472,5 @@ func (s *usageService) ListUsageEventFilterOptions(_ context.Context, filter ser if err != nil { return nil, err } - return &servicedto.UsageEventFilterOptions{Models: options.Models}, nil + return &servicedto.UsageEventFilterOptions{Models: options.Models, ModelLabels: options.ModelLabels}, nil } diff --git a/web/src/components/usage/RequestEventsDetailsCard.test.tsx b/web/src/components/usage/RequestEventsDetailsCard.test.tsx index 64736694..2587dc74 100644 --- a/web/src/components/usage/RequestEventsDetailsCard.test.tsx +++ b/web/src/components/usage/RequestEventsDetailsCard.test.tsx @@ -52,7 +52,7 @@ const renderCard = (props: Partial { const options = [ { value: ALL_FILTER, label: t('usage_stats.filter_all') }, - ...backendModelOptions.map((model) => ({ value: model, label: model })), + ...backendModelOptions, ]; return appendSelectedOption(options, modelFilter); }, [backendModelOptions, modelFilter, t]); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index c67dbf13..3f7a322a 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -214,6 +214,7 @@ export interface UsageEvent { timestamp: string api_key?: string model: string + model_alias?: string reasoning_effort?: string service_tier?: string executor_type?: string @@ -247,8 +248,13 @@ export interface UsageEventsResponse { total_pages: number } +export interface ModelFilterOption { + value: string + label: string +} + export interface UsageEventModelFilterOptionsResponse { - models: string[] + models: ModelFilterOption[] } export interface UsageEventSourceFilterOptionsResponse { diff --git a/web/src/pages/UsagePage.logic.test.ts b/web/src/pages/UsagePage.logic.test.ts index 2e790e60..e6d14583 100644 --- a/web/src/pages/UsagePage.logic.test.ts +++ b/web/src/pages/UsagePage.logic.test.ts @@ -497,7 +497,7 @@ describe('UsagePage request event filters', () => { result: 'failed', }, { - models: ['claude-sonnet'], + models: [{ value: 'claude-sonnet', label: 'claude-sonnet' }], sources: [{ value: 'authidx-source-a', label: 'authidx-source-a' }], }, ); @@ -517,7 +517,7 @@ describe('UsagePage request event filters', () => { result: 'success', }, { - models: ['claude-sonnet'], + models: [{ value: 'claude-sonnet', label: 'claude-sonnet' }], sources: [{ value: 'authidx-source-a', label: 'authidx-source-a' }], }, ); diff --git a/web/src/pages/UsagePage.tsx b/web/src/pages/UsagePage.tsx index 8d510394..c0d1225c 100644 --- a/web/src/pages/UsagePage.tsx +++ b/web/src/pages/UsagePage.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, useCallback, useEffect, useRef, type KeyboardEvent, type SyntheticEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { ApiError, fetchAnalysis, fetchAuthSessions, fetchCpaApiKeyOptions, fetchCpaApiKeySettings, fetchStatus, fetchUpdateCheck, fetchUsageEventModelFilterOptions, fetchUsageEventSourceFilterOptions, fetchUsageEvents, logout, markStatusActive, revokeAuthSession, updateCpaApiKeyAlias } from '@/lib/api'; -import type { AnalysisResponse, AuthManagedSessionItem, CpaApiKeyOption, CpaApiKeySettingsItem, OverviewRealtimeWindow, StatusResponse, UsageEvent, UsageSourceFilterOption } from '@/lib/types'; +import type { AnalysisResponse, AuthManagedSessionItem, CpaApiKeyOption, CpaApiKeySettingsItem, ModelFilterOption, OverviewRealtimeWindow, StatusResponse, UsageEvent, UsageSourceFilterOption } from '@/lib/types'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { LanguageSwitcher } from '@/components/ui/LanguageSwitcher'; import { Select } from '@/components/ui/Select'; @@ -175,7 +175,7 @@ type RequestEventFilterState = { }; type RequestEventFilterOptionsState = { - models: string[]; + models: ModelFilterOption[]; sources: UsageSourceFilterOption[]; }; @@ -556,7 +556,7 @@ export const sanitizeRequestEventFilters = ( }; } - const model = filters.model === ALL_REQUEST_EVENTS_FILTER || options.models.includes(filters.model) + const model = filters.model === ALL_REQUEST_EVENTS_FILTER || options.models.some((m) => m.value === filters.model) ? filters.model : ALL_REQUEST_EVENTS_FILTER; const source = filters.source === ALL_REQUEST_EVENTS_FILTER || options.sources.some((option) => option.value === filters.source) @@ -849,7 +849,7 @@ export function UsagePage({ onAuthRequired }: { onAuthRequired?: () => void }) { const [eventsPageSize, setEventsPageSize] = useState(initialRequestEventsPreferences.pageSize); const [eventsTotalCount, setEventsTotalCount] = useState(0); const [eventsTotalPages, setEventsTotalPages] = useState(0); - const [eventsModelOptions, setEventsModelOptions] = useState([]); + const [eventsModelOptions, setEventsModelOptions] = useState([]); const [eventsSourceOptions, setEventsSourceOptions] = useState([]); const [eventsModelFilter, setEventsModelFilter] = useState(initialRequestEventsPreferences.filters.model); const [eventsSourceFilter, setEventsSourceFilter] = useState(initialRequestEventsPreferences.filters.source); From 3c5b98465457fa46d5020166a61be145992e8717 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 23 Jun 2026 18:43:26 +0800 Subject: [PATCH 02/11] fix: alias name and enhance colors --- internal/repository/usage.go | 43 +++++++++++-------- .../usage/analysis/AnalysisPanel.module.scss | 16 +++---- .../usage/analysis/AnalysisPanel.tsx | 24 +++++------ web/src/pages/UsagePage.styles.test.ts | 4 +- 4 files changed, 47 insertions(+), 40 deletions(-) diff --git a/internal/repository/usage.go b/internal/repository/usage.go index 08a30695..6fdba9c4 100644 --- a/internal/repository/usage.go +++ b/internal/repository/usage.go @@ -318,29 +318,12 @@ func BuildAnalysisWithFilter(db *gorm.DB, filter dto.UsageQueryFilter) (*dto.Ana } record.LatencyDiagnostics = latencyDiagnostics - // 把分析结果中的 model 原名替换成 alias 展示名。 - aliasMap := loadModelAliasMap(db) - for i, item := range record.ModelComposition { - if alias, ok := aliasMap[item.Key]; ok { - record.ModelComposition[i].Label = alias - } - } - for i, item := range record.ModelEfficiency { - if alias, ok := aliasMap[item.Model]; ok { - record.ModelEfficiency[i].Model = alias - } - } - for i, cell := range record.Heatmap { - if alias, ok := aliasMap[cell.Model]; ok { - record.Heatmap[i].Model = alias - } - } - fullStart, fullEnd := usageOverviewFullHourWindow(*filter.StartTime, *filter.EndTime) fullEnd = analysisHourlyStatsEnd(filter, fullEnd) if !fullEnd.After(fullStart) { return record, nil } + aliasMap := loadModelAliasMap(db) if bucketByDay { fullDayStart, fullDayEnd := usageOverviewFullDayWindow(fullStart, fullEnd) var dailyRows []entities.UsageOverviewDailyStat @@ -364,6 +347,7 @@ func BuildAnalysisWithFilter(db *gorm.DB, filter dto.UsageQueryFilter) (*dto.Ana return nil, err } applyAnalysisDailyAndBoundaryHourlyRows(record, dailyRows, dailyIdentityLookup, hourlyRows, hourlyIdentityLookup, pricingByModel) + applyAnalysisModelAliases(record, aliasMap) return record, nil } rows, err := loadAnalysisOverviewHourlyStatsWithFilter(db, filter, fullStart, fullEnd) @@ -376,6 +360,7 @@ func BuildAnalysisWithFilter(db *gorm.DB, filter dto.UsageQueryFilter) (*dto.Ana } applyAnalysisHourlyRows(record, rows, identityLookup, pricingByModel) fillAnalysisFullDayHourlyBuckets(record, filter) + applyAnalysisModelAliases(record, aliasMap) return record, nil } @@ -2412,6 +2397,28 @@ func loadPriceSettingsByModel(db *gorm.DB) (map[string]entities.ModelPriceSettin return result, nil } +// applyAnalysisModelAliases 把分析结果中 model 原名替换成 alias 展示名,必须在数据填充之后调用。 +func applyAnalysisModelAliases(record *dto.AnalysisRecord, aliasMap map[string]string) { + if record == nil || len(aliasMap) == 0 { + return + } + for i, item := range record.ModelComposition { + if alias, ok := aliasMap[item.Key]; ok { + record.ModelComposition[i].Label = alias + } + } + for i, item := range record.ModelEfficiency { + if alias, ok := aliasMap[item.Model]; ok { + record.ModelEfficiency[i].Model = alias + } + } + for i, cell := range record.Heatmap { + if alias, ok := aliasMap[cell.Model]; ok { + record.Heatmap[i].Model = alias + } + } +} + // usageOverviewModelAliasPtr 把 stats 行的 ModelAlias string 转成 *string,便于传递给 lookupPricingByModelOrAlias。 func usageOverviewModelAliasPtr(alias string) *string { if strings.TrimSpace(alias) == "" { diff --git a/web/src/components/usage/analysis/AnalysisPanel.module.scss b/web/src/components/usage/analysis/AnalysisPanel.module.scss index 74c5acc6..7dced7a1 100644 --- a/web/src/components/usage/analysis/AnalysisPanel.module.scss +++ b/web/src/components/usage/analysis/AnalysisPanel.module.scss @@ -748,8 +748,8 @@ border-radius: 5px; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.20), - inset 0 -10px 18px rgba(127, 29, 29, 0.18), - 0 1px 3px rgba(67, 20, 7, 0.10); + inset 0 -10px 18px rgba(80, 70, 60, 0.12), + 0 1px 3px rgba(60, 50, 40, 0.08); font-variant-numeric: tabular-nums; outline: none; } @@ -759,16 +759,16 @@ position: absolute; inset: 0; background: - radial-gradient(circle at 50% 115%, rgba(255, 247, 237, 0.82), rgba(249, 115, 22, 0.36) 34%, rgba(127, 29, 29, 0) 72%), - linear-gradient(180deg, rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0) 38%); + radial-gradient(circle at 50% 115%, rgba(245, 240, 235, 0.60), rgba(180, 170, 160, 0.20) 34%, rgba(120, 110, 100, 0) 72%), + linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0) 38%); opacity: var(--heatmap-flame-alpha, 0.35); pointer-events: none; } .heatmapCardDark .heatmapCell::before { background: - radial-gradient(circle at 50% 115%, rgba(254, 243, 199, 0.92), rgba(249, 115, 22, 0.48) 36%, rgba(127, 29, 29, 0) 74%), - linear-gradient(180deg, rgba(255, 247, 237, 0.10), rgba(255, 247, 237, 0) 42%); + radial-gradient(circle at 50% 115%, rgba(220, 215, 210, 0.50), rgba(160, 152, 144, 0.22) 36%, rgba(100, 90, 80, 0) 74%), + linear-gradient(180deg, rgba(240, 238, 232, 0.08), rgba(240, 238, 232, 0) 42%); } .heatmapCell:focus-visible { @@ -803,12 +803,12 @@ width: 120px; height: 10px; border-radius: 8px; - background: linear-gradient(90deg, #fff7ed, #fed7aa, #fb923c, #ef4444, #7c2d12); + background: linear-gradient(90deg, #f0eee8, #cec8c0, #ada59c, #8b8378, #5c524a); box-shadow: inset 0 0 0 1px var(--border-color); } .heatmapCardDark .heatmapLegendRamp { - background: linear-gradient(90deg, #1a1118, #4a1f23, #9a3412, #f97316, #fde68a); + background: linear-gradient(90deg, #1a1815, #37322e, #5a534c, #82786e, #b2aaa3); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10); } diff --git a/web/src/components/usage/analysis/AnalysisPanel.tsx b/web/src/components/usage/analysis/AnalysisPanel.tsx index b9030c70..0204d0f1 100644 --- a/web/src/components/usage/analysis/AnalysisPanel.tsx +++ b/web/src/components/usage/analysis/AnalysisPanel.tsx @@ -471,18 +471,18 @@ const getHeatmapCellColor = (intensity: number, isDark: boolean) => { const stops: Array<{ at: number; color: [number, number, number] }> = [ ...(isDark ? [ - { at: 0, color: [26, 17, 24] }, - { at: 0.24, color: [74, 31, 35] }, - { at: 0.48, color: [154, 52, 18] }, - { at: 0.74, color: [249, 115, 22] }, - { at: 1, color: [253, 230, 138] }, + { at: 0, color: [26, 24, 23] }, + { at: 0.25, color: [55, 50, 47] }, + { at: 0.5, color: [90, 83, 76] }, + { at: 0.75, color: [130, 120, 112] }, + { at: 1, color: [178, 170, 163] }, ] satisfies Array<{ at: number; color: [number, number, number] }> : [ - { at: 0, color: [255, 247, 237] }, - { at: 0.22, color: [254, 215, 170] }, - { at: 0.48, color: [251, 146, 60] }, - { at: 0.72, color: [239, 68, 68] }, - { at: 1, color: [124, 45, 18] }, + { at: 0, color: [240, 238, 232] }, + { at: 0.2, color: [206, 200, 192] }, + { at: 0.45, color: [173, 165, 156] }, + { at: 0.7, color: [139, 131, 124] }, + { at: 1, color: [92, 82, 74] }, ] satisfies Array<{ at: number; color: [number, number, number] }>), ]; const upperIndex = stops.findIndex((stop) => clampedIntensity <= stop.at); @@ -496,9 +496,9 @@ const getHeatmapCellColor = (intensity: number, isDark: boolean) => { const getHeatmapCellTextColor = (intensity: number, isDark: boolean) => { const clampedIntensity = Math.max(0, Math.min(1, intensity)); if (!isDark) { - return clampedIntensity > 0.58 ? '#fff7ed' : '#431407'; + return clampedIntensity > 0.58 ? '#f5f3ef' : '#3a3530'; } - return clampedIntensity > 0.86 ? '#1c1208' : '#fff7ed'; + return clampedIntensity > 0.75 ? '#1a1817' : '#f0eee8'; }; const getHeatmapVisualIntensity = (value: number, maxValue: number) => { diff --git a/web/src/pages/UsagePage.styles.test.ts b/web/src/pages/UsagePage.styles.test.ts index c700dd05..dc44a4a9 100644 --- a/web/src/pages/UsagePage.styles.test.ts +++ b/web/src/pages/UsagePage.styles.test.ts @@ -337,8 +337,8 @@ describe('UsagePage toolbar styles', () => { expect(heatmapRowLabelBlock).toContain('align-self: center;') expect(analysisPanelStyles).toMatch(/\.heatmapModelLabel\s*\{[\s\S]*?-webkit-line-clamp:\s*2;/) expect(analysisPanelStyles).toMatch(/\.heatmapModelLabel\s*\{[\s\S]*?overflow-wrap:\s*anywhere;/) - expect(analysisPanelStyles).toMatch(/\.heatmapLegendRamp\s*\{[\s\S]*?linear-gradient\(90deg, #fff7ed, #fed7aa, #fb923c, #ef4444, #7c2d12\)/) - expect(analysisPanelStyles).toMatch(/\.heatmapCardDark \.heatmapLegendRamp\s*\{[\s\S]*?linear-gradient\(90deg, #1a1118, #4a1f23, #9a3412, #f97316, #fde68a\)/) + expect(analysisPanelStyles).toMatch(/\.heatmapLegendRamp\s*\{[\s\S]*?linear-gradient\(90deg, #f0eee8, #cec8c0, #ada59c, #8b8378, #5c524a\)/) + expect(analysisPanelStyles).toMatch(/\.heatmapCardDark \.heatmapLegendRamp\s*\{[\s\S]*?linear-gradient\(90deg, #1a1815, #37322e, #5a534c, #82786e, #b2aaa3\)/) expect(analysisPanelStyles).toMatch(/\.heatmapFloatingTooltip\s*\{[\s\S]*?position:\s*fixed;/) expect(analysisPanelStyles).toMatch(/\.heatmapFloatingTooltip\s*\{[\s\S]*?border:\s*1px solid var\(--border-color\);/) expect(analysisPanelStyles).toMatch(/\.heatmapFloatingTooltip\s*\{[\s\S]*?background:\s*var\(--bg-primary\);/) From 923d7115406bdc158d386da97e9ff4fde805c4d7 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 23 Jun 2026 20:59:08 +0800 Subject: [PATCH 03/11] feat: show masked identity badge for AI provider credentials --- .../usage/credentials/AiProviderCredentialsSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/usage/credentials/AiProviderCredentialsSection.tsx b/web/src/components/usage/credentials/AiProviderCredentialsSection.tsx index bd13182b..c81ef82c 100644 --- a/web/src/components/usage/credentials/AiProviderCredentialsSection.tsx +++ b/web/src/components/usage/credentials/AiProviderCredentialsSection.tsx @@ -50,7 +50,7 @@ export function AiProviderCredentialsSection({ rows, total, page, totalPages, pa {row.priorityLabel && {row.priorityLabel}} )} - badges={null} + badges={{row.maskedIdentity}} metrics={( <> } /> From 932fc44d4d39352f0bbfe0f0dcba253c62530f08 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 24 Jun 2026 10:09:20 +0800 Subject: [PATCH 04/11] fix: add minimum generation duration threshold for speed_tps calculation --- internal/api/usage_events.go | 3 ++- internal/api/usage_events_test.go | 18 +++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/internal/api/usage_events.go b/internal/api/usage_events.go index 4cb87c13..0b82c301 100644 --- a/internal/api/usage_events.go +++ b/internal/api/usage_events.go @@ -215,7 +215,8 @@ func usageEventSpeedTPS(row servicedto.UsageEventRecord) *float64 { if visibleOutputTokens < 0 { visibleOutputTokens = 0 } - if row.TTFTMS == nil || *row.TTFTMS <= 0 || row.LatencyMS <= *row.TTFTMS || visibleOutputTokens <= 1 { + // 生成时间过短(<10ms)时,毫秒级精度无法支撑可信的 TPS 计算。 + if row.TTFTMS == nil || *row.TTFTMS <= 0 || row.LatencyMS-*row.TTFTMS < 10 || visibleOutputTokens <= 1 { return nil } // Speed 只衡量首字后可见输出 token 的平均生成速度,避免把等待首字的时间重复计入。 diff --git a/internal/api/usage_events_test.go b/internal/api/usage_events_test.go index bf2ed81f..bcc3b564 100644 --- a/internal/api/usage_events_test.go +++ b/internal/api/usage_events_test.go @@ -579,13 +579,21 @@ func TestUsageEventSpeedTPS(t *testing.T) { }, }, { - name: "omits speed when only first visible token is present", + name: "omits speed when generation duration is too short", row: servicedto.UsageEventRecord{ - LatencyMS: 2045, - TTFTMS: usageEventInt64Ptr(45), - OutputTokens: 4, - ReasoningTokens: 3, + LatencyMS: 54, + TTFTMS: usageEventInt64Ptr(45), + OutputTokens: 100, + }, + }, + { + name: "includes speed when generation duration reaches threshold", + row: servicedto.UsageEventRecord{ + LatencyMS: 55, + TTFTMS: usageEventInt64Ptr(45), + OutputTokens: 100, }, + want: usageEventFloat64Ptr(9900), }, } From bf297982bd254fd5f3532c64064e83109edfe285 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 24 Jun 2026 11:57:59 +0800 Subject: [PATCH 05/11] =?UTF-8?q?fix:=20=E8=A1=A5=E5=85=85=20realtime=20?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E6=A8=A1=E5=9E=8B=E5=8D=A0=E6=AF=94=20alias?= =?UTF-8?q?=20=E5=AD=97=E6=AE=B5=E4=BC=A0=E9=80=92=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/repository/usage.go | 3 ++- .../repository/usage_recent_event_cache.go | 22 +++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/internal/repository/usage.go b/internal/repository/usage.go index 6fdba9c4..3ec72b45 100644 --- a/internal/repository/usage.go +++ b/internal/repository/usage.go @@ -20,7 +20,7 @@ const usageEventProjectionColumns = "id, api_group_key, provider, auth_type, mod const analysisLatencyMaxDisplayPoints = 2500 // usageOverviewRawEventProjectionColumns 是 Overview 边界补偿和 realtime DB 兜底的最小事件投影。 -const usageOverviewRawEventProjectionColumns = "api_group_key, provider, auth_type, model, timestamp, source, auth_index, failed, latency_ms, ttft_ms, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, total_tokens" +const usageOverviewRawEventProjectionColumns = "api_group_key, provider, auth_type, model, model_alias, timestamp, source, auth_index, failed, latency_ms, ttft_ms, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, total_tokens" // usageEventProjection 是 usage_events 轻量投影,专门承接 select columns 的查询结果。 type usageEventProjection struct { @@ -209,6 +209,7 @@ func usageEventProjectionToEntity(event usageEventProjection) entities.UsageEven Provider: event.Provider, AuthType: event.AuthType, Model: event.Model, + ModelAlias: event.ModelAlias, ReasoningEffort: event.ReasoningEffort, ServiceTier: event.ServiceTier, ExecutorType: event.ExecutorType, diff --git a/internal/repository/usage_recent_event_cache.go b/internal/repository/usage_recent_event_cache.go index 6526d1ff..8ec00001 100644 --- a/internal/repository/usage_recent_event_cache.go +++ b/internal/repository/usage_recent_event_cache.go @@ -38,6 +38,8 @@ type RecentUsageEvent struct { APIGroupKey string // Model 用于 realtime 当前模型占比和 cost 价格表匹配。 Model string + // ModelAlias 保存模型别名,用于前端展示替代 model 原名。 + ModelAlias *string // AuthIndex 用于关联 usage_identities,找不到身份时才使用 fallback。 AuthIndex string // IdentityFallbackKind 记录 fallback 应落到 Auth File 还是 AI Provider。 @@ -103,6 +105,7 @@ type recentUsageEventLoadRow struct { Provider string AuthType string Model string + ModelAlias *string `gorm:"column:model_alias"` Timestamp time.Time Source string AuthIndex string @@ -309,6 +312,7 @@ func (c *UsageRecentEventCache) appendEvents(events []entities.UsageEvent) { Provider: event.Provider, AuthType: event.AuthType, Model: event.Model, + ModelAlias: event.ModelAlias, Timestamp: event.Timestamp, Source: event.Source, AuthIndex: event.AuthIndex, @@ -423,7 +427,7 @@ func loadUsageRecentEventCacheRows(db *gorm.DB, start time.Time) ([]recentUsageE var rows []recentUsageEventLoadRow // 只 select 最近缓存和 realtime 必需字段,避免大字段进入 70 分钟内存窗口。 if err := db.Model(&entities.UsageEvent{}). - Select("api_group_key, provider, auth_type, model, timestamp, source, auth_index, failed, latency_ms, ttft_ms, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, total_tokens"). + Select("api_group_key, provider, auth_type, model, model_alias, timestamp, source, auth_index, failed, latency_ms, ttft_ms, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, total_tokens"). // 启动加载只取 retention 左边界之后的数据。 Where("timestamp >= ?", timeutil.FormatStorageTime(start)). // 按时间排序让后续剪枝和调试输出更直观。 @@ -452,6 +456,7 @@ func (c *UsageRecentEventCache) recentEventFromRowLocked(row recentUsageEventLoa // 高频重复字符串通过池化复用,降低缓存内存占用。 APIGroupKey: c.pool.intern(strings.TrimSpace(row.APIGroupKey)), Model: c.pool.intern(strings.TrimSpace(row.Model)), + ModelAlias: row.ModelAlias, AuthIndex: c.pool.intern(strings.TrimSpace(row.AuthIndex)), IdentityFallbackKind: identityKind, IdentityFallbackLabel: c.pool.intern(fallbackLabel), @@ -566,8 +571,9 @@ func cloneUsageEventsForRecentCache(events []entities.UsageEvent) []entities.Usa for index := range events { // 结构体浅拷贝覆盖大多数字段。 result[index] = events[index] - // TTFTMS 是指针字段,需要深拷贝避免跨 goroutine 共享。 + // TTFTMS 和 ModelAlias 是指针字段,需要深拷贝避免跨 goroutine 共享。 result[index].TTFTMS = cloneInt64Ptr(events[index].TTFTMS) + result[index].ModelAlias = cloneStringPtr(events[index].ModelAlias) } return result } @@ -575,6 +581,7 @@ func cloneUsageEventsForRecentCache(events []entities.UsageEvent) []entities.Usa func cloneRecentUsageEvent(event RecentUsageEvent) RecentUsageEvent { // RecentUsageEvent 也包含 TTFT 指针,返回给调用方前需要复制。 event.TTFTMS = cloneInt64Ptr(event.TTFTMS) + event.ModelAlias = cloneStringPtr(event.ModelAlias) return event } @@ -583,6 +590,7 @@ func recentUsageEventToEntity(event RecentUsageEvent) entities.UsageEvent { return entities.UsageEvent{ APIGroupKey: event.APIGroupKey, Model: event.Model, + ModelAlias: event.ModelAlias, Timestamp: event.Timestamp, AuthIndex: event.AuthIndex, Failed: event.Failed, @@ -607,3 +615,13 @@ func cloneInt64Ptr(value *int64) *int64 { cloned := *value return &cloned } + +func cloneStringPtr(value *string) *string { + // nil 表示原始事件没有该样本,必须原样保留。 + if value == nil { + return nil + } + // 非 nil 时复制字符串,避免调用方通过指针修改缓存内容。 + cloned := *value + return &cloned +} From 9fd1f510c71c719a4ea9ee75fd8b200461807ec9 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 24 Jun 2026 11:57:59 +0800 Subject: [PATCH 06/11] =?UTF-8?q?fix:=20=E8=A1=A5=E5=85=85=20realtime=20?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E6=A8=A1=E5=9E=8B=E5=8D=A0=E6=AF=94=20alias?= =?UTF-8?q?=20=E5=AD=97=E6=AE=B5=E4=BC=A0=E9=80=92=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/repository/query_projection_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/repository/query_projection_test.go b/internal/repository/query_projection_test.go index 669ad601..e82f4c10 100644 --- a/internal/repository/query_projection_test.go +++ b/internal/repository/query_projection_test.go @@ -17,10 +17,10 @@ func TestRepositoryQueriesAvoidKnownFullEntityReads(t *testing.T) { assertFileContains(t, "usage.go", "Select(usageEventProjectionColumns).Order(\"timestamp DESC, id DESC\")", "Select(usageOverviewRawEventProjectionColumns).\n\t\tOrder(\"timestamp asc\")", - "usageOverviewRawEventProjectionColumns = \"api_group_key, provider, auth_type, model, timestamp, source, auth_index, failed, latency_ms, ttft_ms, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, total_tokens\"", + "usageOverviewRawEventProjectionColumns = \"api_group_key, provider, auth_type, model, model_alias, timestamp, source, auth_index, failed, latency_ms, ttft_ms, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, total_tokens\"", ) assertFileContains(t, "usage_recent_event_cache.go", - "Select(\"api_group_key, provider, auth_type, model, timestamp, source, auth_index, failed, latency_ms, ttft_ms, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, total_tokens\")", + "Select(\"api_group_key, provider, auth_type, model, model_alias, timestamp, source, auth_index, failed, latency_ms, ttft_ms, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, total_tokens\")", ) assertFileDoesNotContain(t, "usage_identities.go", From 1e4d0033a9a13da5d37807c89de052114d27575f Mon Sep 17 00:00:00 2001 From: t Date: Wed, 24 Jun 2026 16:18:14 +0800 Subject: [PATCH 07/11] style: redesign heatmap with flat warm colors - Remove pseudo-3D effects: drop inset box-shadow and ::before flame gradient - Restore warm orange color scale for semantic heat meaning - Flatten cell styling to match overall design language - Sync test assertions with new color values --- .../usage/analysis/AnalysisPanel.module.scss | 25 ++----------------- .../usage/analysis/AnalysisPanel.tsx | 25 +++++++++---------- web/src/pages/UsagePage.styles.test.ts | 6 ++--- 3 files changed, 17 insertions(+), 39 deletions(-) diff --git a/web/src/components/usage/analysis/AnalysisPanel.module.scss b/web/src/components/usage/analysis/AnalysisPanel.module.scss index 7dced7a1..65b10f29 100644 --- a/web/src/components/usage/analysis/AnalysisPanel.module.scss +++ b/web/src/components/usage/analysis/AnalysisPanel.module.scss @@ -746,31 +746,10 @@ justify-content: center; padding: 0 4px; border-radius: 5px; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.20), - inset 0 -10px 18px rgba(80, 70, 60, 0.12), - 0 1px 3px rgba(60, 50, 40, 0.08); font-variant-numeric: tabular-nums; outline: none; } -.heatmapCell::before { - content: ''; - position: absolute; - inset: 0; - background: - radial-gradient(circle at 50% 115%, rgba(245, 240, 235, 0.60), rgba(180, 170, 160, 0.20) 34%, rgba(120, 110, 100, 0) 72%), - linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0) 38%); - opacity: var(--heatmap-flame-alpha, 0.35); - pointer-events: none; -} - -.heatmapCardDark .heatmapCell::before { - background: - radial-gradient(circle at 50% 115%, rgba(220, 215, 210, 0.50), rgba(160, 152, 144, 0.22) 36%, rgba(100, 90, 80, 0) 74%), - linear-gradient(180deg, rgba(240, 238, 232, 0.08), rgba(240, 238, 232, 0) 42%); -} - .heatmapCell:focus-visible { box-shadow: 0 0 0 2px color-mix(in srgb, #d86a4a 70%, transparent), inset 0 0 0 1px rgba(255, 255, 255, 0.08); } @@ -803,12 +782,12 @@ width: 120px; height: 10px; border-radius: 8px; - background: linear-gradient(90deg, #f0eee8, #cec8c0, #ada59c, #8b8378, #5c524a); + background: linear-gradient(90deg, #fff5eb, #ffd8a8, #ffa94d, #e8590c, #5c2b0e); box-shadow: inset 0 0 0 1px var(--border-color); } .heatmapCardDark .heatmapLegendRamp { - background: linear-gradient(90deg, #1a1815, #37322e, #5a534c, #82786e, #b2aaa3); + background: linear-gradient(90deg, #1a1108, #4a2a12, #a0400a, #ff7b00, #ffe066); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10); } diff --git a/web/src/components/usage/analysis/AnalysisPanel.tsx b/web/src/components/usage/analysis/AnalysisPanel.tsx index 0204d0f1..3b1e72d3 100644 --- a/web/src/components/usage/analysis/AnalysisPanel.tsx +++ b/web/src/components/usage/analysis/AnalysisPanel.tsx @@ -471,18 +471,18 @@ const getHeatmapCellColor = (intensity: number, isDark: boolean) => { const stops: Array<{ at: number; color: [number, number, number] }> = [ ...(isDark ? [ - { at: 0, color: [26, 24, 23] }, - { at: 0.25, color: [55, 50, 47] }, - { at: 0.5, color: [90, 83, 76] }, - { at: 0.75, color: [130, 120, 112] }, - { at: 1, color: [178, 170, 163] }, + { at: 0, color: [26, 16, 8] }, + { at: 0.25, color: [74, 28, 5] }, + { at: 0.5, color: [160, 64, 10] }, + { at: 0.75, color: [255, 123, 0] }, + { at: 1, color: [255, 224, 102] }, ] satisfies Array<{ at: number; color: [number, number, number] }> : [ - { at: 0, color: [240, 238, 232] }, - { at: 0.2, color: [206, 200, 192] }, - { at: 0.45, color: [173, 165, 156] }, - { at: 0.7, color: [139, 131, 124] }, - { at: 1, color: [92, 82, 74] }, + { at: 0, color: [255, 245, 235] }, + { at: 0.25, color: [255, 216, 168] }, + { at: 0.5, color: [255, 169, 77] }, + { at: 0.75, color: [232, 89, 12] }, + { at: 1, color: [92, 43, 14] }, ] satisfies Array<{ at: number; color: [number, number, number] }>), ]; const upperIndex = stops.findIndex((stop) => clampedIntensity <= stop.at); @@ -496,9 +496,9 @@ const getHeatmapCellColor = (intensity: number, isDark: boolean) => { const getHeatmapCellTextColor = (intensity: number, isDark: boolean) => { const clampedIntensity = Math.max(0, Math.min(1, intensity)); if (!isDark) { - return clampedIntensity > 0.58 ? '#f5f3ef' : '#3a3530'; + return clampedIntensity > 0.55 ? '#fff8f0' : '#3d1f0a'; } - return clampedIntensity > 0.75 ? '#1a1817' : '#f0eee8'; + return clampedIntensity > 0.75 ? '#1c1208' : '#fff5eb'; }; const getHeatmapVisualIntensity = (value: number, maxValue: number) => { @@ -1688,7 +1688,6 @@ function Heatmap({ cells, apiKeys, apiKeyLabels, models, loading, isDark }: { ce style={{ background: getHeatmapCellColor(intensity, isDark), color: getHeatmapCellTextColor(intensity, isDark), - '--heatmap-flame-alpha': intensity.toFixed(3), } as CSSProperties} tabIndex={0} aria-label={tooltipLines.join(', ')} diff --git a/web/src/pages/UsagePage.styles.test.ts b/web/src/pages/UsagePage.styles.test.ts index dc44a4a9..603205a8 100644 --- a/web/src/pages/UsagePage.styles.test.ts +++ b/web/src/pages/UsagePage.styles.test.ts @@ -328,7 +328,7 @@ describe('UsagePage toolbar styles', () => { expect(analysisPanelStyles).not.toMatch(/\.compositionTabActive\s*\{[\s\S]*?#2563eb/) expect(analysisPanelStyles).toMatch(/\.heatmapCardLight \.analysisChartSurface\s*\{[\s\S]*?background:\s*color-mix/) expect(analysisPanelStyles).toMatch(/\.heatmapCardDark \.analysisChartSurface\s*\{[\s\S]*?background:\s*#100e16;/) - expect(analysisPanelStyles).toMatch(/\.heatmapCell::before\s*\{[\s\S]*?radial-gradient\(circle at 50% 115%/) + expect(analysisPanelStyles).not.toContain('.heatmapCellTooltip') expect(analysisPanelStyles).toMatch(/\.heatmapCorner,\s*\.heatmapHeaderCell\s*\{[\s\S]*?min-height:\s*48px;/) const heatmapRowLabelBlock = [...analysisPanelStyles.matchAll(/\.heatmapRowLabel\s*\{([\s\S]*?)\n\}/g)] .map((match) => match[1]) @@ -337,8 +337,8 @@ describe('UsagePage toolbar styles', () => { expect(heatmapRowLabelBlock).toContain('align-self: center;') expect(analysisPanelStyles).toMatch(/\.heatmapModelLabel\s*\{[\s\S]*?-webkit-line-clamp:\s*2;/) expect(analysisPanelStyles).toMatch(/\.heatmapModelLabel\s*\{[\s\S]*?overflow-wrap:\s*anywhere;/) - expect(analysisPanelStyles).toMatch(/\.heatmapLegendRamp\s*\{[\s\S]*?linear-gradient\(90deg, #f0eee8, #cec8c0, #ada59c, #8b8378, #5c524a\)/) - expect(analysisPanelStyles).toMatch(/\.heatmapCardDark \.heatmapLegendRamp\s*\{[\s\S]*?linear-gradient\(90deg, #1a1815, #37322e, #5a534c, #82786e, #b2aaa3\)/) + expect(analysisPanelStyles).toMatch(/\.heatmapLegendRamp\s*\{[\s\S]*?linear-gradient\(90deg, #fff5eb, #ffd8a8, #ffa94d, #e8590c, #5c2b0e\)/) + expect(analysisPanelStyles).toMatch(/\.heatmapCardDark \.heatmapLegendRamp\s*\{[\s\S]*?linear-gradient\(90deg, #1a1108, #4a2a12, #a0400a, #ff7b00, #ffe066\)/) expect(analysisPanelStyles).toMatch(/\.heatmapFloatingTooltip\s*\{[\s\S]*?position:\s*fixed;/) expect(analysisPanelStyles).toMatch(/\.heatmapFloatingTooltip\s*\{[\s\S]*?border:\s*1px solid var\(--border-color\);/) expect(analysisPanelStyles).toMatch(/\.heatmapFloatingTooltip\s*\{[\s\S]*?background:\s*var\(--bg-primary\);/) From 4aae4a1da2242bf9834ba290a1c897e73dfbc361 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 26 Jun 2026 20:12:28 +0800 Subject: [PATCH 08/11] feat: add overall speed TPS column (speed_total_tps) including TTFT --- internal/api/usage_events.go | 16 ++++++++++++++++ internal/api/usage_events_test.go | 3 +++ .../usage/RequestEventsDetailsCard.tsx | 11 +++++++++++ web/src/i18n/index.test.ts | 9 +++++++++ web/src/i18n/index.ts | 6 ++++++ web/src/lib/types.ts | 1 + 6 files changed, 46 insertions(+) diff --git a/internal/api/usage_events.go b/internal/api/usage_events.go index 0b82c301..55698e04 100644 --- a/internal/api/usage_events.go +++ b/internal/api/usage_events.go @@ -58,6 +58,7 @@ type usageEventPayload struct { LatencyMS int64 `json:"latency_ms"` TTFTMS *int64 `json:"ttft_ms,omitempty"` SpeedTPS *float64 `json:"speed_tps,omitempty"` + SpeedTotalTPS *float64 `json:"speed_total_tps,omitempty"` Tokens usageEventTokenPayload `json:"tokens"` CostUSD float64 `json:"cost_usd"` CostAvailable bool `json:"cost_available"` @@ -193,6 +194,7 @@ func buildUsageEventsPayload(rows []servicedto.UsageEventRecord, resolver usageI LatencyMS: row.LatencyMS, TTFTMS: row.TTFTMS, SpeedTPS: usageEventSpeedTPS(row), + SpeedTotalTPS: usageEventSpeedTotalTPS(row), CostUSD: row.CostUSD, CostAvailable: row.CostAvailable, PricingStyle: strings.TrimSpace(row.PricingStyle), @@ -224,6 +226,20 @@ func usageEventSpeedTPS(row servicedto.UsageEventRecord) *float64 { return &speed } +// usageEventSpeedTotalTPS 计算包含 TTFT 在内的总耗时 TPS,更贴近用户体感。 +func usageEventSpeedTotalTPS(row servicedto.UsageEventRecord) *float64 { + visibleOutputTokens := row.OutputTokens - row.ReasoningTokens + if visibleOutputTokens < 0 { + visibleOutputTokens = 0 + } + // 总耗时过短(<10ms)时,毫秒级精度无法支撑可信的 TPS 计算。 + if row.LatencyMS < 10 || visibleOutputTokens <= 0 { + return nil + } + speed := float64(visibleOutputTokens) / (float64(row.LatencyMS) / 1000) + return &speed +} + func usageEventAPIKeyLabel(apiGroupKey string, apiKeyInfos map[string]analysisAPIKeyInfo) string { apiKey := strings.TrimSpace(apiGroupKey) if apiKey == "" { diff --git a/internal/api/usage_events_test.go b/internal/api/usage_events_test.go index bcc3b564..6bf69abf 100644 --- a/internal/api/usage_events_test.go +++ b/internal/api/usage_events_test.go @@ -136,6 +136,9 @@ func TestUsageEventsReturnsFilteredRows(t *testing.T) { if !contains(body, `"speed_tps":29`) { t.Fatalf("expected speed_tps in response body: %s", body) } + if !contains(body, `"speed_total_tps":28.8`) { + t.Fatalf("expected speed_total_tps in response body: %s", body) + } if !contains(body, `"executor_type":"responses"`) { t.Fatalf("expected executor_type in response body: %s", body) } diff --git a/web/src/components/usage/RequestEventsDetailsCard.tsx b/web/src/components/usage/RequestEventsDetailsCard.tsx index 5b8b7c9a..b3f87848 100644 --- a/web/src/components/usage/RequestEventsDetailsCard.tsx +++ b/web/src/components/usage/RequestEventsDetailsCard.tsx @@ -42,6 +42,7 @@ export const REQUEST_EVENT_COLUMN_IDS = [ 'ttft', 'latency', 'speed', + 'speed_total', 'input_tokens', 'output_tokens', 'reasoning_tokens', @@ -123,6 +124,7 @@ type RequestEventRow = { latencyMs: number | null; ttftMs: number | null; speedTPS: number | null; + speedTotalTPS: number | null; inputTokens: number; outputTokens: number; reasoningTokens: number; @@ -517,6 +519,7 @@ export function RequestEventsDetailsCard({ }); const ttftHint = t('usage_stats.ttft_hint'); const speedHint = t('usage_stats.speed_hint'); + const speedTotalHint = t('usage_stats.speed_total_hint'); const rows = useMemo(() => { return events.map((event, index) => { @@ -544,6 +547,7 @@ export function RequestEventsDetailsCard({ const latencyMs = Number.isFinite(event.latency_ms) ? event.latency_ms : null; const ttftMs = Number.isFinite(event.ttft_ms) ? event.ttft_ms as number : null; const speedTPS = Number.isFinite(event.speed_tps) ? event.speed_tps as number : null; + const speedTotalTPS = Number.isFinite(event.speed_total_tps) ? event.speed_total_tps as number : null; // 费用由后端按当前价格配置运行时计算,前端只负责展示可用/不可用状态。 const costAvailable = event.cost_available === true; const cost = costAvailable ? Math.max(toNumber(event.cost_usd), 0) : null; @@ -568,6 +572,7 @@ export function RequestEventsDetailsCard({ latencyMs, ttftMs, speedTPS, + speedTotalTPS, inputTokens, outputTokens, reasoningTokens, @@ -754,6 +759,12 @@ export function RequestEventsDetailsCard({ header: {t('usage_stats.speed')}, renderCell: (row) => {formatSpeedTPS(row.speedTPS)}, }, + { + id: 'speed_total', + label: t('usage_stats.speed_total'), + header: {t('usage_stats.speed_total')}, + renderCell: (row) => {formatSpeedTPS(row.speedTotalTPS)}, + }, { id: 'input_tokens', label: t('usage_stats.input_tokens'), diff --git a/web/src/i18n/index.test.ts b/web/src/i18n/index.test.ts index 9dfa3540..8395872d 100644 --- a/web/src/i18n/index.test.ts +++ b/web/src/i18n/index.test.ts @@ -125,6 +125,15 @@ describe('i18n resources', () => { expect(i18n.getResource('zh-TW', 'translation', 'usage_stats.speed_mode_fast')).toBe('快速'); }); + it('labels the overall speed column across languages', () => { + expect(i18n.getResource('en', 'translation', 'usage_stats.speed_total')).toBe('Overall Speed'); + expect(i18n.getResource('en', 'translation', 'usage_stats.speed_total_hint')).toBe('Average visible output tokens per second including TTFT'); + expect(i18n.getResource('zh', 'translation', 'usage_stats.speed_total')).toBe('整体速度'); + expect(i18n.getResource('zh', 'translation', 'usage_stats.speed_total_hint')).toBe('包含首字延迟在内的平均输出速度'); + expect(i18n.getResource('zh-TW', 'translation', 'usage_stats.speed_total')).toBe('整體速度'); + expect(i18n.getResource('zh-TW', 'translation', 'usage_stats.speed_total_hint')).toBe('包含首字延遲在內的平均輸出速度'); + }); + it('keeps Analysis heatmap copy focused on hover details', () => { expect(i18n.getResource('en', 'translation', 'usage_stats.analysis_heatmap_subtitle')).toBe('Token distribution across API keys and models with hover details.'); expect(i18n.getResource('zh', 'translation', 'usage_stats.analysis_heatmap_subtitle')).toBe('展示 API Key 与模型组合下的 Token 分布,悬浮查看明细。'); diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts index f013e1c3..7cacbf99 100644 --- a/web/src/i18n/index.ts +++ b/web/src/i18n/index.ts @@ -169,6 +169,8 @@ const resources = { ttft_hint: 'Time to First Token', speed: 'Speed', speed_hint: 'Average visible output tokens per second after TTFT', + speed_total: 'Overall Speed', + speed_total_hint: 'Average visible output tokens per second including TTFT', speed_mode: 'Speed Mode', speed_mode_standard: 'Standard', speed_mode_fast: 'Fast', @@ -636,6 +638,8 @@ const resources = { ttft_hint: '首字延迟', speed: '生成速度', speed_hint: '首字后可见输出 token 的平均速度', + speed_total: '整体速度', + speed_total_hint: '包含首字延迟在内的平均输出速度', speed_mode: '速度模式', speed_mode_standard: '标准', speed_mode_fast: '快速', @@ -1103,6 +1107,8 @@ const resources = { ttft_hint: '首字延遲', speed: '生成速度', speed_hint: '首字後可見輸出 token 的平均速度', + speed_total: '整體速度', + speed_total_hint: '包含首字延遲在內的平均輸出速度', speed_mode: '速度模式', speed_mode_standard: '標準', speed_mode_fast: '快速', diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 3f7a322a..852f3194 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -228,6 +228,7 @@ export interface UsageEvent { latency_ms: number ttft_ms?: number speed_tps?: number + speed_total_tps?: number tokens: UsageEventTokens cost_usd?: number cost_available?: boolean From d75c9629eb6ce4dbf3dd560443f19bfd04531fcd Mon Sep 17 00:00:00 2001 From: t Date: Fri, 26 Jun 2026 21:59:05 +0800 Subject: [PATCH 09/11] fix: bump preferences version to show speed_total column by default --- web/dist/.gitkeep | 0 web/src/pages/UsagePage.logic.test.ts | 12 ++++++------ web/src/pages/UsagePage.tsx | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) delete mode 100644 web/dist/.gitkeep diff --git a/web/dist/.gitkeep b/web/dist/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/web/src/pages/UsagePage.logic.test.ts b/web/src/pages/UsagePage.logic.test.ts index e6d14583..544f91b0 100644 --- a/web/src/pages/UsagePage.logic.test.ts +++ b/web/src/pages/UsagePage.logic.test.ts @@ -544,7 +544,7 @@ describe('UsagePage request event preferences', () => { }); expect(preferences).toEqual({ - version: 2, + version: 3, pageSize: 500, filters: { model: 'claude-opus', @@ -605,7 +605,7 @@ describe('UsagePage request event preferences', () => { const hiddenSpeedColumnIds = REQUEST_EVENT_COLUMN_IDS.filter((columnId) => columnId !== 'speed'); saveRequestEventsPreferences({ - version: 2, + version: 3, pageSize: 100, filters: { model: '__all__', @@ -617,7 +617,7 @@ describe('UsagePage request event preferences', () => { const stored = JSON.parse(storage.value(REQUEST_EVENTS_PREFERENCES_STORAGE_KEY) ?? ''); expect(stored).toEqual({ - version: 2, + version: 3, pageSize: 100, filters: { model: '__all__', @@ -634,7 +634,7 @@ describe('UsagePage request event preferences', () => { const hiddenSpeedModeColumnIds = REQUEST_EVENT_COLUMN_IDS.filter((columnId) => columnId !== 'service_tier'); saveRequestEventsPreferences({ - version: 2, + version: 3, pageSize: 100, filters: { model: '__all__', @@ -655,7 +655,7 @@ describe('UsagePage request event preferences', () => { expect(loadRequestEventsPreferences(storage).pageSize).toBe(100); saveRequestEventsPreferences({ - version: 2, + version: 3, pageSize: 50, filters: { model: 'gpt-4.1', @@ -667,7 +667,7 @@ describe('UsagePage request event preferences', () => { expect(storage.setItem).toHaveBeenCalledTimes(1); expect(JSON.parse(storage.value(REQUEST_EVENTS_PREFERENCES_STORAGE_KEY) ?? '')).toEqual({ - version: 2, + version: 3, pageSize: 50, filters: { model: 'gpt-4.1', diff --git a/web/src/pages/UsagePage.tsx b/web/src/pages/UsagePage.tsx index c0d1225c..f20839f8 100644 --- a/web/src/pages/UsagePage.tsx +++ b/web/src/pages/UsagePage.tsx @@ -82,7 +82,7 @@ const DEFAULT_USAGE_TAB: UsageTab = 'overview'; const USAGE_TAB_STORAGE_KEY = 'cli-proxy-usage-tab-v1'; const REQUEST_EVENTS_PAGE_SIZES = [20, 50, 100, 500, 1000] as const; const REQUEST_EVENTS_DEFAULT_PAGE_SIZE = 100; -const REQUEST_EVENTS_PREFERENCES_VERSION = 2; +const REQUEST_EVENTS_PREFERENCES_VERSION = 3; const ALL_REQUEST_EVENTS_FILTER = '__all__'; const OVERVIEW_AUTO_REFRESH_INTERVAL_MS = 10_000; export const CUSTOM_DATE_RANGE_BOUNDS_REFRESH_INTERVAL_MS = 60_000; From 4c91078d7761a18a1f20768d1632cf610bdc57c2 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 28 Jun 2026 13:56:21 +0800 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20Dockerfile=20web-builder=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20BUILDPLATFORM=20=E9=81=BF=E5=85=8D=20QEMU?= =?UTF-8?q?=20arm64=20=E5=B4=A9=E6=BA=83;=20fix:=20useMemo=20=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E6=95=B0=E7=BB=84=E8=A1=A5=E5=85=85=20speedTotalHint;?= =?UTF-8?q?=20remove:=20=E5=88=A0=E9=99=A4=20Homebrew=20tap=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/binary-release.yml | 138 ------------------ Dockerfile | 3 +- .../usage/RequestEventsDetailsCard.tsx | 2 +- 3 files changed, 3 insertions(+), 140 deletions(-) diff --git a/.github/workflows/binary-release.yml b/.github/workflows/binary-release.yml index e3ce24fa..4cd33c80 100644 --- a/.github/workflows/binary-release.yml +++ b/.github/workflows/binary-release.yml @@ -251,141 +251,3 @@ jobs: dist/*.zip dist/checksums.txt - update-homebrew-tap: - runs-on: ubuntu-24.04 - needs: publish-release - steps: - - name: Download binary package artifacts - uses: actions/download-artifact@v6 - with: - path: dist - merge-multiple: true - - - name: Generate Homebrew formula values - id: formula - shell: bash - run: | - set -euo pipefail - tag="${GITHUB_REF_NAME}" - version="${tag#v}" - - cd dist - sha256sum *.tar.gz *.zip > checksums.txt - - arm_file="cpa-usage-keeper_${tag}_darwin_arm64.tar.gz" - amd_file="cpa-usage-keeper_${tag}_darwin_amd64.tar.gz" - arm_sha="$(awk -v f="${arm_file}" '$2 == f {print $1}' checksums.txt)" - amd_sha="$(awk -v f="${amd_file}" '$2 == f {print $1}' checksums.txt)" - - test -n "${arm_sha}" - test -n "${amd_sha}" - - { - echo "tag=${tag}" - echo "version=${version}" - echo "arm_file=${arm_file}" - echo "amd_file=${amd_file}" - echo "arm_sha=${arm_sha}" - echo "amd_sha=${amd_sha}" - } >> "${GITHUB_OUTPUT}" - - - name: Checkout Homebrew tap - uses: actions/checkout@v6 - with: - repository: Willxup/homebrew-cpa-usage-keeper - token: ${{ secrets.HOMEBREW_TAP_TOKEN }} - path: homebrew-tap - - - name: Update formula - shell: bash - env: - TAG: ${{ steps.formula.outputs.tag }} - VERSION: ${{ steps.formula.outputs.version }} - ARM_FILE: ${{ steps.formula.outputs.arm_file }} - AMD_FILE: ${{ steps.formula.outputs.amd_file }} - ARM_SHA: ${{ steps.formula.outputs.arm_sha }} - AMD_SHA: ${{ steps.formula.outputs.amd_sha }} - run: | - set -euo pipefail - mkdir -p homebrew-tap/Formula - - cat > homebrew-tap/Formula/cpa-usage-keeper.rb < "cpa-usage-keeper.env.example" - pkgshare.install "README.md", "README.zh.md", "LICENSE" - end - - def post_install - (var/"cpa-usage-keeper").mkpath - - env_file = etc/"cpa-usage-keeper.env" - cp etc/"cpa-usage-keeper.env.example", env_file unless env_file.exist? - end - - service do - run [opt_bin/"cpa-usage-keeper", "--env", etc/"cpa-usage-keeper.env"] - working_dir var/"cpa-usage-keeper" - keep_alive true - log_path var/"log/cpa-usage-keeper.log" - error_log_path var/"log/cpa-usage-keeper.err.log" - end - - def caveats - <<~EOS - Edit the configuration before starting the service: - - #{etc}/cpa-usage-keeper.env - - Required values include CPA_BASE_URL and CPA_MANAGEMENT_KEY. - - Start the service with: - - brew services start cpa-usage-keeper - EOS - end - - test do - assert_match "Usage of", shell_output("#{bin}/cpa-usage-keeper --help 2>&1") - end - end - EOF - - - name: Commit and push tap update - shell: bash - working-directory: homebrew-tap - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - git add Formula/cpa-usage-keeper.rb - if git diff --cached --quiet; then - echo "Homebrew formula already up to date" - exit 0 - fi - - git commit -m "Update cpa-usage-keeper to ${GITHUB_REF_NAME}" - git push diff --git a/Dockerfile b/Dockerfile index ca6d1872..f27a4b65 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ # syntax=docker/dockerfile:1 -FROM node:22-alpine AS web-builder +# web-builder: always build on native platform to avoid QEMU issues with npm/node +FROM --platform=$BUILDPLATFORM node:22-alpine AS web-builder WORKDIR /app/web COPY web/package.json web/package-lock.json ./ RUN npm ci diff --git a/web/src/components/usage/RequestEventsDetailsCard.tsx b/web/src/components/usage/RequestEventsDetailsCard.tsx index b3f87848..dc54d7a4 100644 --- a/web/src/components/usage/RequestEventsDetailsCard.tsx +++ b/web/src/components/usage/RequestEventsDetailsCard.tsx @@ -814,7 +814,7 @@ export function RequestEventsDetailsCard({ ]; return definitions; - }, [latencyHint, speedHint, t, ttftHint]); + }, [latencyHint, speedHint, speedTotalHint, t, ttftHint]); const visibleColumns = useMemo( () => columnDefinitions.filter((definition) => effectiveVisibleColumnIdSet.has(definition.id)), From 83ac4abd6a2dc8067471f214fc03bbcec9d61d1b Mon Sep 17 00:00:00 2001 From: t Date: Sun, 28 Jun 2026 17:10:29 +0800 Subject: [PATCH 11/11] fix(ci): create dummy web/dist for go:embed in backend tests --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8daef818..9cfe52ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,9 @@ jobs: path-type: inherit install: mingw-w64-ucrt-x86_64-gcc + - name: Create dummy dist for go:embed + run: mkdir -p web/dist && touch web/dist/.gitkeep + - name: Run backend tests if: ${{ runner.os != 'Windows' }} run: go test ./cmd/... ./internal/...