Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ It relies on [CLIProxyAPI (CPA)](https://github.com/router-for-me/CLIProxyAPI) a
- Analysis page for token trends, model/API Key/AI Provider composition, and hourly heatmaps
- Standalone API Key usage page for querying usage by CPA API Key
- Credentials page for Auth File and AI Provider usage, with credential quota lookup and refresh
- Maintain model prices for cost estimation and reporting
- Maintain model prices for cost estimation and reporting, including token pricing and per-request pricing
- Optional password login protection, SQLite backups, Docker/Docker Compose, and systemd deployment

## Quick Start
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
- 分析页面提供 Token 趋势、模型/API Key/AI Provider 构成和时段热力图
- API Key 独立查询页,可按 CPA API Key 查看专属用量
- 凭证页面展示 Auth File 与 AI Provider 使用情况,支持凭证限额查询与刷新
- 可维护模型价格,用于成本估算和统计展示
- 可维护模型价格,用于成本估算和统计展示,支持 Token 单价与按请求次数计费
- 可选密码登录保护、SQLite 备份、Docker/Docker Compose 和 systemd 部署

## 快速开始
Expand Down
5 changes: 5 additions & 0 deletions internal/api/pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type pricingEntryResponse struct {
PromptPricePer1M float64 `json:"prompt_price_per_1m"`
CompletionPricePer1M float64 `json:"completion_price_per_1m"`
CachePricePer1M float64 `json:"cache_price_per_1m"`
PricePerRequest float64 `json:"price_per_request"`
}

type pricingListResponse struct {
Expand All @@ -29,6 +30,7 @@ type updatePricingRequest struct {
PromptPricePer1M float64 `json:"prompt_price_per_1m"`
CompletionPricePer1M float64 `json:"completion_price_per_1m"`
CachePricePer1M float64 `json:"cache_price_per_1m"`
PricePerRequest float64 `json:"price_per_request"`
}

func registerPricingRoutes(router gin.IRoutes, pricingProvider service.PricingProvider) {
Expand Down Expand Up @@ -66,6 +68,7 @@ func registerPricingRoutes(router gin.IRoutes, pricingProvider service.PricingPr
PromptPricePer1M: setting.PromptPricePer1M,
CompletionPricePer1M: setting.CompletionPricePer1M,
CachePricePer1M: setting.CachePricePer1M,
PricePerRequest: setting.PricePerRequest,
})
}
c.JSON(http.StatusOK, pricingListResponse{Pricing: response})
Expand Down Expand Up @@ -127,6 +130,7 @@ func updatePricing(c *gin.Context, pricingProvider service.PricingProvider, path
PromptPricePer1M: request.PromptPricePer1M,
CompletionPricePer1M: request.CompletionPricePer1M,
CachePricePer1M: request.CachePricePer1M,
PricePerRequest: request.PricePerRequest,
})
if err != nil {
if strings.Contains(err.Error(), "has not been used") || strings.Contains(err.Error(), "required") || strings.Contains(err.Error(), "non-negative") {
Expand All @@ -142,5 +146,6 @@ func updatePricing(c *gin.Context, pricingProvider service.PricingProvider, path
PromptPricePer1M: setting.PromptPricePer1M,
CompletionPricePer1M: setting.CompletionPricePer1M,
CachePricePer1M: setting.CachePricePer1M,
PricePerRequest: setting.PricePerRequest,
})
}
12 changes: 7 additions & 5 deletions internal/api/pricing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ func TestPricingRoutesReturnConfiguredData(t *testing.T) {
PromptPricePer1M: 3,
CompletionPricePer1M: 15,
CachePricePer1M: 0.3,
PricePerRequest: 0.063,
}},
}, AuthConfig{}, nil, "")

Expand All @@ -77,7 +78,7 @@ func TestPricingRoutesReturnConfiguredData(t *testing.T) {
pricingReq := httptest.NewRequest(http.MethodGet, "/api/v1/pricing", nil)
pricingResp := httptest.NewRecorder()
router.ServeHTTP(pricingResp, pricingReq)
if pricingResp.Code != http.StatusOK || !contains(pricingResp.Body.String(), `"prompt_price_per_1m":3`) {
if pricingResp.Code != http.StatusOK || !contains(pricingResp.Body.String(), `"price_per_request":0.063`) {
t.Fatalf("unexpected pricing response: %d %s", pricingResp.Code, pricingResp.Body.String())
}
}
Expand All @@ -89,16 +90,17 @@ func TestUpdatePricingRoute(t *testing.T) {
PromptPricePer1M: 3,
CompletionPricePer1M: 15,
CachePricePer1M: 0.3,
PricePerRequest: 0.063,
},
}
router := NewRouter(nil, nil, nil, provider, AuthConfig{}, nil, "")

