package helper import ( "fmt" "math" "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" ) type imageBillingMode string const ( imageBillingModeTextToImage imageBillingMode = "text_to_image" imageBillingModeImageToImage imageBillingMode = "image_to_image" ) type imageEstimateContext struct { Mode imageBillingMode Width int Height int Count int } // HasImagePerImageTablePricing reports whether resolution-tier per-image rules exist. func HasImagePerImageTablePricing(channelID int, modelName string) bool { _, ok := resolveImagePricingRules(channelID, modelName) return ok } // HasImageGenerationPricing reports whether per-image generation pricing is configured. func HasImageGenerationPricing(channelID int, modelName string) bool { if HasImagePerImageTablePricing(channelID, modelName) { return true } for _, name := range imageModelNameCandidates(modelName) { if _, ok := ratio_setting.GetImagePrice(name); ok { return true } if _, ok := ratio_setting.GetChannelImagePrice(channelID, name); ok { return true } } return false } func imageModelNameCandidates(modelName string) []string { name := ratio_setting.FormatMatchingModelName(strings.TrimSpace(modelName)) if name == "" { return nil } return []string{name} } func imageModelNameCandidatesFromInfo(info *relaycommon.RelayInfo) []string { if info == nil { return nil } seen := make(map[string]struct{}, 6) out := make([]string, 0, 4) add := func(name string) { name = strings.TrimSpace(name) if name == "" { return } formatted := ratio_setting.FormatMatchingModelName(name) if formatted == "" { return } if _, ok := seen[formatted]; ok { return } seen[formatted] = struct{}{} out = append(out, formatted) } add(info.OriginModelName) if info.ChannelMeta != nil { add(info.UpstreamModelName) } return out } func resolveImagePricingRules(channelID int, modelName string) (ratio_setting.ImagePricingRules, bool) { return resolveImagePricingRulesForNames(channelID, imageModelNameCandidates(modelName)) } func resolveImagePricingRulesForInfo(channelID int, info *relaycommon.RelayInfo) (ratio_setting.ImagePricingRules, bool) { return resolveImagePricingRulesForNames(channelID, imageModelNameCandidatesFromInfo(info)) } func resolveImagePricingRulesForNames(channelID int, names []string) (ratio_setting.ImagePricingRules, bool) { var merged ratio_setting.ImagePricingRules hasMerged := false for _, name := range names { if name == "" { continue } if rules, ok := ratio_setting.GetChannelImagePricingRules(channelID, name); ok { merged = mergeImagePricingRules(merged, rules) hasMerged = hasMerged || ratio_setting.HasUsableImagePerImageRules(rules) } if rules, ok := ratio_setting.GetImagePricingRules(name); ok { merged = mergeImagePricingRules(merged, rules) hasMerged = hasMerged || ratio_setting.HasUsableImagePerImageRules(rules) } } if !hasMerged || !ratio_setting.HasUsableImagePerImageRules(merged) { return ratio_setting.ImagePricingRules{}, false } return normalizeMergedImageRules(merged), true } func mergeImagePricingRules(dst, src ratio_setting.ImagePricingRules) ratio_setting.ImagePricingRules { if dst.SimilarityThreshold <= 0 && src.SimilarityThreshold > 0 { dst.SimilarityThreshold = src.SimilarityThreshold } if dst.PriceUnit == "" && src.PriceUnit != "" { dst.PriceUnit = src.PriceUnit } dst.TextToImagePerImage = mergeImagePerImageRows(dst.TextToImagePerImage, src.TextToImagePerImage) dst.ImageToImagePerImage = mergeImagePerImageRows(dst.ImageToImagePerImage, src.ImageToImagePerImage) return dst } func mergeImagePerImageRows(dst, src []ratio_setting.ImageResolutionPerImageRule) []ratio_setting.ImageResolutionPerImageRule { if len(src) == 0 { return dst } index := make(map[string]int, len(dst)) for i, row := range dst { index[strings.ToLower(strings.TrimSpace(row.Resolution))] = i } for _, row := range src { key := strings.ToLower(strings.TrimSpace(row.Resolution)) if key == "" || row.ImagePrice <= 0 { continue } if i, ok := index[key]; ok { dst[i] = row continue } dst = append(dst, row) index[key] = len(dst) - 1 } return dst } func normalizeMergedImageRules(v ratio_setting.ImagePricingRules) ratio_setting.ImagePricingRules { if v.SimilarityThreshold <= 0 { v.SimilarityThreshold = 0.35 } return v } func resolveImageFlatUSD(channelID int, modelName string) (float64, bool) { for _, name := range imageModelNameCandidates(modelName) { if price, ok := ratio_setting.GetChannelImagePrice(channelID, name); ok && price > 0 { return price, true } if price, ok := ratio_setting.GetImagePrice(name); ok && price > 0 { return price, true } } return 0, false } func HasImageGenerationPricingForInfo(channelID int, info *relaycommon.RelayInfo) bool { if info == nil { return false } if HasImagePerImageTablePricingForInfo(channelID, info) { return true } for _, name := range imageModelNameCandidatesFromInfo(info) { if _, ok := ratio_setting.GetImagePrice(name); ok { return true } if _, ok := ratio_setting.GetChannelImagePrice(channelID, name); ok { return true } } return false } func HasImagePerImageTablePricingForInfo(channelID int, info *relaycommon.RelayInfo) bool { _, ok := resolveImagePricingRulesForInfo(channelID, info) return ok } func resolveChannelOnlyImagePricingRules(channelID int, names []string) (ratio_setting.ImagePricingRules, bool) { for _, name := range names { if name == "" { continue } if rules, ok := ratio_setting.GetChannelImagePricingRules(channelID, name); ok && ratio_setting.HasUsableImagePerImageRules(rules) { return rules, true } } return ratio_setting.ImagePricingRules{}, false } func resolveGlobalOnlyImagePricingRules(names []string) (ratio_setting.ImagePricingRules, bool) { for _, name := range names { if name == "" { continue } if rules, ok := ratio_setting.GetImagePricingRules(name); ok && ratio_setting.HasUsableImagePerImageRules(rules) { return rules, true } } return ratio_setting.ImagePricingRules{}, false } func resolveChannelImageFlatUSD(channelID int, names []string) (float64, bool) { for _, name := range names { if price, ok := ratio_setting.GetChannelImagePrice(channelID, name); ok && price > 0 { return price, true } } return 0, false } func resolveGlobalImageFlatUSD(names []string) (float64, bool) { for _, name := range names { if price, ok := ratio_setting.GetImagePrice(name); ok && price > 0 { return price, true } } return 0, false } func resolveImageFlatUSDForInfo(channelID int, info *relaycommon.RelayInfo) (float64, bool) { for _, name := range imageModelNameCandidatesFromInfo(info) { if price, ok := ratio_setting.GetChannelImagePrice(channelID, name); ok && price > 0 { return price, true } if price, ok := ratio_setting.GetImagePrice(name); ok && price > 0 { return price, true } } return 0, false } // TryModelPriceHelperImage prices image generation when per-image rules or flat ImagePrice exist. // Returns (priceData, true, nil) on success; (zero, false, nil) when not configured. func TryModelPriceHelperImage(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, bool, error) { if info == nil { return types.PriceData{}, false, nil } channelID := 0 if info.ChannelMeta != nil { channelID = info.ChannelId } modelName := info.OriginModelName if !HasImageGenerationPricing(channelID, modelName) && !HasImageGenerationPricingForInfo(channelID, info) { return types.PriceData{}, false, nil } names := imageModelNameCandidatesFromInfo(info) if len(names) == 0 { names = imageModelNameCandidates(modelName) } estimateCtx := estimateImageRequestContext(c, info) channelUSD, globalUSD, chOK, glOK := resolveImagePerImageUnitUSD(channelID, names, estimateCtx) usdPerImage := channelUSD okPrice := chOK if !okPrice || usdPerImage <= 0 { usdPerImage = globalUSD okPrice = glOK } if !okPrice || usdPerImage <= 0 { matchName := ratio_setting.FormatMatchingModelName(modelName) if matchName == "" { matchName = modelName } return types.PriceData{}, false, fmt.Errorf( "图片模型 %s 未设置按张价格,请配置文生图/图生图分辨率价格或兜底每张价格;Image model %s per-image price not set", matchName, matchName, ) } count := estimateCtx.Count if count <= 0 { count = 1 } estimateCtx.Count = count priceData, ok := buildImagePerImagePriceData(c, info, channelID, channelUSD, globalUSD, chOK, glOK, usdPerImage, estimateCtx) if !ok { matchName := ratio_setting.FormatMatchingModelName(modelName) if matchName == "" { matchName = modelName } return types.PriceData{}, false, fmt.Errorf( "图片模型 %s 未设置按张价格,请配置文生图/图生图分辨率价格或兜底每张价格;Image model %s per-image price not set", matchName, matchName, ) } info.PriceData = priceData return priceData, true, nil } // resolveImagePerImageUnitUSD 分别解析渠道规则价与全局规则价(不合并规则表)。 func resolveImagePerImageUnitUSD(channelID int, names []string, estimateCtx imageEstimateContext) (channelUSD, globalUSD float64, chOK, glOK bool) { channelRules, chHasRules := resolveChannelOnlyImagePricingRules(channelID, names) globalRules, glHasRules := resolveGlobalOnlyImagePricingRules(names) chFallback, chHasFallback := resolveChannelImageFlatUSD(channelID, names) glFallback, glHasFallback := resolveGlobalImageFlatUSD(names) channelUSD, chOK = matchFlatPerImageUSDRules(estimateCtx, channelRules, chHasRules, chFallback, chHasFallback) globalUSD, glOK = matchFlatPerImageUSDRules(estimateCtx, globalRules, glHasRules, glFallback, glHasFallback) return channelUSD, globalUSD, chOK, glOK } func buildImagePerImagePriceData( c *gin.Context, info *relaycommon.RelayInfo, channelID int, channelUSD, globalUSD float64, chOK, glOK bool, fallbackUSD float64, estimateCtx imageEstimateContext, ) (types.PriceData, bool) { if info == nil { return types.PriceData{}, false } usdPerImage := channelUSD okPrice := chOK if !okPrice || usdPerImage <= 0 { usdPerImage = globalUSD okPrice = glOK } if !okPrice || usdPerImage <= 0 { return types.PriceData{}, false } count := estimateCtx.Count if count <= 0 { count = 1 } groupRatioInfo := HandleGroupRatio(c, info) freeModel := false if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { if groupRatioInfo.GroupRatio == 0 { freeModel = true } } chDiscImg := model.ResolveChannelPriceDiscountPercent(channelID) markupDiscImg := effectiveMarkupDiscountPercent(c, info, channelID, info.OriginModelName) channelRuleUSD := channelUSD if !chOK || channelRuleUSD <= 0 { channelRuleUSD = usdPerImage } globalRuleUSD := globalUSD if !glOK || globalRuleUSD <= 0 { globalRuleUSD = 0 } effUsdPerImage := model.EffectiveRuleUnitPrice(channelRuleUSD, globalRuleUSD, chDiscImg, markupDiscImg) rawQuota := effUsdPerImage * float64(count) * common.QuotaPerUnit * groupRatioInfo.GroupRatio chDiscCopyImg := chDiscImg quota := int(math.Round(rawQuota)) if !freeModel && quota <= 0 && rawQuota > 0 && groupRatioInfo.GroupRatio > 0 { quota = 1 } if freeModel { quota = 0 rawQuota = 0 } priceData := types.PriceData{ FreeModel: freeModel, ModelPrice: channelRuleUSD, GroupRatioInfo: groupRatioInfo, UsePrice: true, Quota: quota, QuotaToPreConsume: quota, ChannelPriceDiscount: &chDiscCopyImg, CostDiscountPercent: chDiscImg, MarkupDiscountPercent: markupDiscImg, GlobalModelPrice: globalRuleUSD, } priceData.AddOtherRatio("n", float64(count)) info.ImageBilling = &relaycommon.ImageBillingSnapshot{ UsdPerImage: effUsdPerImage, Width: estimateCtx.Width, Height: estimateCtx.Height, Count: count, Mode: string(estimateCtx.Mode), } if common.DebugEnabled { logger.LogDebug(c, fmt.Sprintf( "[image][per-image] model=%s mode=%s w=%d h=%d count=%d channelUSD=%.6f globalUSD=%.6f effUSD=%.6f quota=%d", info.OriginModelName, estimateCtx.Mode, estimateCtx.Width, estimateCtx.Height, count, channelRuleUSD, globalRuleUSD, effUsdPerImage, quota, )) } return priceData, true } // SyncImagePerImagePriceData 按渠道/全局规则价刷新 PriceData(供 finalize 与结算对齐)。 func SyncImagePerImagePriceData(c *gin.Context, info *relaycommon.RelayInfo, estimateCtx imageEstimateContext) bool { if info == nil || !info.PriceData.UsePrice { return false } channelID := 0 if info.ChannelMeta != nil { channelID = info.ChannelId } names := imageModelNameCandidatesFromInfo(info) if len(names) == 0 { names = imageModelNameCandidates(info.OriginModelName) } channelUSD, globalUSD, chOK, glOK := resolveImagePerImageUnitUSD(channelID, names, estimateCtx) usdPerImage := channelUSD okPrice := chOK if !okPrice || usdPerImage <= 0 { usdPerImage = globalUSD okPrice = glOK } if !okPrice || usdPerImage <= 0 { return false } pd, ok := buildImagePerImagePriceData(c, info, channelID, channelUSD, globalUSD, chOK, glOK, usdPerImage, estimateCtx) if !ok { return false } info.PriceData = pd return true } func estimateImageRequestContext(c *gin.Context, info *relaycommon.RelayInfo) imageEstimateContext { ctx := imageEstimateContext{ Mode: imageBillingModeTextToImage, Width: 0, Height: 0, Count: 1, } if info != nil && info.RelayMode == relayconstant.RelayModeImagesEdits { ctx.Mode = imageBillingModeImageToImage } if info != nil { if req, ok := info.Request.(*dto.ImageRequest); ok && req != nil { if w, h, ok := parseResolutionFlexible(req.Size); ok { ctx.Width = w ctx.Height = h } if req.N != nil && *req.N > 0 { ctx.Count = int(*req.N) } if ctx.Mode == imageBillingModeTextToImage && hasImageInputInRequest(req) { ctx.Mode = imageBillingModeImageToImage } } } _ = c return ctx } func hasImageInputInRequest(req *dto.ImageRequest) bool { if req == nil { return false } raw := strings.TrimSpace(string(req.Image)) return raw != "" && raw != "null" } func matchFlatPerImageUSDRules( ctx imageEstimateContext, rules ratio_setting.ImagePricingRules, hasRules bool, fallbackUSD float64, hasFallback bool, ) (float64, bool) { if hasRules { threshold := rules.SimilarityThreshold if threshold <= 0 { threshold = 0.35 } var rows []ratio_setting.ImageResolutionPerImageRule if ctx.Mode == imageBillingModeImageToImage { rows = rules.ImageToImagePerImage } else { rows = rules.TextToImagePerImage } if price, ok := matchPerImageRulesByPixels(ctx, rows, threshold, fallbackUSD, hasFallback); ok { return price, true } } if hasFallback && fallbackUSD > 0 { return fallbackUSD, true } return 0, false } // matchPerImageRulesByPixels picks the closest resolution row. When request has no // resolution or relative pixel gap exceeds threshold, uses fallbackUSD when configured. func matchPerImageRulesByPixels( ctx imageEstimateContext, rules []ratio_setting.ImageResolutionPerImageRule, threshold float64, fallbackUSD float64, hasFallback bool, ) (float64, bool) { if len(rules) == 0 { if hasFallback && fallbackUSD > 0 { return fallbackUSD, true } return 0, false } if ctx.Width <= 0 || ctx.Height <= 0 { if hasFallback && fallbackUSD > 0 { return fallbackUSD, true } return 0, false } bestIdx := -1 targetPixels := ctx.Width * ctx.Height minDiffRatio := math.MaxFloat64 for i, rule := range rules { if rule.ImagePrice <= 0 { continue } ruleW, ruleH, ok := parseResolution(rule.Resolution) if !ok { continue } rulePixels := ruleW * ruleH if rulePixels <= 0 { continue } diffRatio := math.Abs(float64(targetPixels-rulePixels)) / float64(rulePixels) if diffRatio < minDiffRatio { minDiffRatio = diffRatio bestIdx = i } } if bestIdx < 0 { if hasFallback && fallbackUSD > 0 { return fallbackUSD, true } return 0, false } if threshold <= 0 { threshold = 0.35 } if minDiffRatio > threshold { if hasFallback && fallbackUSD > 0 { return fallbackUSD, true } return 0, false } return rules[bestIdx].ImagePrice, true } // ModelPriceHelperForImageFallback is used only when per-image rules are not configured. // If rules exist in Option but were not applied, return an error instead of silent supplier ModelPrice. func ModelPriceHelperForImageFallback(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta) (types.PriceData, error) { if info == nil { return ModelPriceHelper(c, info, promptTokens, meta) } channelID := 0 if info.ChannelMeta != nil { channelID = info.ChannelId } if HasImagePerImageTablePricingForInfo(channelID, info) || HasImagePerImageTablePricing(channelID, info.OriginModelName) { matchName := info.OriginModelName return types.PriceData{}, fmt.Errorf( "图片模型 %s 已保存按张分辨率价格但未生效,请确认已保存 ImagePricingRules/ChannelImagePricingRules 并重启服务;模型名须与请求一致。Image per-image rules exist for %s but billing did not apply", matchName, matchName, ) } priceData, err := ModelPriceHelper(c, info, promptTokens, meta) if err != nil { return priceData, err } if priceData.UsePrice && priceData.ModelPrice > 0 { logger.LogInfo(c, fmt.Sprintf( "[image][fallback] model=%s channel=%d using fixed price $%.4f/request (no ImagePricingRules for this model). Set per-image rules in ratio settings.", info.OriginModelName, channelID, priceData.ModelPrice, )) } return priceData, err }