Skip to content

Commit b8b91a5

Browse files
committed
feat: 优化 Stats 页面和升级计费精度至纳美元
## Stats 页面优化 - 添加 year 粒度支持,用于全时间范围查询 - 修复时间范围边界问题,避免 lastWeek/lastMonth 等重复计算边界日期 - 使用系统配置的时区而不是 UTC 进行时间桶计算 - 添加筛选条件摘要显示(精确到时间) - 修复 model 参数未传递到 API 的问题 - 移除侧边栏折叠功能,保持始终显示 - 图表颜色使用主题变量,Cost 在图例和 Tooltip 中优先显示 - 修复 Filter 清空按钮的布局位移问题 - Provider 分组显示类型名称标签 ## 计费精度升级 - 成本计算从微美元(microUSD)升级到纳美元(nanoUSD),精度提升1000倍 - 使用 big.Int 进行计算,防止大数量 token 时的整数溢出 - 添加 CacheCreationCount fallback 支持 - 保留旧函数作为兼容层(标记为 Deprecated)
1 parent e34a25c commit b8b91a5

40 files changed

+2970
-535
lines changed

cmd/maxx/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ func main() {
228228
responseModelRepo,
229229
*addr,
230230
r, // Router implements ProviderAdapterRefresher interface
231+
wsHub,
231232
)
232233

233234
// Create backup service

internal/core/database.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ func InitializeServerComponents(
275275
repos.ResponseModelRepo,
276276
addr,
277277
r,
278+
wailsBroadcaster,
278279
)
279280

280281
log.Printf("[Core] Creating backup service")

internal/domain/model.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ type ProxyRequest struct {
239239
Cache5mWriteCount uint64 `json:"cache5mWriteCount"`
240240
Cache1hWriteCount uint64 `json:"cache1hWriteCount"`
241241

242-
// 成本 (微美元,1 USD = 1,000,000)
242+
// 成本 (纳美元,1 USD = 1,000,000,000 nanoUSD)
243243
Cost uint64 `json:"cost"`
244244

245245
// 使用的 API Token ID,0 表示未使用 Token
@@ -295,6 +295,22 @@ type ProxyUpstreamAttempt struct {
295295
Cost uint64 `json:"cost"`
296296
}
297297

298+
// AttemptCostData contains minimal data needed for cost recalculation
299+
type AttemptCostData struct {
300+
ID uint64
301+
ProxyRequestID uint64
302+
ResponseModel string
303+
MappedModel string
304+
RequestModel string
305+
InputTokenCount uint64
306+
OutputTokenCount uint64
307+
CacheReadCount uint64
308+
CacheWriteCount uint64
309+
Cache5mWriteCount uint64
310+
Cache1hWriteCount uint64
311+
Cost uint64
312+
}
313+
298314
// 重试配置
299315
type RetryConfig struct {
300316
ID uint64 `json:"id"`
@@ -432,7 +448,7 @@ type ProviderStats struct {
432448
TotalCacheRead uint64 `json:"totalCacheRead"`
433449
TotalCacheWrite uint64 `json:"totalCacheWrite"`
434450

435-
// 成本 (微美元)
451+
// 成本 (纳美元)
436452
TotalCost uint64 `json:"totalCost"`
437453
}
438454

@@ -445,6 +461,7 @@ const (
445461
GranularityDay Granularity = "day"
446462
GranularityWeek Granularity = "week"
447463
GranularityMonth Granularity = "month"
464+
GranularityYear Granularity = "year"
448465
)
449466

450467
// UsageStats 使用统计汇总(多层级时间聚合)
@@ -476,7 +493,7 @@ type UsageStats struct {
476493
CacheRead uint64 `json:"cacheRead"`
477494
CacheWrite uint64 `json:"cacheWrite"`
478495

479-
// 成本 (微美元)
496+
// 成本 (纳美元)
480497
Cost uint64 `json:"cost"`
481498
}
482499

@@ -756,3 +773,14 @@ type DashboardData struct {
756773
ProviderStats map[uint64]DashboardProviderStats `json:"providerStats"`
757774
Timezone string `json:"timezone"` // 配置的时区,如 "Asia/Shanghai"
758775
}
776+
777+
// ===== Progress Reporting =====
778+
779+
// Progress represents a progress update for long-running operations
780+
type Progress struct {
781+
Phase string `json:"phase"` // Current phase of the operation
782+
Current int `json:"current"` // Current item being processed
783+
Total int `json:"total"` // Total items to process
784+
Percentage int `json:"percentage"` // 0-100
785+
Message string `json:"message"` // Human-readable message
786+
}

internal/executor/executor.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,12 @@ func (e *Executor) Execute(ctx context.Context, w http.ResponseWriter, req *http
404404
Cache5mCreationCount: attemptRecord.Cache5mWriteCount,
405405
Cache1hCreationCount: attemptRecord.Cache1hWriteCount,
406406
}
407-
attemptRecord.Cost = pricing.GlobalCalculator().Calculate(attemptRecord.MappedModel, metrics)
407+
// Use ResponseModel for pricing (actual model from API response), fallback to MappedModel
408+
pricingModel := attemptRecord.ResponseModel
409+
if pricingModel == "" {
410+
pricingModel = attemptRecord.MappedModel
411+
}
412+
attemptRecord.Cost = pricing.GlobalCalculator().Calculate(pricingModel, metrics)
408413
}
409414

410415
_ = e.attemptRepo.Update(attemptRecord)
@@ -476,7 +481,12 @@ func (e *Executor) Execute(ctx context.Context, w http.ResponseWriter, req *http
476481
Cache5mCreationCount: attemptRecord.Cache5mWriteCount,
477482
Cache1hCreationCount: attemptRecord.Cache1hWriteCount,
478483
}
479-
attemptRecord.Cost = pricing.GlobalCalculator().Calculate(attemptRecord.MappedModel, metrics)
484+
// Use ResponseModel for pricing (actual model from API response), fallback to MappedModel
485+
pricingModel := attemptRecord.ResponseModel
486+
if pricingModel == "" {
487+
pricingModel = attemptRecord.MappedModel
488+
}
489+
attemptRecord.Cost = pricing.GlobalCalculator().Calculate(pricingModel, metrics)
480490
}
481491