req := httptest.NewRequest(http.MethodPut, "/api/v1/pricing/claude-sonnet", strings.NewReader(`{"prompt_price_per_1m":3,"completion_price_per_1m":15,"cache_price_per_1m":0.3}`))
req := httptest.NewRequest(http.MethodPut, "/api/v1/pricing/claude-sonnet", strings.NewReader(`{"prompt_price_per_1m":3,"completion_price_per_1m":15,"cache_price_per_1m":0.3,"price_per_request":0.063}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)

if resp.Code != http.StatusOK || !contains(resp.Body.String(), `"model":"claude-sonnet"`) {
if resp.Code != http.StatusOK || !contains(resp.Body.String(), `"price_per_request":0.063`) {
t.Fatalf("unexpected update response: %d %s", resp.Code, resp.Body.String())
}
}
Expand All @@ -114,15 +116,15 @@ func TestUpdatePricingRouteAcceptsModelInBody(t *testing.T) {
}
router := NewRouter(nil, nil, nil, provider, AuthConfig{}, nil, "")

req := httptest.NewRequest(http.MethodPut, "/api/v1/pricing", strings.NewReader(`{"model":"openai/gpt-4.1","prompt_price_per_1m":3,"completion_price_per_1m":15,"cache_price_per_1m":0.3}`))
req := httptest.NewRequest(http.MethodPut, "/api/v1/pricing", strings.NewReader(`{"model":"openai/gpt-4.1","prompt_price_per_1m":3,"completion_price_per_1m":15,"cache_price_per_1m":0.3,"price_per_request":0.063}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)

if resp.Code != http.StatusOK || !contains(resp.Body.String(), `"model":"openai/gpt-4.1"`) {
t.Fatalf("unexpected update response: %d %s", resp.Code, resp.Body.String())
}
if provider.lastUpdate == nil || provider.lastUpdate.Model != "openai/gpt-4.1" {
if provider.lastUpdate == nil || provider.lastUpdate.Model != "openai/gpt-4.1" || provider.lastUpdate.PricePerRequest != 0.063 {
t.Fatalf("expected model from body to be passed through, got %+v", provider.lastUpdate)
}
}
Expand Down
1 change: 1 addition & 0 deletions internal/entities/model_price_setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type ModelPriceSetting struct {
PromptPricePer1M float64
CompletionPricePer1M float64
CachePricePer1M float64
PricePerRequest float64
CreatedAt time.Time `gorm:"serializer:storageTime"`
UpdatedAt time.Time `gorm:"serializer:storageTime"`
}
9 changes: 8 additions & 1 deletion internal/helper/usage_cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type UsageTokenCostInput struct {
InputTokens int64
OutputTokens int64
CachedTokens int64
Requests int64
}

// UsageEventRequiresPricing 判断事件是否包含需要价格表解释的计费 token。
Expand All @@ -29,6 +30,7 @@ func CalculateUsageEventCost(event entities.UsageEvent, pricing entities.ModelPr
InputTokens: event.InputTokens,
OutputTokens: event.OutputTokens,
CachedTokens: event.CachedTokens,
Requests: 1,
}, pricing)
}

Expand All @@ -46,11 +48,16 @@ func CalculateUsageTokenCost(input UsageTokenCostInput, pricing entities.ModelPr
if cachedTokens < 0 {
cachedTokens = 0
}
requests := input.Requests
if requests < 0 {
requests = 0
}
promptTokens := inputTokens - cachedTokens
if promptTokens < 0 {
promptTokens = 0
}
return (float64(promptTokens)/1_000_000.0)*pricing.PromptPricePer1M +
(float64(outputTokens)/1_000_000.0)*pricing.CompletionPricePer1M +
(float64(cachedTokens)/1_000_000.0)*pricing.CachePricePer1M
(float64(cachedTokens)/1_000_000.0)*pricing.CachePricePer1M +
float64(requests)*pricing.PricePerRequest
}
9 changes: 9 additions & 0 deletions internal/helper/usage_cost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,12 @@ func TestUsageEventRequiresPricingUsesBillableTokenFields(t *testing.T) {
t.Fatal("expected input tokens to require pricing")
}
}

