diff --git a/README.en.md b/README.en.md index 84f993b2..b20d4ec2 100644 --- a/README.en.md +++ b/README.en.md @@ -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 diff --git a/README.md b/README.md index 4915beef..4f23c9c0 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ - 分析页面提供 Token 趋势、模型/API Key/AI Provider 构成和时段热力图 - API Key 独立查询页,可按 CPA API Key 查看专属用量 - 凭证页面展示 Auth File 与 AI Provider 使用情况,支持凭证限额查询与刷新 -- 可维护模型价格,用于成本估算和统计展示 +- 可维护模型价格,用于成本估算和统计展示,支持 Token 单价与按请求次数计费 - 可选密码登录保护、SQLite 备份、Docker/Docker Compose 和 systemd 部署 ## 快速开始 diff --git a/internal/api/pricing.go b/internal/api/pricing.go index e8e7fe0b..c2fc24da 100644 --- a/internal/api/pricing.go +++ b/internal/api/pricing.go @@ -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 { @@ -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) { @@ -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}) @@ -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") { @@ -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, }) } diff --git a/internal/api/pricing_test.go b/internal/api/pricing_test.go index 860e5658..fba18a46 100644 --- a/internal/api/pricing_test.go +++ b/internal/api/pricing_test.go @@ -64,6 +64,7 @@ func TestPricingRoutesReturnConfiguredData(t *testing.T) { PromptPricePer1M: 3, CompletionPricePer1M: 15, CachePricePer1M: 0.3, + PricePerRequest: 0.063, }}, }, AuthConfig{}, nil, "") @@ -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()) } } @@ -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()) } } @@ -114,7 +116,7 @@ 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) @@ -122,7 +124,7 @@ func TestUpdatePricingRouteAcceptsModelInBody(t *testing.T) { 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) } } diff --git a/internal/entities/model_price_setting.go b/internal/entities/model_price_setting.go index a2e37479..61b31547 100644 --- a/internal/entities/model_price_setting.go +++ b/internal/entities/model_price_setting.go @@ -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"` } diff --git a/internal/helper/usage_cost.go b/internal/helper/usage_cost.go index ffbb138c..6da6960b 100644 --- a/internal/helper/usage_cost.go +++ b/internal/helper/usage_cost.go @@ -7,6 +7,7 @@ type UsageTokenCostInput struct { InputTokens int64 OutputTokens int64 CachedTokens int64 + Requests int64 } // UsageEventRequiresPricing 判断事件是否包含需要价格表解释的计费 token。 @@ -29,6 +30,7 @@ func CalculateUsageEventCost(event entities.UsageEvent, pricing entities.ModelPr InputTokens: event.InputTokens, OutputTokens: event.OutputTokens, CachedTokens: event.CachedTokens, + Requests: 1, }, pricing) } @@ -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 } diff --git a/internal/helper/usage_cost_test.go b/internal/helper/usage_cost_test.go index 94c8ab9c..6403e0e9 100644 --- a/internal/helper/usage_cost_test.go +++ b/internal/helper/usage_cost_test.go @@ -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) + } +} diff --git a/internal/repository/db_test.go b/internal/repository/db_test.go index 03a4e487..9ec1cae5 100644 --- a/internal/repository/db_test.go +++ b/internal/repository/db_test.go @@ -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()) diff --git a/internal/repository/dto/pricing.go b/internal/repository/dto/pricing.go index e550713f..e17b20e2 100644 --- a/internal/repository/dto/pricing.go +++ b/internal/repository/dto/pricing.go @@ -6,4 +6,5 @@ type ModelPriceSettingInput struct { PromptPricePer1M float64 CompletionPricePer1M float64 CachePricePer1M float64 + PricePerRequest float64 } diff --git a/internal/repository/migration/20260528_model_price_per_request.go b/internal/repository/migration/20260528_model_price_per_request.go new file mode 100644 index 00000000..4cc3edd9 --- /dev/null +++ b/internal/repository/migration/20260528_model_price_per_request.go @@ -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 +} diff --git a/internal/repository/migration/migration.go b/internal/repository/migration/migration.go index 3a4a56e4..781393e8 100644 --- a/internal/repository/migration/migration.go +++ b/internal/repository/migration/migration.go @@ -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 { @@ -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}, } } diff --git a/internal/repository/migration/migration_test.go b/internal/repository/migration/migration_test.go index b4bd9dc4..ef1cb38b 100644 --- a/internal/repository/migration/migration_test.go +++ b/internal/repository/migration/migration_test.go @@ -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) @@ -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) diff --git a/internal/repository/pricing.go b/internal/repository/pricing.go index f8e3dffd..04c6a1f3 100644 --- a/internal/repository/pricing.go +++ b/internal/repository/pricing.go @@ -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 @@ -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 { @@ -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) diff --git a/internal/repository/query_projection_test.go b/internal/repository/query_projection_test.go index 514e4166..73d6aa1b 100644 --- a/internal/repository/query_projection_test.go +++ b/internal/repository/query_projection_test.go @@ -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\")", ) } diff --git a/internal/repository/usage.go b/internal/repository/usage.go index 135ea444..eb1c218d 100644 --- a/internal/repository/usage.go +++ b/internal/repository/usage.go @@ -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 } @@ -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) } @@ -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 } diff --git a/internal/repository/usage_window_stats.go b/internal/repository/usage_window_stats.go index ac3075a3..703b58ce 100644 --- a/internal/repository/usage_window_stats.go +++ b/internal/repository/usage_window_stats.go @@ -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) { @@ -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。 @@ -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。 @@ -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 } @@ -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 diff --git a/internal/repository/usage_window_stats_test.go b/internal/repository/usage_window_stats_test.go index 0d181adb..62f85d18 100644 --- a/internal/repository/usage_window_stats_test.go +++ b/internal/repository/usage_window_stats_test.go @@ -158,3 +158,27 @@ func TestSumUsageWindowStatsByAuthIndexTreatsMissingPriceAsZeroCost(t *testing.T t.Fatalf("expected tokens with zero missing-price cost, got %+v", stats) } } + +func TestSumUsageWindowStatsByAuthIndexIncludesPerRequestPricing(t *testing.T) { + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-window-stats-per-request.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + start := time.Date(2026, 5, 28, 10, 0, 0, 0, time.UTC) + if err := db.Create(&entities.ModelPriceSetting{Model: "image-2", PricePerRequest: 0.063}).Error; err != nil { + t.Fatalf("seed pricing: %v", err) + } + for i := 0; i < 2; i++ { + if err := db.Create(&entities.UsageEvent{AuthType: "oauth", AuthIndex: "auth-image", Model: "image-2", Timestamp: start.Add(time.Duration(i) * time.Minute)}).Error; err != nil { + t.Fatalf("seed usage event: %v", err) + } + } + stats, err := SumUsageWindowStatsByAuthIndex(context.Background(), db, "auth-image", start.Add(-time.Minute), nil) + if err != nil { + t.Fatalf("SumUsageWindowStatsByAuthIndex returned error: %v", err) + } + if stats.Cost != 0.126 { + t.Fatalf("expected two image requests to cost 0.126, got %+v", stats) + } +} diff --git a/internal/service/dto/pricing.go b/internal/service/dto/pricing.go index 6dad62f5..60d74581 100644 --- a/internal/service/dto/pricing.go +++ b/internal/service/dto/pricing.go @@ -6,4 +6,5 @@ type UpdatePricingInput struct { PromptPricePer1M float64 CompletionPricePer1M float64 CachePricePer1M float64 + PricePerRequest float64 } diff --git a/internal/service/pricing_service.go b/internal/service/pricing_service.go index 841cafec..c347edb3 100644 --- a/internal/service/pricing_service.go +++ b/internal/service/pricing_service.go @@ -52,7 +52,7 @@ func (s *pricingService) UpdatePricing(ctx context.Context, input servicedto.Upd if modelName == "" { return nil, fmt.Errorf("model is required") } - if input.PromptPricePer1M < 0 || input.CompletionPricePer1M < 0 || input.CachePricePer1M < 0 { + if input.PromptPricePer1M < 0 || input.CompletionPricePer1M < 0 || input.CachePricePer1M < 0 || input.PricePerRequest < 0 { return nil, fmt.Errorf("prices must be non-negative") } @@ -74,6 +74,7 @@ func (s *pricingService) UpdatePricing(ctx context.Context, input servicedto.Upd PromptPricePer1M: input.PromptPricePer1M, CompletionPricePer1M: input.CompletionPricePer1M, CachePricePer1M: input.CachePricePer1M, + PricePerRequest: input.PricePerRequest, }) } diff --git a/web/src/components/usage/PriceSettingsCard.tsx b/web/src/components/usage/PriceSettingsCard.tsx index 949cb719..43681042 100644 --- a/web/src/components/usage/PriceSettingsCard.tsx +++ b/web/src/components/usage/PriceSettingsCard.tsx @@ -76,25 +76,29 @@ export function PriceSettingsCard({ const [promptPrice, setPromptPrice] = useState(''); const [completionPrice, setCompletionPrice] = useState(''); const [cachePrice, setCachePrice] = useState(''); + const [requestPrice, setRequestPrice] = useState(''); // 编辑弹窗独立保存草稿值,避免用户取消时污染已保存价格。 const [editModel, setEditModel] = useState(null); const [editPrompt, setEditPrompt] = useState(''); const [editCompletion, setEditCompletion] = useState(''); const [editCache, setEditCache] = useState(''); + const [editRequest, setEditRequest] = useState(''); const handleSavePrice = () => { if (!selectedModel) return; const prompt = parsePriceValue(promptPrice); const completion = parsePriceValue(completionPrice); const cache = cachePrice.trim() === '' ? prompt : parsePriceValue(cachePrice); - if (prompt === null || completion === null || cache === null) return; - const newPrices = { ...modelPrices, [selectedModel]: { prompt, completion, cache } }; + const request = requestPrice.trim() === '' ? 0 : parsePriceValue(requestPrice); + if (prompt === null || completion === null || cache === null || request === null) return; + const newPrices = { ...modelPrices, [selectedModel]: { prompt, completion, cache, request } }; onPricesChange(newPrices); setSelectedModel(''); setPromptPrice(''); setCompletionPrice(''); setCachePrice(''); + setRequestPrice(''); }; const handleDeletePrice = (model: string) => { @@ -109,6 +113,7 @@ export function PriceSettingsCard({ setEditPrompt(price?.prompt?.toString() || ''); setEditCompletion(price?.completion?.toString() || ''); setEditCache(price?.cache?.toString() || ''); + setEditRequest(price?.request?.toString() || ''); }; const handleSaveEdit = () => { @@ -116,8 +121,9 @@ export function PriceSettingsCard({ const prompt = parsePriceValue(editPrompt); const completion = parsePriceValue(editCompletion); const cache = editCache.trim() === '' ? prompt : parsePriceValue(editCache); - if (prompt === null || completion === null || cache === null) return; - const newPrices = { ...modelPrices, [editModel]: { prompt, completion, cache } }; + const request = editRequest.trim() === '' ? 0 : parsePriceValue(editRequest); + if (prompt === null || completion === null || cache === null || request === null) return; + const newPrices = { ...modelPrices, [editModel]: { prompt, completion, cache, request } }; onPricesChange(newPrices); setEditModel(null); }; @@ -129,10 +135,12 @@ export function PriceSettingsCard({ setPromptPrice(price.prompt.toString()); setCompletionPrice(price.completion.toString()); setCachePrice(price.cache.toString()); + setRequestPrice(price.request?.toString() || ''); } else { setPromptPrice(''); setCompletionPrice(''); setCachePrice(''); + setRequestPrice(''); } }; @@ -208,6 +216,17 @@ export function PriceSettingsCard({ className={styles.usagePillControl} /> +
+ + setRequestPrice(e.target.value)} + placeholder="0.00" + step="0.0001" + className={styles.usagePillControl} + /> +
@@ -232,6 +251,9 @@ export function PriceSettingsCard({ {t('usage_stats.model_price_cache')}: ${price.cache.toFixed(4)}/1M + + {t('usage_stats.model_price_request')}: ${(price.request ?? 0).toFixed(4)}/request +
@@ -304,6 +326,17 @@ export function PriceSettingsCard({ className={styles.usagePillControl} />
+
+ + setEditRequest(e.target.value)} + placeholder="0.00" + step="0.0001" + className={styles.usagePillControl} + /> +
diff --git a/web/src/components/usage/PricingCard.tsx b/web/src/components/usage/PricingCard.tsx index a5e56804..58d1161f 100644 --- a/web/src/components/usage/PricingCard.tsx +++ b/web/src/components/usage/PricingCard.tsx @@ -16,6 +16,7 @@ export function PricingCard({ usedModels, pricing, saving, error, onSave }: Pric const [promptPrice, setPromptPrice] = useState('') const [completionPrice, setCompletionPrice] = useState('') const [cachePrice, setCachePrice] = useState('') + const [requestPrice, setRequestPrice] = useState('') const pricingMap = useMemo(() => new Map(pricing.map((entry) => [entry.model, entry])), [pricing]) const sortedPricing = useMemo(() => [...pricing].sort((left, right) => left.model.localeCompare(right.model)), [pricing]) @@ -26,6 +27,7 @@ export function PricingCard({ usedModels, pricing, saving, error, onSave }: Pric setPromptPrice(existing ? String(existing.prompt_price_per_1m) : '') setCompletionPrice(existing ? String(existing.completion_price_per_1m) : '') setCachePrice(existing ? String(existing.cache_price_per_1m) : '') + setRequestPrice(existing ? String(existing.price_per_request) : '') } async function handleSubmit(event: React.FormEvent) { @@ -36,6 +38,7 @@ export function PricingCard({ usedModels, pricing, saving, error, onSave }: Pric prompt_price_per_1m: Number(promptPrice) || 0, completion_price_per_1m: Number(completionPrice) || 0, cache_price_per_1m: Number(cachePrice) || 0, + price_per_request: Number(requestPrice) || 0, }) } @@ -73,6 +76,10 @@ export function PricingCard({ usedModels, pricing, saving, error, onSave }: Pric Cache / 1M setCachePrice(event.target.value)} /> + @@ -87,6 +94,7 @@ export function PricingCard({ usedModels, pricing, saving, error, onSave }: Pric Prompt / 1M Completion / 1M Cache / 1M + Request Seen in usage {sortedPricing.length > 0 ? ( @@ -96,6 +104,7 @@ export function PricingCard({ usedModels, pricing, saving, error, onSave }: Pric ${formatNumber(entry.prompt_price_per_1m)} ${formatNumber(entry.completion_price_per_1m)} ${formatNumber(entry.cache_price_per_1m)} + ${formatNumber(entry.price_per_request)}/request {usedModels.includes(entry.model) ? 'Used' : 'Unknown'} )) diff --git a/web/src/components/usage/hooks/usePricingData.ts b/web/src/components/usage/hooks/usePricingData.ts index cd35c2fb..6c826e8d 100644 --- a/web/src/components/usage/hooks/usePricingData.ts +++ b/web/src/components/usage/hooks/usePricingData.ts @@ -24,10 +24,12 @@ const pricingToModelPrice = (entry: { prompt_price_per_1m: number; completion_price_per_1m: number; cache_price_per_1m: number; + price_per_request?: number; }): ModelPrice => ({ prompt: entry.prompt_price_per_1m, completion: entry.completion_price_per_1m, cache: entry.cache_price_per_1m, + request: entry.price_per_request ?? 0, }); export function usePricingData(options: UsePricingDataOptions = {}): UsePricingDataReturn { @@ -110,6 +112,7 @@ export function usePricingData(options: UsePricingDataOptions = {}): UsePricingD prompt_price_per_1m: pricing.prompt, completion_price_per_1m: pricing.completion, cache_price_per_1m: pricing.cache, + price_per_request: pricing.request ?? 0, }) ), ...Array.from(previousModels) diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts index 7156df78..8fac40ed 100644 --- a/web/src/i18n/index.ts +++ b/web/src/i18n/index.ts @@ -264,6 +264,7 @@ const resources = { model_price_prompt: 'Prompt', model_price_completion: 'Completion', model_price_cache: 'Cache', + model_price_request: 'Request', saved_prices: 'Saved Prices', model_price_empty: 'No saved prices', token_breakdown: 'Token Breakdown', @@ -563,6 +564,7 @@ const resources = { model_price_prompt: '输入', model_price_completion: '输出', model_price_cache: '缓存', + model_price_request: '按次', saved_prices: '已保存价格', model_price_empty: '暂无已保存价格', token_breakdown: 'Token 构成', @@ -862,6 +864,7 @@ const resources = { model_price_prompt: '輸入', model_price_completion: '輸出', model_price_cache: '快取', + model_price_request: '按次', saved_prices: '已儲存價格', model_price_empty: '尚無已儲存價格', token_breakdown: 'Token 組成', diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index e460ca79..29a3afa2 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -382,6 +382,7 @@ export interface PricingEntry { prompt_price_per_1m: number completion_price_per_1m: number cache_price_per_1m: number + price_per_request: number } export interface UsedModelsResponse { diff --git a/web/src/lib/usage.ts b/web/src/lib/usage.ts index 29b31ff5..9098930f 100644 --- a/web/src/lib/usage.ts +++ b/web/src/lib/usage.ts @@ -67,7 +67,8 @@ function calculateEventCost(event: UsageEventWithNames, priceMap: Map