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/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/service/billing_service.go b/backend/internal/service/billing_service.go index 873773d9f9..9b3f169a7b 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -860,6 +860,7 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing pricing.ImageOutputPricePerToken = 0 } pricing.ImageOutputPriceExplicit = true + applyChannelImageInputPrice(channelPricing, pricing) return pricing, nil } 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/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/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 -
+
+
+ + +
+