func TestCalculateUsageTokenCostAddsPerRequestPricing(t *testing.T) {
pricing := entities.ModelPriceSetting{PricePerRequest: 0.063}
cost := CalculateUsageTokenCost(UsageTokenCostInput{Requests: 3}, pricing)
want := 0.189
if cost != want {
t.Fatalf("expected per-request cost %.3f, got %.3f", want, cost)
}
}
4 changes: 2 additions & 2 deletions internal/repository/db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ func TestOpenDatabaseCreatesFreshDatabaseFromCurrentSchemaWithoutRunningMigratio
if err := db.Table("schema_migrations").Count(&count).Error; err != nil {
t.Fatalf("count schema migrations: %v", err)
}
if count != 29 {
t.Fatalf("expected fresh database to mark 29 migrations applied, got %d", count)
if count != 30 {
t.Fatalf("expected fresh database to mark 30 migrations applied, got %d", count)
}
if strings.Contains(logs.String(), "schema migration started") {
t.Fatalf("expected fresh database creation not to run version migrations, got logs:\n%s", logs.String())
Expand Down
1 change: 1 addition & 0 deletions internal/repository/dto/pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ type ModelPriceSettingInput struct {
PromptPricePer1M float64
CompletionPricePer1M float64
CachePricePer1M float64
PricePerRequest float64
}
15 changes: 15 additions & 0 deletions internal/repository/migration/20260528_model_price_per_request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package migration

import (
"fmt"

"cpa-usage-keeper/internal/entities"
"gorm.io/gorm"
)

func addModelPricePerRequestMigration(tx *gorm.DB) error {
if err := tx.AutoMigrate(&entities.ModelPriceSetting{}); err != nil {
return fmt.Errorf("auto migrate model price per request: %w", err)
}
return nil
}
2 changes: 2 additions & 0 deletions internal/repository/migration/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const (
migrationUsageOverviewRollupDimensions = "20260518_usage_overview_rollup_dimensions"
migrationAddUsageEventReasoningEffort = "20260519_add_usage_event_reasoning_effort"
migrationAddUsageEventQuotaWindowIndexes = "20260525_add_usage_event_quota_window_indexes"
migrationAddModelPricePerRequest = "20260528_add_model_price_per_request"
)

type schemaMigration struct {
Expand Down Expand Up @@ -122,6 +123,7 @@ func orderedMigrations() []databaseMigration {
{version: migrationUsageOverviewRollupDimensions, run: usageOverviewRollupDimensionsMigration, disableTransaction: true},
{version: migrationAddUsageEventReasoningEffort, run: addUsageEventReasoningEffortMigration},
{version: migrationAddUsageEventQuotaWindowIndexes, run: addUsageEventQuotaWindowIndexesMigration},
{version: migrationAddModelPricePerRequest, run: addModelPricePerRequestMigration},
}
}

Expand Down
2 changes: 2 additions & 0 deletions internal/repository/migration/migration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func TestOrderedMigrationsPreservesExecutionOrder(t *testing.T) {
"20260518_usage_overview_rollup_dimensions",
"20260519_add_usage_event_reasoning_effort",
"20260525_add_usage_event_quota_window_indexes",
"20260528_add_model_price_per_request",
}
if len(got) != len(want) {
t.Fatalf("expected ordered migrations %v, got %v", want, got)
Expand Down Expand Up @@ -112,6 +113,7 @@ func TestOpenDatabaseRunsSchemaMigrationsAndAddsUsageEventRedisFields(t *testing
"20260518_usage_overview_rollup_dimensions",
"20260519_add_usage_event_reasoning_effort",
"20260525_add_usage_event_quota_window_indexes",
"20260528_add_model_price_per_request",
}
if len(versions) != len(expected) {
t.Fatalf("expected migration versions %v, got %v", expected, versions)
Expand Down
5 changes: 3 additions & 2 deletions internal/repository/pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func ListModelPriceSettings(db *gorm.DB) ([]entities.ModelPriceSetting, error) {
}

var settings []entities.ModelPriceSetting
if err := db.Select("ID", "Model", "PromptPricePer1M", "CompletionPricePer1M", "CachePricePer1M", "CreatedAt", "UpdatedAt").Order("model asc").Find(&settings).Error; err != nil {
if err := db.Select("ID", "Model", "PromptPricePer1M", "CompletionPricePer1M", "CachePricePer1M", "PricePerRequest", "CreatedAt", "UpdatedAt").Order("model asc").Find(&settings).Error; err != nil {
return nil, fmt.Errorf("list pricing settings: %w", err)
}
return settings, nil
Expand All @@ -64,7 +64,7 @@ func UpsertModelPriceSetting(db *gorm.DB, input dto.ModelPriceSettingInput) (*en
}

setting := &entities.ModelPriceSetting{}
if err := db.Select("ID", "Model", "PromptPricePer1M", "CompletionPricePer1M", "CachePricePer1M", "CreatedAt", "UpdatedAt").Where("model = ?", modelName).First(setting).Error; err != nil {
if err := db.Select("ID", "Model", "PromptPricePer1M", "CompletionPricePer1M", "CachePricePer1M", "PricePerRequest", "CreatedAt", "UpdatedAt").Where("model = ?", modelName).First(setting).Error; err != nil {
if err == gorm.ErrRecordNotFound {
setting = &entities.ModelPriceSetting{Model: modelName}
} else {
Expand All @@ -76,6 +76,7 @@ func UpsertModelPriceSetting(db *gorm.DB, input dto.ModelPriceSettingInput) (*en
setting.PromptPricePer1M = input.PromptPricePer1M
setting.CompletionPricePer1M = input.CompletionPricePer1M
setting.CachePricePer1M = input.CachePricePer1M
setting.PricePerRequest = input.PricePerRequest

if err := db.Save(setting).Error; err != nil {
return nil, fmt.Errorf("save pricing setting: %w", err)
Expand Down
2 changes: 1 addition & 1 deletion internal/repository/query_projection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func TestRepositoryQueriesAvoidKnownFullEntityReads(t *testing.T) {
"Select(redisUsageInboxProcessingColumns).Where(\"status = ?\"",
)
assertFileContains(t, "pricing.go",
"Select(\"ID\", \"Model\", \"PromptPricePer1M\", \"CompletionPricePer1M\", \"CachePricePer1M\", \"CreatedAt\", \"UpdatedAt\")",
"Select(\"ID\", \"Model\", \"PromptPricePer1M\", \"CompletionPricePer1M\", \"CachePricePer1M\", \"PricePerRequest\", \"CreatedAt\", \"UpdatedAt\")",
)
}

Expand Down
6 changes: 3 additions & 3 deletions internal/repository/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -1045,7 +1045,7 @@ func applyUsageOverviewHourlyStatToOverview(overview *dto.UsageOverviewRecord, r
// 小时 stats 是完整小时事实,可直接累计到 snapshot totals。
applyUsageOverviewHourlyStatToSnapshot(overview.Usage, row)
// cost 不入 stats 表,必须在读取时按当前价格表重新计算。
rowCost := helper.CalculateUsageTokenCost(helper.UsageTokenCostInput{InputTokens: row.InputTokens, OutputTokens: row.OutputTokens, CachedTokens: row.CachedTokens}, pricingByModel[strings.TrimSpace(row.Model)])
rowCost := helper.CalculateUsageTokenCost(helper.UsageTokenCostInput{InputTokens: row.InputTokens, OutputTokens: row.OutputTokens, CachedTokens: row.CachedTokens, Requests: row.RequestCount}, pricingByModel[strings.TrimSpace(row.Model)])
if _, ok := pricingByModel[strings.TrimSpace(row.Model)]; !ok && helper.UsageTokenInputRequiresPricing(helper.UsageTokenCostInput{InputTokens: row.InputTokens, OutputTokens: row.OutputTokens, CachedTokens: row.CachedTokens}) {
overview.Summary.CostAvailable = false
}
Expand All @@ -1069,7 +1069,7 @@ func applyUsageOverviewHourlyStatToHourlySeries(overview *dto.UsageOverviewRecor
if latestHourlyStart != nil && timeutil.NormalizeStorageTime(row.BucketStart).Before(*latestHourlyStart) {
return
}
rowCost := helper.CalculateUsageTokenCost(helper.UsageTokenCostInput{InputTokens: row.InputTokens, OutputTokens: row.OutputTokens, CachedTokens: row.CachedTokens}, pricingByModel[strings.TrimSpace(row.Model)])
rowCost := helper.CalculateUsageTokenCost(helper.UsageTokenCostInput{InputTokens: row.InputTokens, OutputTokens: row.OutputTokens, CachedTokens: row.CachedTokens, Requests: row.RequestCount}, pricingByModel[strings.TrimSpace(row.Model)])
hourKey, hourMinutes := usageOverviewBucket(timeutil.NormalizeStorageTime(row.BucketStart), false)
applyUsageOverviewStatToSeries(&overview.HourlySeries, row.Model, row.RequestCount, row.InputTokens, row.OutputTokens, row.CachedTokens, row.ReasoningTokens, row.TotalTokens, rowCost, hourKey, hourMinutes)
}
Expand All @@ -1078,7 +1078,7 @@ func applyUsageOverviewHourlyStatToHourlySeries(overview *dto.UsageOverviewRecor
func applyUsageOverviewDailyStatToOverview(overview *dto.UsageOverviewRecord, row entities.UsageOverviewDailyStat, bucketByDay bool, pricingByModel map[string]entities.ModelPriceSetting) {
// 天 stats 只覆盖完整本地天,不能用于非整天边界。
applyUsageOverviewDailyStatToSnapshot(overview.Usage, row)
rowCost := helper.CalculateUsageTokenCost(helper.UsageTokenCostInput{InputTokens: row.InputTokens, OutputTokens: row.OutputTokens, CachedTokens: row.CachedTokens}, pricingByModel[strings.TrimSpace(row.Model)])
rowCost := helper.CalculateUsageTokenCost(helper.UsageTokenCostInput{InputTokens: row.InputTokens, OutputTokens: row.OutputTokens, CachedTokens: row.CachedTokens, Requests: row.RequestCount}, pricingByModel[strings.TrimSpace(row.Model)])
if _, ok := pricingByModel[strings.TrimSpace(row.Model)]; !ok && helper.UsageTokenInputRequiresPricing(helper.UsageTokenCostInput{InputTokens: row.InputTokens, OutputTokens: row.OutputTokens, CachedTokens: row.CachedTokens}) {
overview.Summary.CostAvailable = false
}
Expand Down
8 changes: 5 additions & 3 deletions internal/repository/usage_window_stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type usageWindowTokenStats struct {
InputTokens int64 `gorm:"column:input_tokens"`
OutputTokens int64 `gorm:"column:output_tokens"`
CachedTokens int64 `gorm:"column:cached_tokens"`
RequestCount int64 `gorm:"column:request_count"`
}

func SumUsageWindowStatsByAuthIndex(ctx context.Context, db *gorm.DB, authIndex string, start time.Time, end *time.Time) (UsageWindowStats, error) {
Expand Down Expand Up @@ -168,7 +169,7 @@ 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").
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(COUNT(*), 0) AS request_count").
// auth_index 已经是唯一身份维度,这里不再额外按 auth_type 过滤。
Where("auth_index = ? AND timestamp >= ?", authIndex, timeutil.FormatStorageTime(start)).
// 按 model 分组,后续按 model 价格表计算 cost。
Expand All @@ -193,7 +194,7 @@ 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").
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(request_count), 0) AS request_count").
// 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。
Expand Down Expand Up @@ -226,6 +227,7 @@ func mergeUsageWindowTokenStats(merged map[string]usageWindowTokenStats, rows []
current.OutputTokens += row.OutputTokens
// 累加 cached_tokens,用于 cache 成本计算并从 prompt 中扣除。
current.CachedTokens += row.CachedTokens
current.RequestCount += row.RequestCount
// 把合并后的 model 统计写回 map。
merged[model] = current
}
Expand Down Expand Up @@ -253,7 +255,7 @@ func usageWindowStatsFromTokenStats(rows []usageWindowTokenStats, pricingByModel
// model 名称按 trim 后查价格,保持和其它 Overview/Usage cost 逻辑一致。
pricing := pricingByModel[strings.TrimSpace(row.Model)]
// 使用统一 helper 按当前价格表计算该 model 的 cost。
stats.Cost += helper.CalculateUsageTokenCost(helper.UsageTokenCostInput{InputTokens: row.InputTokens, OutputTokens: row.OutputTokens, CachedTokens: row.CachedTokens}, pricing)
stats.Cost += helper.CalculateUsageTokenCost(helper.UsageTokenCostInput{InputTokens: row.InputTokens, OutputTokens: row.OutputTokens, CachedTokens: row.CachedTokens, Requests: row.RequestCount}, pricing)
}
// 返回最终窗口统计。
return stats
Expand Down
Loading