diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index 30d208c0d2..f5442448c8 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -64,6 +64,7 @@ type channelModelPricingRequest struct { OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"` CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"` CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"` + ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"` ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"` PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"` Intervals []pricingIntervalRequest `json:"intervals"` @@ -115,6 +116,7 @@ type channelModelPricingResponse struct { OutputPrice *float64 `json:"output_price"` CacheWritePrice *float64 `json:"cache_write_price"` CacheReadPrice *float64 `json:"cache_read_price"` + ImageInputPrice *float64 `json:"image_input_price"` ImageOutputPrice *float64 `json:"image_output_price"` PerRequestPrice *float64 `json:"per_request_price"` Intervals []pricingIntervalResponse `json:"intervals"` @@ -222,6 +224,7 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon OutputPrice: p.OutputPrice, CacheWritePrice: p.CacheWritePrice, CacheReadPrice: p.CacheReadPrice, + ImageInputPrice: p.ImageInputPrice, ImageOutputPrice: p.ImageOutputPrice, PerRequestPrice: p.PerRequestPrice, Intervals: intervals, @@ -273,6 +276,7 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe OutputPrice: r.OutputPrice, CacheWritePrice: r.CacheWritePrice, CacheReadPrice: r.CacheReadPrice, + ImageInputPrice: r.ImageInputPrice, ImageOutputPrice: r.ImageOutputPrice, PerRequestPrice: r.PerRequestPrice, Intervals: intervals, @@ -498,6 +502,7 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) { "output_price": pricing.OutputPricePerToken, "cache_write_price": pricing.CacheCreationPricePerToken, "cache_read_price": pricing.CacheReadPricePerToken, + "image_input_price": pricing.ImageInputPricePerToken, "image_output_price": pricing.ImageOutputPricePerToken, }) } diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index 8da4c07973..05d64e2002 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -72,6 +72,7 @@ type userSupportedModelPricing struct { OutputPrice *float64 `json:"output_price"` CacheWritePrice *float64 `json:"cache_write_price"` CacheReadPrice *float64 `json:"cache_read_price"` + ImageInputPrice *float64 `json:"image_input_price"` ImageOutputPrice *float64 `json:"image_output_price"` PerRequestPrice *float64 `json:"per_request_price"` Intervals []userPricingIntervalDTO `json:"intervals"` @@ -284,6 +285,7 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing { OutputPrice: p.OutputPrice, CacheWritePrice: p.CacheWritePrice, CacheReadPrice: p.CacheReadPrice, + ImageInputPrice: p.ImageInputPrice, ImageOutputPrice: p.ImageOutputPrice, PerRequestPrice: p.PerRequestPrice, Intervals: intervals, 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/channel_repo_pricing.go b/backend/internal/repository/channel_repo_pricing.go index 6dcf3c9175..995621139e 100644 --- a/backend/internal/repository/channel_repo_pricing.go +++ b/backend/internal/repository/channel_repo_pricing.go @@ -16,7 +16,7 @@ import ( func (r *channelRepository) ListModelPricing(ctx context.Context, channelID int64) ([]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_output_price, per_request_price, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, created_at, updated_at FROM channel_model_pricing WHERE channel_id = $1 ORDER BY id`, channelID, ) if err != nil { @@ -57,10 +57,10 @@ func (r *channelRepository) UpdateModelPricing(ctx context.Context, pricing *ser } result, err := r.db.ExecContext(ctx, `UPDATE channel_model_pricing - SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_read_price = $6, image_output_price = $7, per_request_price = $8, platform = $9, updated_at = NOW() - WHERE id = $10`, + SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_read_price = $6, image_input_price = $7, image_output_price = $8, per_request_price = $9, platform = $10, updated_at = NOW() + WHERE id = $11`, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice, - pricing.ImageOutputPrice, pricing.PerRequestPrice, pricing.Platform, pricing.ID, + pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, pricing.Platform, pricing.ID, ) if err != nil { return fmt.Errorf("update model pricing: %w", err) @@ -91,7 +91,7 @@ func (r *channelRepository) ReplaceModelPricing(ctx context.Context, channelID i // batchLoadModelPricing 批量加载多个渠道的模型定价(含区间) func (r *channelRepository) batchLoadModelPricing(ctx context.Context, channelIDs []int64) (map[int64][]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_output_price, per_request_price, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, created_at, updated_at FROM channel_model_pricing WHERE channel_id = ANY($1) ORDER BY channel_id, id`, pq.Array(channelIDs), ) @@ -172,7 +172,7 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6 if err := rows.Scan( &p.ID, &p.ChannelID, &p.Platform, &modelsJSON, &p.BillingMode, &p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheReadPrice, - &p.ImageOutputPrice, &p.PerRequestPrice, &p.CreatedAt, &p.UpdatedAt, + &p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &p.CreatedAt, &p.UpdatedAt, ); err != nil { return nil, nil, fmt.Errorf("scan model pricing: %w", err) } @@ -229,11 +229,11 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C platform = "anthropic" } err = exec.QueryRowContext(ctx, - `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_output_price, per_request_price) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at`, + `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at`, pricing.ChannelID, platform, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice, - pricing.ImageOutputPrice, pricing.PerRequestPrice, + pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, ).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt) if err != nil { return fmt.Errorf("insert model pricing: %w", err) 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 7fa69d41ea..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 @@ -802,6 +803,7 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) { LongContextInputThreshold: litellmPricing.LongContextInputTokenThreshold, LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier, LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier, + ImageInputPricePerToken: litellmPricing.InputCostPerImageToken, ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken, }), nil } @@ -859,6 +861,7 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing pricing.ImageOutputPricePerToken = 0 } pricing.ImageOutputPriceExplicit = true + applyChannelImageInputPrice(channelPricing, pricing) return pricing, nil } @@ -1001,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 @@ -1015,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 } @@ -1043,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 @@ -1277,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/channel.go b/backend/internal/service/channel.go index 1fd9e57068..4a3bdf304d 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -92,6 +92,7 @@ type ChannelModelPricing struct { OutputPrice *float64 // 每 token 输出价格(USD) CacheWritePrice *float64 // 缓存写入价格 CacheReadPrice *float64 // 缓存读取价格 + ImageInputPrice *float64 // 图片输入 token 价格(如 gpt-image-2 图片编辑);未配置时回退文本输入价 ImageOutputPrice *float64 // 图片输出价格(向后兼容) PerRequestPrice *float64 // 默认按次计费价格(USD) Intervals []PricingInterval // 区间定价列表 diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 5272fe0e93..25c7b587e3 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -645,6 +645,7 @@ func checkPricesNotNegative(p ChannelModelPricing) error { {"output_price", p.OutputPrice}, {"cache_write_price", p.CacheWritePrice}, {"cache_read_price", p.CacheReadPrice}, + {"image_input_price", p.ImageInputPrice}, {"image_output_price", p.ImageOutputPrice}, {"per_request_price", p.PerRequestPrice}, } diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index a9f603113b..ea5f4a29d1 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -161,6 +161,7 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin resolved.BasePricing.ImageOutputPricePerToken = 0 } resolved.BasePricing.ImageOutputPriceExplicit = true + applyChannelImageInputPrice(chPricing, resolved.BasePricing) return } @@ -199,6 +200,20 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin resolved.BasePricing.ImageOutputPricePerToken = 0 } resolved.BasePricing.ImageOutputPriceExplicit = true + applyChannelImageInputPrice(chPricing, resolved.BasePricing) +} + +// applyChannelImageInputPrice 应用渠道图片输入价:显式配置则用配置值; +// 未配置时归零,使 computeTokenBreakdown 回退到文本输入价(向后兼容, +// 避免 commit 引入的 LiteLLM 图片输入价泄漏进渠道自定义定价)。 +// 与 image_output 不同,此处不设 Explicit 标志——图片输入未配置应回退文本价, +// 而非硬置 0。 +func applyChannelImageInputPrice(chPricing *ChannelModelPricing, pricing *ModelPricing) { + if chPricing != nil && chPricing.ImageInputPrice != nil { + pricing.ImageInputPricePerToken = *chPricing.ImageInputPrice + } else { + pricing.ImageInputPricePerToken = 0 + } } // applyRequestTierOverrides 应用按次/图片模式的渠道覆盖 @@ -262,12 +277,14 @@ func intervalToModelPricing(iv *PricingInterval, supportsCacheBreakdown bool, ch pricing.CacheReadPricePerToken = *iv.CacheReadPrice pricing.CacheReadPricePerTokenPriority = *iv.CacheReadPrice } - // 渠道定价存在时,ImageOutputPrice 显式覆盖 + // 渠道定价存在时,ImageOutputPrice 显式覆盖;图片输入价用渠道级配置 + // (区间不携带图片输入价,与 image_output 一致)。 if chPricing != nil { pricing.ImageOutputPriceExplicit = true if chPricing.ImageOutputPrice != nil { pricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice } + applyChannelImageInputPrice(chPricing, pricing) } return pricing } diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 436e431163..8857bdd527 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -1015,8 +1015,16 @@ func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) { if imageOutputTokens == 0 { imageOutputTokens = value.Get("completion_tokens_details.image_tokens").Int() } + // 图片输入 token(如 gpt-image-2 的 /v1/images/edits 带图请求), + // 上游在 input_tokens_details.image_tokens 单独回传,用于图/文输入分价计费。 + // 普通文本请求该字段为 0,走原路径行为不变。 + imageInputTokens := firstPositiveGJSONInt( + value.Get("input_tokens_details.image_tokens"), + value.Get("prompt_tokens_details.image_tokens"), + ) return OpenAIUsage{ InputTokens: int(inputTokens), + ImageInputTokens: imageInputTokens, OutputTokens: int(outputTokens), CacheCreationInputTokens: cacheCreationTokens, CacheReadInputTokens: cacheReadTokens, 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/openai_images.go b/backend/internal/service/openai_images.go index 7a5d456d20..fa0b7f8b69 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -1151,6 +1151,9 @@ func mergeOpenAIUsage(dst *OpenAIUsage, body []byte) { if parsed.CacheReadInputTokens > 0 { dst.CacheReadInputTokens = parsed.CacheReadInputTokens } + if parsed.ImageInputTokens > 0 { + dst.ImageInputTokens = parsed.ImageInputTokens + } if parsed.ImageOutputTokens > 0 { dst.ImageOutputTokens = parsed.ImageOutputTokens } diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index d0ae3c7d96..814d6ddaa1 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -125,6 +125,7 @@ type LiteLLMModelPricing struct { SupportsPromptCaching bool `json:"supports_prompt_caching"` OutputCostPerImage float64 `json:"output_cost_per_image"` // 图片生成模型每张图片价格 OutputCostPerImageToken float64 `json:"output_cost_per_image_token"` // 图片输出 token 价格 + InputCostPerImageToken float64 `json:"input_cost_per_image_token"` // 图片输入 token 价格(如 gpt-image-2 图片编辑) // TokenPricingAbsent 表示源数据中 input/output token 价格均缺失(仅有图片价)。 // 此类条目只可用于图片计费,token 计费必须回退到 fallback 或 fail-closed, @@ -158,6 +159,7 @@ type LiteLLMRawEntry struct { SupportsPromptCaching bool `json:"supports_prompt_caching"` OutputCostPerImage *float64 `json:"output_cost_per_image"` OutputCostPerImageToken *float64 `json:"output_cost_per_image_token"` + InputCostPerImageToken *float64 `json:"input_cost_per_image_token"` } // PricingService 动态价格服务 @@ -435,7 +437,7 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel } // 只保留有有效价格的条目 - if entry.InputCostPerToken == nil && entry.OutputCostPerToken == nil && entry.OutputCostPerImage == nil && entry.OutputCostPerImageToken == nil { + if entry.InputCostPerToken == nil && entry.OutputCostPerToken == nil && entry.OutputCostPerImage == nil && entry.OutputCostPerImageToken == nil && entry.InputCostPerImageToken == nil { continue } @@ -489,6 +491,9 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel if entry.OutputCostPerImageToken != nil { pricing.OutputCostPerImageToken = *entry.OutputCostPerImageToken } + if entry.InputCostPerImageToken != nil { + pricing.InputCostPerImageToken = *entry.InputCostPerImageToken + } result[modelName] = pricing } 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/178_channel_image_input_price.sql b/backend/migrations/178_channel_image_input_price.sql new file mode 100644 index 0000000000..34a8f885e9 --- /dev/null +++ b/backend/migrations/178_channel_image_input_price.sql @@ -0,0 +1,8 @@ +-- 178_channel_image_input_price.sql +-- 渠道自定义定价(token 模式)新增图片输入 token 单价列。 +-- 用于 gpt-image-2 等模型的图片编辑/图生图请求:上游 usage 的 +-- input_tokens_details.image_tokens 需按独立单价计费,区别于文本 input_price。 +-- 未配置(NULL)时计费回退到文本输入价,保持向后兼容。 + +ALTER TABLE channel_model_pricing + ADD COLUMN IF NOT EXISTS image_input_price NUMERIC(20,12); 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/api/admin/channels.ts b/frontend/src/api/admin/channels.ts index afa43a2d7b..fdbeadf57a 100644 --- a/frontend/src/api/admin/channels.ts +++ b/frontend/src/api/admin/channels.ts @@ -30,6 +30,7 @@ export interface ChannelModelPricing { output_price: number | null cache_write_price: number | null cache_read_price: number | null + image_input_price: number | null image_output_price: number | null per_request_price: number | null intervals: PricingInterval[] @@ -154,6 +155,7 @@ export interface ModelDefaultPricing { output_price?: number cache_write_price?: number cache_read_price?: number + image_input_price?: number image_output_price?: number } diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index 8eaf7eed95..46e350809f 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -39,6 +39,7 @@ export interface UserSupportedModelPricing { output_price: number | null cache_write_price: number | null cache_read_price: number | null + image_input_price: number | null image_output_price: number | null per_request_price: number | null intervals: UserPricingInterval[] diff --git a/frontend/src/components/admin/channel/PricingEntryCard.vue b/frontend/src/components/admin/channel/PricingEntryCard.vue index 9fc103ef56..086a9cd307 100644 --- a/frontend/src/components/admin/channel/PricingEntryCard.vue +++ b/frontend/src/components/admin/channel/PricingEntryCard.vue @@ -101,7 +101,7 @@ {{ t('admin.channels.form.defaultPrices') }} $/MTok -
+
+
+ + +
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 @@