diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 3c45c3b95e..7aa12d7a40 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -634,6 +634,8 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog { ImageSize: l.ImageSize, ImageInputSize: l.ImageInputSize, ImageOutputSize: l.ImageOutputSize, + ImageInputTokens: l.ImageInputTokens, + ImageInputCost: l.ImageInputCost, ImageOutputTokens: l.ImageOutputTokens, ImageOutputCost: l.ImageOutputCost, ImageSizeSource: l.ImageSizeSource, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 619926c1e4..89d3bc5634 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -503,6 +503,8 @@ type UsageLog struct { ImageSize *string `json:"image_size"` ImageInputSize *string `json:"image_input_size"` ImageOutputSize *string `json:"image_output_size"` + ImageInputTokens int `json:"image_input_tokens"` + ImageInputCost float64 `json:"image_input_cost"` ImageOutputTokens int `json:"image_output_tokens"` ImageOutputCost float64 `json:"image_output_cost"` ImageSizeSource *string `json:"image_size_source"` diff --git a/backend/internal/repository/usage_log_repo_insert.go b/backend/internal/repository/usage_log_repo_insert.go index ec09b308a0..54d24b5957 100644 --- a/backend/internal/repository/usage_log_repo_insert.go +++ b/backend/internal/repository/usage_log_repo_insert.go @@ -41,6 +41,8 @@ var usageLogInsertArgTypes = [...]string{ "integer", // cache_creation_1h_tokens "integer", // image_output_tokens "numeric", // image_output_cost + "integer", // image_input_tokens + "numeric", // image_input_cost "numeric", // input_cost "numeric", // output_cost "numeric", // cache_creation_cost @@ -234,6 +236,8 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, cache_creation_1h_tokens, image_output_tokens, image_output_cost, + image_input_tokens, + image_input_cost, input_cost, output_cost, cache_creation_cost, @@ -277,7 +281,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54 + $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -686,6 +690,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage cache_creation_1h_tokens, image_output_tokens, image_output_cost, + image_input_tokens, + image_input_cost, input_cost, output_cost, cache_creation_cost, @@ -725,7 +731,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage created_at ) AS (VALUES `) - args := make([]any, 0, len(keys)*54) + args := make([]any, 0, len(keys)*56) argPos := 1 for idx, key := range keys { if idx > 0 { @@ -771,6 +777,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage cache_creation_1h_tokens, image_output_tokens, image_output_cost, + image_input_tokens, + image_input_cost, input_cost, output_cost, cache_creation_cost, @@ -827,6 +835,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage cache_creation_1h_tokens, image_output_tokens, image_output_cost, + image_input_tokens, + image_input_cost, input_cost, output_cost, cache_creation_cost, @@ -923,6 +933,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( cache_creation_1h_tokens, image_output_tokens, image_output_cost, + image_input_tokens, + image_input_cost, input_cost, output_cost, cache_creation_cost, @@ -962,7 +974,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( created_at ) AS (VALUES `) - args := make([]any, 0, len(preparedList)*54) + args := make([]any, 0, len(preparedList)*56) argPos := 1 for idx, prepared := range preparedList { if idx > 0 { @@ -1005,6 +1017,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( cache_creation_1h_tokens, image_output_tokens, image_output_cost, + image_input_tokens, + image_input_cost, input_cost, output_cost, cache_creation_cost, @@ -1061,6 +1075,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( cache_creation_1h_tokens, image_output_tokens, image_output_cost, + image_input_tokens, + image_input_cost, input_cost, output_cost, cache_creation_cost, @@ -1125,6 +1141,8 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared cache_creation_1h_tokens, image_output_tokens, image_output_cost, + image_input_tokens, + image_input_cost, input_cost, output_cost, cache_creation_cost, @@ -1168,7 +1186,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54 + $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56 ) ON CONFLICT (request_id, api_key_id) DO NOTHING `, prepared.args...) @@ -1243,6 +1261,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { log.CacheCreation1hTokens, log.ImageOutputTokens, log.ImageOutputCost, + log.ImageInputTokens, + log.ImageInputCost, log.InputCost, log.OutputCost, log.CacheCreationCost, diff --git a/backend/internal/repository/usage_log_repo_query.go b/backend/internal/repository/usage_log_repo_query.go index 1fdedd8665..fb0e03ed60 100644 --- a/backend/internal/repository/usage_log_repo_query.go +++ b/backend/internal/repository/usage_log_repo_query.go @@ -19,7 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" ) -const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at" +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at" func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) { query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1" @@ -443,6 +443,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e cacheCreation1h int imageOutputTokens int imageOutputCost float64 + imageInputTokens int + imageInputCost float64 inputCost float64 outputCost float64 cacheCreationCost float64 @@ -501,6 +503,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &cacheCreation1h, &imageOutputTokens, &imageOutputCost, + &imageInputTokens, + &imageInputCost, &inputCost, &outputCost, &cacheCreationCost, @@ -557,6 +561,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e CacheCreation1hTokens: cacheCreation1h, ImageOutputTokens: imageOutputTokens, ImageOutputCost: imageOutputCost, + ImageInputTokens: imageInputTokens, + ImageInputCost: imageInputCost, InputCost: inputCost, OutputCost: outputCost, CacheCreationCost: cacheCreationCost, diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index 052c319183..f039029608 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -58,6 +58,8 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) { log.CacheCreation1hTokens, log.ImageOutputTokens, log.ImageOutputCost, + log.ImageInputTokens, + log.ImageInputCost, log.InputCost, log.OutputCost, log.CacheCreationCost, @@ -145,6 +147,8 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) { log.CacheCreation1hTokens, log.ImageOutputTokens, log.ImageOutputCost, + log.ImageInputTokens, + log.ImageInputCost, log.InputCost, log.OutputCost, log.CacheCreationCost, @@ -267,11 +271,11 @@ func TestPrepareUsageLogInsert_PersistsImageSizeMetadata(t *testing.T) { CreatedAt: time.Date(2025, 1, 6, 12, 0, 0, 0, time.UTC), }) - require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[34]) - require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[35]) - require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[36]) - require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[37]) - breakdownJSON, ok := prepared.args[38].(string) + require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[36]) + require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[37]) + require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[38]) + require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[39]) + breakdownJSON, ok := prepared.args[40].(string) require.True(t, ok) require.JSONEq(t, `{"1K":1,"4K":1}`, breakdownJSON) } @@ -790,6 +794,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullInt64{}, 0, 0, 0, 0, 0, 0, 0, 0.0, // image_output_tokens, image_output_cost + 0, 0.0, // image_input_tokens, image_input_cost 0.0, 0.0, 0.0, 0.0, 0.8, 0.8, 1.0, sql.NullFloat64{}, @@ -857,6 +862,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { 6, // cache_creation_1h_tokens 0, // image_output_tokens 0.0, // image_output_cost + 0, // image_input_tokens + 0.0, // image_input_cost 0.1, // input_cost 0.2, // output_cost 0.3, // cache_creation_cost @@ -918,6 +925,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullInt64{}, 1, 2, 3, 4, 5, 6, 0, 0.0, // image_output_tokens, image_output_cost + 0, 0.0, // image_input_tokens, image_input_cost 0.1, 0.2, 0.3, 0.4, 1.0, 0.9, 1.0, sql.NullFloat64{}, @@ -974,6 +982,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullInt64{}, 1, 2, 3, 4, 5, 6, 0, 0.0, // image_output_tokens, image_output_cost + 0, 0.0, // image_input_tokens, image_input_cost 0.1, 0.2, 0.3, 0.4, 1.0, 0.9, 1.0, sql.NullFloat64{}, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index f1fd4230be..29b8ac37cd 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -603,6 +603,8 @@ func TestAPIContracts(t *testing.T) { "image_size": null, "image_input_size": null, "image_output_size": null, + "image_input_tokens": 0, + "image_input_cost": 0, "image_output_tokens": 0, "image_output_cost": 0, "image_size_source": null, diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 9b3f169a7b..8ebe754bdb 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -153,7 +153,8 @@ type UsageTokens struct { // CostBreakdown 费用明细 type CostBreakdown struct { - InputCost float64 + InputCost float64 // 文本输入费用(不含图片输入,图片输入单独记入 ImageInputCost) + ImageInputCost float64 // 图片输入 token 费用(如 gpt-image-2 图片编辑) OutputCost float64 ImageOutputCost float64 CacheCreationCost float64 @@ -1003,7 +1004,8 @@ func (s *BillingService) computeTokenBreakdown( } bd := &CostBreakdown{} - // 分离图片输入 token 与文本输入 token(多模态 embedding 等图文不同价场景)。 + // 分离图片输入 token 与文本输入 token(多模态 embedding、图片编辑等图文不同价场景)。 + // InputCost 仅计文本输入,图片输入费用单独记入 ImageInputCost,便于对账;总额不变。 // ImageInputTokens 为 0 时(绝大多数 chat/vision 流量)走原始单价路径,行为不变。 if tokens.ImageInputTokens > 0 { imageInputTokens := tokens.ImageInputTokens @@ -1017,7 +1019,8 @@ func (s *BillingService) computeTokenBreakdown( // 未配置图片输入档时回退到文本 input 价(已含 priority / 长上下文调整) imageInputPrice = inputPrice } - bd.InputCost = float64(textInputTokens)*inputPrice + float64(imageInputTokens)*imageInputPrice + bd.InputCost = float64(textInputTokens) * inputPrice + bd.ImageInputCost = float64(imageInputTokens) * imageInputPrice } else { bd.InputCost = float64(tokens.InputTokens) * inputPrice } @@ -1045,13 +1048,14 @@ func (s *BillingService) computeTokenBreakdown( if tierMultiplier != 1.0 { bd.InputCost *= tierMultiplier + bd.ImageInputCost *= tierMultiplier bd.OutputCost *= tierMultiplier bd.ImageOutputCost *= tierMultiplier bd.CacheCreationCost *= tierMultiplier bd.CacheReadCost *= tierMultiplier } - bd.TotalCost = bd.InputCost + bd.OutputCost + bd.ImageOutputCost + + bd.TotalCost = bd.InputCost + bd.ImageInputCost + bd.OutputCost + bd.ImageOutputCost + bd.CacheCreationCost + bd.CacheReadCost bd.ActualCost = bd.TotalCost * rateMultiplier bd.LongContextBillingApplied = baselineCost != nil && bd.ActualCost > baselineCost.ActualCost @@ -1279,6 +1283,7 @@ func (s *BillingService) CalculateCostWithLongContext(model string, tokens Usage // 合并成本 return &CostBreakdown{ InputCost: inRangeCost.InputCost + outRangeCost.InputCost, + ImageInputCost: inRangeCost.ImageInputCost + outRangeCost.ImageInputCost, OutputCost: inRangeCost.OutputCost, ImageOutputCost: inRangeCost.ImageOutputCost, CacheCreationCost: inRangeCost.CacheCreationCost, diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 885da194e3..265fbf4fa4 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -755,7 +755,7 @@ func TestGetModelPricing_DoubaoEmbeddingVisionImageInputRate(t *testing.T) { } } -// 验证双档计费:InputCost = 文本token×文本价 + 图片token×图片价; +// 验证双档计费:InputCost = 文本token×文本价(不含图片),ImageInputCost = 图片token×图片价; // 且 ImageInputTokens=0 时走原单价路径,ImageInputTokens>InputTokens 时不负计文本。 func TestCalculateCost_DoubaoEmbeddingVisionDifferentialInput(t *testing.T) { svc := newTestBillingService() @@ -764,22 +764,60 @@ func TestCalculateCost_DoubaoEmbeddingVisionDifferentialInput(t *testing.T) { mixed := UsageTokens{InputTokens: 1340, ImageInputTokens: 28} cost, err := svc.CalculateCost("doubao-embedding-vision", mixed, 1.0) require.NoError(t, err) - wantMixed := float64(1312)*0.098e-6 + float64(28)*0.252e-6 - require.InDelta(t, wantMixed, cost.InputCost, 1e-15) - require.InDelta(t, wantMixed, cost.TotalCost, 1e-15) + wantText := float64(1312) * 0.098e-6 + wantImage := float64(28) * 0.252e-6 + require.InDelta(t, wantText, cost.InputCost, 1e-15, "InputCost 仅计文本输入") + require.InDelta(t, wantImage, cost.ImageInputCost, 1e-15, "ImageInputCost 单独计图片输入") + require.InDelta(t, wantText+wantImage, cost.TotalCost, 1e-15, "TotalCost 口径不变") require.Zero(t, cost.OutputCost) - // 纯文本:全部按文本档计费,与原单价路径一致。 + // 纯文本:全部按文本档计费,与原单价路径一致,无图片输入费用。 textOnly := UsageTokens{InputTokens: 1340} costText, err := svc.CalculateCost("doubao-embedding-vision", textOnly, 1.0) require.NoError(t, err) require.InDelta(t, float64(1340)*0.098e-6, costText.InputCost, 1e-15) + require.Zero(t, costText.ImageInputCost) // 健壮性:ImageInputTokens 超过 InputTokens 时,文本置 0、计费 token 不超过 InputTokens。 weird := UsageTokens{InputTokens: 10, ImageInputTokens: 50} costWeird, err := svc.CalculateCost("doubao-embedding-vision", weird, 1.0) require.NoError(t, err) - require.InDelta(t, float64(10)*0.252e-6, costWeird.InputCost, 1e-15) + require.Zero(t, costWeird.InputCost, "全为图片输入时文本费用为 0") + require.InDelta(t, float64(10)*0.252e-6, costWeird.ImageInputCost, 1e-15) + require.InDelta(t, float64(10)*0.252e-6, costWeird.TotalCost, 1e-15) +} + +// 复现 issue #4386:gpt-image-2 /v1/images/edits 带 1 张输入图。 +// 上游 usage:input_tokens=371(image_tokens=352 + text_tokens=19), +// output_tokens=439(全部图片输出)。官方定价:文本输入 $5/1M、图片输入 $8/1M、 +// 文本输出 $10/1M、图片输出 $30/1M。修复前图片输入被并入文本价,单次偏低 ~6.6%。 +func TestComputeTokenBreakdown_GptImage2ImageEditIssue4386(t *testing.T) { + svc := newTestBillingService() + + pricing := &ModelPricing{ + InputPricePerToken: 5e-6, + ImageInputPricePerToken: 8e-6, + OutputPricePerToken: 10e-6, + ImageOutputPricePerToken: 30e-6, + ImageOutputPriceExplicit: true, + } + tokens := UsageTokens{ + InputTokens: 371, + ImageInputTokens: 352, + OutputTokens: 439, + ImageOutputTokens: 439, + } + + cost := svc.computeTokenBreakdown(pricing, tokens, 1.0, "", false) + + wantTextInput := float64(19) * 5e-6 // 0.000095 + wantImageInput := float64(352) * 8e-6 // 0.002816 + wantImageOutput := float64(439) * 30e-6 // 0.013170 + require.InDelta(t, wantTextInput, cost.InputCost, 1e-15, "InputCost 仅含文本输入") + require.InDelta(t, wantImageInput, cost.ImageInputCost, 1e-15, "图片输入按 $8/1M 独立计费") + require.Zero(t, cost.OutputCost, "输出全部为图片,文本输出费用为 0") + require.InDelta(t, wantImageOutput, cost.ImageOutputCost, 1e-15) + require.InDelta(t, 0.016081, cost.TotalCost, 1e-9, "总额应为 $0.016081(修复前为 $0.015025)") } func TestCalculateCostWithLongContext_BelowThreshold(t *testing.T) { svc := newTestBillingService() diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 507fb9d5c3..49ef5b5f7e 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -259,6 +259,31 @@ func TestExtractOpenAIResponseIDFromJSONBytes(t *testing.T) { require.Empty(t, extractOpenAIResponseIDFromJSONBytes([]byte(`not-json`))) } +// 复现 #4386:gpt-image-2 /v1/images/edits 的 usage 携带 input_tokens_details.image_tokens, +// 提取器须将图片输入 token 单独填入 ImageInputTokens(此前被丢弃并入 InputTokens 按文本价计费)。 +func TestExtractOpenAIUsage_CapturesImageInputTokens(t *testing.T) { + body := []byte(`{"usage":{"input_tokens":371,"input_tokens_details":{"image_tokens":352,"text_tokens":19},"output_tokens":439,"output_tokens_details":{"image_tokens":439,"text_tokens":0},"total_tokens":810}}`) + usage, ok := extractOpenAIUsageFromJSONBytes(body) + require.True(t, ok) + require.Equal(t, 371, usage.InputTokens) + require.Equal(t, 352, usage.ImageInputTokens) + require.Equal(t, 439, usage.OutputTokens) + require.Equal(t, 439, usage.ImageOutputTokens) + + // prompt_tokens_details 回退路径(部分上游用 prompt_tokens 口径)。 + promptStyle := []byte(`{"usage":{"prompt_tokens":100,"prompt_tokens_details":{"image_tokens":80}}}`) + pu, ok := extractOpenAIUsageFromJSONBytes(promptStyle) + require.True(t, ok) + require.Equal(t, 100, pu.InputTokens) + require.Equal(t, 80, pu.ImageInputTokens) + + // 纯文本请求:无 image_tokens 时 ImageInputTokens 为 0,行为不变。 + textOnly := []byte(`{"usage":{"input_tokens":50,"output_tokens":10}}`) + tu, ok := extractOpenAIUsageFromJSONBytes(textOnly) + require.True(t, ok) + require.Zero(t, tu.ImageInputTokens) +} + func TestOpenAIGatewayService_BindHTTPResponseAccount(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 410b4d944d..a4ec35c041 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -254,6 +254,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec OutputTokens: result.Usage.OutputTokens, CacheCreationTokens: result.Usage.CacheCreationInputTokens, CacheReadTokens: result.Usage.CacheReadInputTokens, + ImageInputTokens: result.Usage.ImageInputTokens, ImageOutputTokens: result.Usage.ImageOutputTokens, ImageCount: result.ImageCount, ImageSize: optionalTrimmedStringPtr(result.ImageSize), @@ -271,6 +272,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec } if cost != nil { usageLog.InputCost = cost.InputCost + usageLog.ImageInputCost = cost.ImageInputCost usageLog.OutputCost = cost.OutputCost usageLog.ImageOutputCost = cost.ImageOutputCost usageLog.CacheCreationCost = cost.CacheCreationCost diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index 0adcc04a94..4e8df04aaf 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -139,6 +139,8 @@ type UsageLog struct { CacheCreation5mTokens int `gorm:"column:cache_creation_5m_tokens"` CacheCreation1hTokens int `gorm:"column:cache_creation_1h_tokens"` + ImageInputTokens int + ImageInputCost float64 ImageOutputTokens int ImageOutputCost float64 diff --git a/backend/migrations/179_usage_log_image_input_tokens.sql b/backend/migrations/179_usage_log_image_input_tokens.sql new file mode 100644 index 0000000000..99706da3f5 --- /dev/null +++ b/backend/migrations/179_usage_log_image_input_tokens.sql @@ -0,0 +1,6 @@ +-- 179_usage_log_image_input_tokens.sql +-- usage_logs 单独记录图片输入 token 数与费用,便于图片编辑/图生图场景对账。 +-- image_input_tokens 从 input_tokens 中拆出,image_input_cost 从 input_cost 中拆出, +-- total_cost 口径不变。 +ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS image_input_tokens INTEGER NOT NULL DEFAULT 0; +ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS image_input_cost DECIMAL(20, 10) NOT NULL DEFAULT 0; diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index 623ac705d3..ea69fefb2f 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -144,6 +144,12 @@ R +
+
+ + {{ row.image_input_tokens.toLocaleString() }} +
+
@@ -246,10 +252,18 @@
{{ t('usage.tokenDetails') }}
-
+
{{ t('admin.usage.inputTokens') }} {{ tokenTooltipData.input_tokens.toLocaleString() }}
+
+ {{ t('admin.usage.inputTokens') }} + {{ textInputTokens(tokenTooltipData).toLocaleString() }} +
+
+ {{ t('usage.imageInputTokens') }} + {{ tokenTooltipData.image_input_tokens.toLocaleString() }} +
{{ t('admin.usage.outputTokens') }} {{ tokenTooltipData.output_tokens.toLocaleString() }} @@ -327,6 +341,10 @@ {{ t('admin.usage.inputCost') }} ${{ tooltipData.input_cost.toFixed(6) }}
+
+ {{ t('usage.imageInputCost') }} + ${{ tooltipData.image_input_cost.toFixed(6) }} +
{{ t('admin.usage.outputCost') }} ${{ tooltipData.output_cost.toFixed(6) }} @@ -337,9 +355,13 @@