482492
_ = e.attemptRepo.Update(attemptRecord)

internal/handler/admin.go

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/awsl-project/maxx/internal/cooldown"
1111
"github.com/awsl-project/maxx/internal/domain"
12+
"github.com/awsl-project/maxx/internal/pricing"
1213
"github.com/awsl-project/maxx/internal/repository"
1314
"github.com/awsl-project/maxx/internal/service"
1415
)
@@ -88,6 +89,8 @@ func (h *AdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
8889
h.handleResponseModels(w, r)
8990
case "backup":
9091
h.handleBackup(w, r, parts)
92+
case "pricing":
93+
h.handlePricing(w, r)
9194
default:
9295
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
9396
}
@@ -650,7 +653,7 @@ func (h *AdminHandler) handleRoutingStrategies(w http.ResponseWriter, r *http.Re
650653
}
651654

652655
// ProxyRequest handlers
653-
// Routes: /admin/requests, /admin/requests/count, /admin/requests/active, /admin/requests/{id}, /admin/requests/{id}/attempts
656+
// Routes: /admin/requests, /admin/requests/count, /admin/requests/active, /admin/requests/{id}, /admin/requests/{id}/attempts, /admin/requests/{id}/recalculate-cost
654657
func (h *AdminHandler) handleProxyRequests(w http.ResponseWriter, r *http.Request, id uint64, parts []string) {
655658
// Check for count endpoint: /admin/requests/count
656659
if len(parts) > 2 && parts[2] == "count" {
@@ -670,6 +673,12 @@ func (h *AdminHandler) handleProxyRequests(w http.ResponseWriter, r *http.Reques
670673
return
671674
}
672675

676+
// Check for sub-resource: /admin/requests/{id}/recalculate-cost
677+
if len(parts) > 3 && parts[3] == "recalculate-cost" && id > 0 {
678+
h.handleRecalculateRequestCost(w, r, id)
679+
return
680+
}
681+
673682
switch r.Method {
674683
case http.MethodGet:
675684
if id > 0 {
@@ -691,7 +700,18 @@ func (h *AdminHandler) handleProxyRequests(w http.ResponseWriter, r *http.Reques
691700
if a := r.URL.Query().Get("after"); a != "" {
692701
after, _ = strconv.ParseUint(a, 10, 64)
693702
}
694-
result, err := h.svc.GetProxyRequestsCursor(limit, before, after)
703+
704+
// 构建过滤条件
705+
var filter *repository.ProxyRequestFilter
706+
if p := r.URL.Query().Get("providerId"); p != "" {
707+
if providerID, err := strconv.ParseUint(p, 10, 64); err == nil {
708+
filter = &repository.ProxyRequestFilter{
709+
ProviderID: &providerID,
710+
}
711+
}
712+
}
713+
714+
result, err := h.svc.GetProxyRequestsCursor(limit, before, after, filter)
695715
if err != nil {
696716
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
697717
return
@@ -710,7 +730,18 @@ func (h *AdminHandler) handleProxyRequestsCount(w http.ResponseWriter, r *http.R
710730
return
711731
}
712732

713-
count, err := h.svc.GetProxyRequestsCount()
733+
// 解析 providerId 过滤参数
734+
var filter *repository.ProxyRequestFilter
735+
if providerIDStr := r.URL.Query().Get("providerId"); providerIDStr != "" {
736+
providerID, err := strconv.ParseUint(providerIDStr, 10, 64)
737+
if err != nil {
738+
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid providerId"})
739+
return
740+
}
741+
filter = &repository.ProxyRequestFilter{ProviderID: &providerID}
742+
}
743+
744+
count, err := h.svc.GetProxyRequestsCountWithFilter(filter)
714745
if err != nil {
715746
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
716747
return
@@ -748,6 +779,21 @@ func (h *AdminHandler) handleProxyUpstreamAttempts(w http.ResponseWriter, r *htt
748779
writeJSON(w, http.StatusOK, attempts)
749780
}
750781

782+
// handleRecalculateRequestCost handles POST /admin/requests/{id}/recalculate-cost
783+
func (h *AdminHandler) handleRecalculateRequestCost(w http.ResponseWriter, r *http.Request, requestID uint64) {
784+
if r.Method != http.MethodPost {
785+
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
786+
return
787+
}
788+
789+
result, err := h.svc.RecalculateRequestCost(requestID)
790+
if err != nil {
791+
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
792+
return
793+
}
794+
writeJSON(w, http.StatusOK, result)
795+
}
796+
751797
// Settings handlers
752798
func (h *AdminHandler) handleSettings(w http.ResponseWriter, r *http.Request, parts []string) {
753799
var key string
@@ -1168,6 +1214,11 @@ func (h *AdminHandler) handleUsageStats(w http.ResponseWriter, r *http.Request)
11681214
h.handleRecalculateUsageStats(w, r)
11691215
return
11701216
}
1217+
// Check for recalculate-costs endpoint: /admin/usage-stats/recalculate-costs
1218+
if strings.HasSuffix(path, "/recalculate-costs") {
1219+
h.handleRecalculateCosts(w, r)
1220+
return
1221+
}
11711222

11721223
if r.Method != http.MethodGet {
11731224
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
@@ -1259,6 +1310,22 @@ func (h *AdminHandler) handleRecalculateUsageStats(w http.ResponseWriter, r *htt
12591310
writeJSON(w, http.StatusOK, map[string]string{"message": "usage stats recalculated successfully"})
12601311
}
12611312

1313+
// handleRecalculateCosts handles POST /admin/usage-stats/recalculate-costs
1314+
// Recalculates cost for all attempts using the current price table
1315+
func (h *AdminHandler) handleRecalculateCosts(w http.ResponseWriter, r *http.Request) {
1316+
if r.Method != http.MethodPost {
1317+
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
1318+
return
1319+
}
1320+
1321+
result, err := h.svc.RecalculateCosts()
1322+
if err != nil {
1323+
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
1324+
return
1325+
}
1326+
writeJSON(w, http.StatusOK, result)
1327+
}
1328+
12621329
// handleResponseModels handles GET /admin/response-models
12631330
func (h *AdminHandler) handleResponseModels(w http.ResponseWriter, r *http.Request) {
12641331
if r.Method != http.MethodGet {
@@ -1359,6 +1426,18 @@ func (h *AdminHandler) handleBackupImport(w http.ResponseWriter, r *http.Request
13591426
writeJSON(w, http.StatusOK, result)
13601427
}
13611428

1429+
// handlePricing handles GET /admin/pricing
1430+
// Returns the default price table for cost calculation display
1431+
func (h *AdminHandler) handlePricing(w http.ResponseWriter, r *http.Request) {
1432+
if r.Method != http.MethodGet {
1433+
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
1434+
return
1435+
}
1436+
1437+
priceTable := pricing.DefaultPriceTable()
1438+
writeJSON(w, http.StatusOK, priceTable)
1439+
}
1440+
13621441
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
13631442
w.Header().Set("Content-Type", "application/json")
13641443
w.WriteHeader(status)

internal/pricing/calculator.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func NewCalculator(pt *PriceTable) *Calculator {
3434
}
3535
}
3636

37-
// Calculate 计算成本,返回微美元 (1 USD = 1,000,000 microUSD)
37+
// Calculate 计算成本,返回纳美元 (1 USD = 1,000,000,000 nanoUSD)
3838
// model: 模型名称
3939
// metrics: token使用指标
4040
// 如果模型未找到,返回0并记录警告日志
@@ -56,6 +56,7 @@ func (c *Calculator) Calculate(model string, metrics *usage.Metrics) uint64 {
5656
}
5757

5858
// CalculateWithPricing 使用指定价格计算成本(纯整数运算)
59+
// 返回: 纳美元成本 (nanoUSD)
5960
func (c *Calculator) CalculateWithPricing(pricing *ModelPricing, metrics *usage.Metrics) uint64 {
6061
if pricing == nil || metrics == nil {
6162
return 0
@@ -67,56 +68,64 @@ func (c *Calculator) CalculateWithPricing(pricing *ModelPricing, metrics *usage.
6768
if metrics.InputTokens > 0 {
6869
if pricing.Has1MContext {
6970
inputNum, inputDenom := pricing.GetInputPremiumFraction()
70-
totalCost += CalculateTieredCostMicro(
71+
totalCost += CalculateTieredCost(
7172
metrics.InputTokens,
7273
pricing.InputPriceMicro,
7374
inputNum, inputDenom,
7475
pricing.GetContext1MThreshold(),
7576
)
7677
} else {
77-
totalCost += CalculateLinearCostMicro(metrics.InputTokens, pricing.InputPriceMicro)
78+
totalCost += CalculateLinearCost(metrics.InputTokens, pricing.InputPriceMicro)
7879
}
7980
}
8081

8182
// 2. 输出成本
8283
if metrics.OutputTokens > 0 {
8384
if pricing.Has1MContext {
8485
outputNum, outputDenom := pricing.GetOutputPremiumFraction()
85-
totalCost += CalculateTieredCostMicro(
86+
totalCost += CalculateTieredCost(
8687
metrics.OutputTokens,
8788
pricing.OutputPriceMicro,
8889
outputNum, outputDenom,
8990
pricing.GetContext1MThreshold(),
9091
)
9192
} else {
92-
totalCost += CalculateLinearCostMicro(metrics.OutputTokens, pricing.OutputPriceMicro)
93+
totalCost += CalculateLinearCost(metrics.OutputTokens, pricing.OutputPriceMicro)
9394
}
9495
}
9596

9697
// 3. 缓存读取成本(使用 input 价格的 10%)
9798
if metrics.CacheReadCount > 0 {
98-
totalCost += CalculateLinearCostMicro(
99+
totalCost += CalculateLinearCost(
99100
metrics.CacheReadCount,
100101
pricing.GetEffectiveCacheReadPriceMicro(),
101102
)
102103
}
103104

104105
// 4. 5分钟缓存写入成本(使用 input 价格的 125%)
105106
if metrics.Cache5mCreationCount > 0 {
106-
totalCost += CalculateLinearCostMicro(
107+
totalCost += CalculateLinearCost(
107108
metrics.Cache5mCreationCount,
108109
pricing.GetEffectiveCache5mWritePriceMicro(),
109110
)
110111
}
111112

112113
// 5. 1小时缓存写入成本(使用 input 价格的 200%)
113114
if metrics.Cache1hCreationCount > 0 {
114-
totalCost += CalculateLinearCostMicro(
115+
totalCost += CalculateLinearCost(
115116
metrics.Cache1hCreationCount,
116117
pricing.GetEffectiveCache1hWritePriceMicro(),
117118
)
118119
}
119120

121+
// 6. Fallback: 如果没有 5m/1h 细分但有总缓存写入数
122+
if metrics.Cache5mCreationCount == 0 && metrics.Cache1hCreationCount == 0 && metrics.CacheCreationCount > 0 {
123+
totalCost += CalculateLinearCost(
124+
metrics.CacheCreationCount,
125+
pricing.GetEffectiveCache5mWritePriceMicro(), // 使用 5m 价格作为默认
126+
)
127+
}
128+
120129
return totalCost
121130
}
122131

0 commit comments

Comments
 (0)