From 957890c0d47dcb4b89e6a300cb56c6d194c6096c Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Thu, 21 May 2026 23:22:22 +0800 Subject: [PATCH 1/9] feat: add per-resolution image model billing Signed-off-by: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> --- model/option.go | 2 + relay/image_handler.go | 15 ++ service/text_quota.go | 50 +++- .../operation_setting/image_model_setting.go | 246 ++++++++++++++++++ .../system-settings/billing/index.tsx | 1 + .../billing/section-registry.tsx | 1 + .../models/model-pricing-sheet.tsx | 235 ++++++++++++++--- .../models/model-ratio-form.tsx | 4 + .../models/model-ratio-visual-editor.tsx | 121 ++++++++- .../models/ratio-settings-card.tsx | 7 + .../src/features/system-settings/types.ts | 1 + .../columns/common-logs-columns.tsx | 52 +++- .../components/dialogs/details-dialog.tsx | 65 ++--- web/default/src/features/usage-logs/types.ts | 5 + web/default/src/i18n/locales/en.json | 21 +- web/default/src/i18n/locales/fr.json | 21 +- web/default/src/i18n/locales/ja.json | 21 +- web/default/src/i18n/locales/ru.json | 21 +- web/default/src/i18n/locales/vi.json | 21 +- web/default/src/i18n/locales/zh.json | 21 +- 20 files changed, 804 insertions(+), 127 deletions(-) create mode 100644 setting/operation_setting/image_model_setting.go diff --git a/model/option.go b/model/option.go index e0a3048d34f..ba7e641ee7e 100644 --- a/model/option.go +++ b/model/option.go @@ -578,6 +578,8 @@ func handleConfigUpdate(key, value string) bool { performance_setting.UpdateAndSync() } else if configName == "tool_price_setting" { operation_setting.RebuildToolPriceIndex() + } else if configName == "image_model_setting" { + operation_setting.RebuildImageModelIndex() } else if configName == "billing_setting" { InvalidatePricingCache() ratio_setting.InvalidateExposedDataCache() diff --git a/relay/image_handler.go b/relay/image_handler.go index 7b3d961bc83..c579cd4d306 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -15,6 +15,7 @@ import ( "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" @@ -150,6 +151,20 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type logContent = append(logContent, fmt.Sprintf("生成数量 %d", imageN)) } + // If the model is configured for per-size billing, inject the size tier + // and image count into the gin context so PostTextConsumeQuota can apply + // the flat per-image surcharge instead of token billing. + if operation_setting.IsImagePerSizeBilling(info.OriginModelName) { + sizeTier, ok := operation_setting.ClassifyImageSizeTier(request.Size) + if !ok { + sizeTier = operation_setting.ImageSizeTier2K // default to 2K when unknown + } + c.Set("image_per_size_billing", true) + c.Set("image_size_tier", sizeTier) + c.Set("image_per_size_count", int(imageN)) + logContent = append(logContent, fmt.Sprintf("分辨率档位 %s", sizeTier)) + } + service.PostTextConsumeQuota(c, info, usage.(*dto.Usage), logContent) return nil } diff --git a/service/text_quota.go b/service/text_quota.go index 3f344dc3e57..c493e20e590 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -54,6 +54,11 @@ type textQuotaSummary struct { FileSearchCallCount int AudioInputPrice float64 ImageGenerationCallPrice float64 + // per-size image billing (Image API path) + ImagePerSizeBilling bool + ImageSizeTier string + ImagePerSizeCount int + ImagePerSizePrice float64 // unit price per image (USD) ToolCallSurchargeQuota decimal.Decimal } @@ -135,6 +140,23 @@ func calculateTextToolCallSurcharge(ctx *gin.Context, relayInfo *relaycommon.Rel Mul(dQuotaPerUnit)) } + // Per-size billing for Image API path. + if ctx.GetBool("image_per_size_billing") { + summary.ImagePerSizeBilling = true + summary.ImageSizeTier = ctx.GetString("image_size_tier") + summary.ImagePerSizeCount = ctx.GetInt("image_per_size_count") + if summary.ImagePerSizeCount <= 0 { + summary.ImagePerSizeCount = 1 + } + summary.ImagePerSizePrice = operation_setting.GetImagePerSizePrice(summary.ModelName, summary.ImageSizeTier) + surcharge = surcharge.Add( + decimal.NewFromFloat(summary.ImagePerSizePrice). + Mul(decimal.NewFromInt(int64(summary.ImagePerSizeCount))). + Mul(dGroupRatio). + Mul(dQuotaPerUnit), + ) + } + return surcharge } @@ -229,6 +251,16 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf summary.ToolCallSurchargeQuota = calculateTextToolCallSurcharge(ctx, relayInfo, &summary) var audioInputQuota decimal.Decimal + if summary.ImagePerSizeBilling { + summary.Quota = int(summary.ToolCallSurchargeQuota.Round(0).IntPart()) + if summary.TotalTokens == 0 { + summary.Quota = 0 + } else if summary.ImagePerSizePrice > 0 && summary.Quota == 0 { + summary.Quota = 1 + } + return summary + } + if !relayInfo.PriceData.UsePrice { baseTokens := dPromptTokens @@ -333,7 +365,7 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us var tieredResult *billingexpr.TieredResult tieredBillingApplied := false - if originUsage != nil { + if originUsage != nil && !summary.ImagePerSizeBilling { var tieredUsedVars map[string]bool if snap := relayInfo.TieredBillingSnapshot; snap != nil { tieredUsedVars = billingexpr.UsedVars(snap.ExprString) @@ -361,6 +393,16 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us if summary.ImageGenerationCallPrice > 0 { extraContent = append(extraContent, fmt.Sprintf("Image Generation Call 花费 %s", decimal.NewFromFloat(summary.ImageGenerationCallPrice).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String())) } + if summary.ImagePerSizeBilling && summary.ImagePerSizePrice > 0 { + extraContent = append(extraContent, fmt.Sprintf("图片生成 %s × %d 张,花费 %s", + summary.ImageSizeTier, + summary.ImagePerSizeCount, + decimal.NewFromFloat(summary.ImagePerSizePrice). + Mul(decimal.NewFromInt(int64(summary.ImagePerSizeCount))). + Mul(decimal.NewFromFloat(summary.GroupRatio)). + Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String(), + )) + } if summary.TotalTokens == 0 { extraContent = append(extraContent, "上游没有返回计费信息,无法扣费(可能是上游超时)") @@ -429,6 +471,12 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us other["image_generation_call"] = true other["image_generation_call_price"] = summary.ImageGenerationCallPrice } + if summary.ImagePerSizeBilling && summary.ImagePerSizePrice > 0 { + other["image_per_size_billing"] = true + other["image_size_tier"] = summary.ImageSizeTier + other["image_per_size_count"] = summary.ImagePerSizeCount + other["image_per_size_price"] = summary.ImagePerSizePrice + } if summary.CacheCreationTokens > 0 { other["cache_creation_tokens"] = summary.CacheCreationTokens other["cache_creation_ratio"] = summary.CacheCreationRatio diff --git a/setting/operation_setting/image_model_setting.go b/setting/operation_setting/image_model_setting.go new file mode 100644 index 00000000000..b72004eeae5 --- /dev/null +++ b/setting/operation_setting/image_model_setting.go @@ -0,0 +1,246 @@ +package operation_setting + +import ( + "strings" + "sync/atomic" + + "github.com/QuantumNous/new-api/setting/config" +) + +// --------------------------------------------------------------------------- +// Image model billing settings +// +// Admins can register any model name and choose between two billing modes: +// - "token" : standard token-ratio billing (default for all models) +// - "per_size" : flat per-image price that varies by output resolution tier +// (1K ≤ 1024px long-edge, 2K ≤ 2048px, 4K > 2048px) +// +// DB key: image_model_setting +// --------------------------------------------------------------------------- + +const ( + ImageBillingModeToken = "token" + ImageBillingModePerSize = "per_size" + + ImageSizeTier1K = "1K" + ImageSizeTier2K = "2K" + ImageSizeTier4K = "4K" +) + +// ImageModelConfig holds the billing configuration for a single image model. +type ImageModelConfig struct { + // BillingMode is either "token" (default) or "per_size". + BillingMode string `json:"billing_mode"` + // Price1K/2K/4K are the per-image prices (USD) for each resolution tier. + // nil means "use the built-in hardcoded default for this model". + Price1K *float64 `json:"price_1k,omitempty"` + Price2K *float64 `json:"price_2k,omitempty"` + Price4K *float64 `json:"price_4k,omitempty"` +} + +// ImageModelSetting is the top-level config struct registered with GlobalConfig. +type ImageModelSetting struct { + // Models maps model name → billing config. + Models map[string]ImageModelConfig `json:"models"` +} + +// defaultImageModels lists the well-known image models that are pre-populated +// in the admin UI. All default to token billing so existing behaviour is +// unchanged until an admin explicitly switches a model to per_size. +var defaultImageModels = map[string]ImageModelConfig{ + "gpt-image-1": {BillingMode: ImageBillingModeToken}, + "gpt-image-1-mini": {BillingMode: ImageBillingModeToken}, + "gpt-image-1.5": {BillingMode: ImageBillingModeToken}, + "chatgpt-image-latest": {BillingMode: ImageBillingModeToken}, + "dall-e-2": {BillingMode: ImageBillingModeToken}, + "dall-e-3": {BillingMode: ImageBillingModeToken}, +} + +var imageModelSetting = ImageModelSetting{ + Models: func() map[string]ImageModelConfig { + m := make(map[string]ImageModelConfig, len(defaultImageModels)) + for k, v := range defaultImageModels { + m[k] = v + } + return m + }(), +} + +// currentImageModelSetting is an atomic snapshot used on the billing hot path. +var currentImageModelSetting atomic.Pointer[ImageModelSetting] + +func init() { + config.GlobalConfig.Register("image_model_setting", &imageModelSetting) + rebuildImageModelIndex() +} + +func rebuildImageModelIndex() { + // Merge defaults with admin overrides: admin config wins. + merged := make(map[string]ImageModelConfig, len(defaultImageModels)+len(imageModelSetting.Models)) + for k, v := range defaultImageModels { + merged[k] = v + } + for k, v := range imageModelSetting.Models { + merged[k] = v + } + snap := &ImageModelSetting{Models: merged} + currentImageModelSetting.Store(snap) +} + +// RebuildImageModelIndex must be called after imageModelSetting is updated +// (e.g. from config.GlobalConfig.OnUpdate). +func RebuildImageModelIndex() { + rebuildImageModelIndex() +} + +// GetImageModelConfig returns the billing config for a model. +// Returns (config, true) if the model is registered; (zero, false) otherwise. +func GetImageModelConfig(modelName string) (ImageModelConfig, bool) { + snap := currentImageModelSetting.Load() + if snap == nil { + return ImageModelConfig{}, false + } + cfg, ok := snap.Models[modelName] + return cfg, ok +} + +// IsImagePerSizeBilling returns true when the model is configured for +// per-resolution billing. +func IsImagePerSizeBilling(modelName string) bool { + cfg, ok := GetImageModelConfig(modelName) + if !ok { + return false + } + return cfg.BillingMode == ImageBillingModePerSize +} + +// GetImagePerSizePrice returns the per-image price (USD) for the given model +// and resolution tier. Falls back to built-in defaults when the admin has not +// set a custom price. +func GetImagePerSizePrice(modelName, sizeTier string) float64 { + cfg, ok := GetImageModelConfig(modelName) + if !ok || cfg.BillingMode != ImageBillingModePerSize { + return 0 + } + + switch strings.ToUpper(sizeTier) { + case ImageSizeTier1K: + if cfg.Price1K != nil { + return *cfg.Price1K + } + return defaultImagePerSizePrice(modelName, ImageSizeTier1K) + case ImageSizeTier2K: + if cfg.Price2K != nil { + return *cfg.Price2K + } + return defaultImagePerSizePrice(modelName, ImageSizeTier2K) + case ImageSizeTier4K: + if cfg.Price4K != nil { + return *cfg.Price4K + } + return defaultImagePerSizePrice(modelName, ImageSizeTier4K) + } + return 0 +} + +// defaultImagePerSizePrice returns the hardcoded fallback price for a model +// and tier. These are the values shown in the UI when no custom price is set. +func defaultImagePerSizePrice(modelName, sizeTier string) float64 { + // gpt-image-1 / gpt-image-1.5 / gpt-image-1-mini: map quality tiers to + // size tiers (low→1K, medium→2K, high→4K) using the existing constants. + if strings.HasPrefix(modelName, "gpt-image-1") || modelName == "chatgpt-image-latest" { + switch sizeTier { + case ImageSizeTier1K: + return GPTImage1Low1024x1024 // $0.011 + case ImageSizeTier2K: + return GPTImage1Medium1024x1024 // $0.042 + case ImageSizeTier4K: + return GPTImage1High1024x1024 // $0.167 + } + } + // dall-e-3: map to existing price constants + if modelName == "dall-e-3" { + switch sizeTier { + case ImageSizeTier1K: + return 0.04 + case ImageSizeTier2K: + return 0.08 + case ImageSizeTier4K: + return 0.12 + } + } + // Generic fallback + switch sizeTier { + case ImageSizeTier1K: + return 0.04 + case ImageSizeTier2K: + return 0.08 + case ImageSizeTier4K: + return 0.12 + } + return 0 +} + +// ClassifyImageSizeTier maps a pixel-dimension string (e.g. "1024x1024", +// "2048x2048") or a tier label ("1K", "2K", "4K") to a canonical tier. +// Returns ("", false) when the input cannot be classified. +func ClassifyImageSizeTier(size string) (string, bool) { + size = strings.TrimSpace(size) + upper := strings.ToUpper(size) + switch upper { + case "", "AUTO": + return "", false + case ImageSizeTier1K: + return ImageSizeTier1K, true + case ImageSizeTier2K: + return ImageSizeTier2K, true + case ImageSizeTier4K: + return ImageSizeTier4K, true + } + + // Parse "WxH" or "W×H" + size = strings.ReplaceAll(size, "×", "x") + parts := strings.SplitN(strings.ToLower(size), "x", 2) + if len(parts) != 2 { + return "", false + } + w := parsePositiveInt(parts[0]) + h := parsePositiveInt(parts[1]) + if w <= 0 || h <= 0 { + return "", false + } + maxEdge := w + if h > maxEdge { + maxEdge = h + } + switch { + case maxEdge <= 1024: + return ImageSizeTier1K, true + case maxEdge <= 2048: + return ImageSizeTier2K, true + default: + return ImageSizeTier4K, true + } +} + +func parsePositiveInt(s string) int { + s = strings.TrimSpace(s) + n := 0 + for _, c := range s { + if c < '0' || c > '9' { + return -1 + } + n = n*10 + int(c-'0') + } + return n +} + +// GetDefaultImageModels returns a copy of the built-in default model list, +// used to pre-populate the admin UI. +func GetDefaultImageModels() map[string]ImageModelConfig { + m := make(map[string]ImageModelConfig, len(defaultImageModels)) + for k, v := range defaultImageModels { + m[k] = v + } + return m +} diff --git a/web/default/src/features/system-settings/billing/index.tsx b/web/default/src/features/system-settings/billing/index.tsx index 3b006f772e5..8beaca27b60 100644 --- a/web/default/src/features/system-settings/billing/index.tsx +++ b/web/default/src/features/system-settings/billing/index.tsx @@ -50,6 +50,7 @@ const defaultBillingSettings: BillingSettings = { 'billing_setting.billing_mode': '{}', 'billing_setting.billing_expr': '{}', 'tool_price_setting.prices': '{}', + 'image_model_setting.models': '{}', TopupGroupRatio: '', GroupRatio: '', UserUsableGroups: '', diff --git a/web/default/src/features/system-settings/billing/section-registry.tsx b/web/default/src/features/system-settings/billing/section-registry.tsx index ee829e23cf0..1e1bda2c975 100644 --- a/web/default/src/features/system-settings/billing/section-registry.tsx +++ b/web/default/src/features/system-settings/billing/section-registry.tsx @@ -37,6 +37,7 @@ const getModelDefaults = (settings: BillingSettings) => ({ ExposeRatioEnabled: settings.ExposeRatioEnabled, BillingMode: settings['billing_setting.billing_mode'], BillingExpr: settings['billing_setting.billing_expr'], + ImageModelSetting: settings['image_model_setting.models'] ?? '', }) const getGroupDefaults = (settings: BillingSettings) => ({ diff --git a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx index 18a8739c74e..b25ba832235 100644 --- a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx +++ b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx @@ -107,6 +107,13 @@ export type ModelRatioData = { billingMode?: PricingMode billingExpr?: string requestRuleExpr?: string + // Per-resolution image pricing (only used when pricingMode === 'per-request' + // and the model is an image model). Stored in image_model_setting separately. + price1k?: string + price2k?: string + price4k?: string + // Signals that per-request billing uses per-resolution sub-mode + perRequestSubMode?: 'fixed' | 'per-resolution' } type ModelPricingSheetProps = { @@ -290,7 +297,11 @@ function buildPreviewRows( promptPrice: string, lanePrices: Record, laneEnabled: Record, - t: (key: string) => string + t: (key: string) => string, + perRequestSubMode?: 'fixed' | 'per-resolution', + price1k?: string, + price2k?: string, + price4k?: string ): PreviewRow[] { if (mode === 'tiered_expr') { const effectiveExpr = combineBillingExpr(billingExpr, requestRuleExpr) @@ -306,6 +317,26 @@ function buildPreviewRows( } if (mode === 'per-request') { + if (perRequestSubMode === 'per-resolution') { + return [ + { key: 'submode', label: 'Sub-mode', value: 'per-resolution' }, + { + key: 'p1k', + label: '1K price', + value: price1k ? `$${price1k}` : t('Default'), + }, + { + key: 'p2k', + label: '2K price', + value: price2k ? `$${price2k}` : t('Default'), + }, + { + key: 'p4k', + label: '4K price', + value: price4k ? `$${price4k}` : t('Default'), + }, + ] + } return [ { key: 'price', @@ -314,7 +345,6 @@ function buildPreviewRows( }, ] } - return [ { key: 'inputPrice', @@ -425,6 +455,13 @@ export function ModelPricingEditorPanel({ const [billingExpr, setBillingExpr] = useState('') const [requestRuleExpr, setRequestRuleExpr] = useState('') const [previewOpen, setPreviewOpen] = useState(true) + // Per-resolution image pricing (shown when pricingMode === 'per-request' and subMode === 'per-resolution') + const [perRequestSubMode, setPerRequestSubMode] = useState< + 'fixed' | 'per-resolution' + >('fixed') + const [price1k, setPrice1k] = useState('') + const [price2k, setPrice2k] = useState('') + const [price4k, setPrice4k] = useState('') const isEditMode = !!editData const form = useForm({ @@ -466,6 +503,17 @@ export function ModelPricingEditorPanel({ ) setBillingExpr(editData.billingExpr || '') setRequestRuleExpr(editData.requestRuleExpr || '') + setPrice1k(editData.price1k || '') + setPrice2k(editData.price2k || '') + setPrice4k(editData.price4k || '') + setPerRequestSubMode( + editData.perRequestSubMode === 'per-resolution' || + editData.price1k || + editData.price2k || + editData.price4k + ? 'per-resolution' + : 'fixed' + ) } else { form.reset({ name: '', @@ -620,7 +668,11 @@ export function ModelPricingEditorPanel({ promptPrice, lanePrices, laneEnabled, - t + t, + perRequestSubMode, + price1k, + price2k, + price4k ), [ billingExpr, @@ -631,6 +683,10 @@ export function ModelPricingEditorPanel({ requestRuleExpr, t, watchedValues, + perRequestSubMode, + price1k, + price2k, + price4k, ] ) @@ -707,7 +763,10 @@ export function ModelPricingEditorPanel({ const data: ModelRatioData = { name: values.name.trim(), billingMode: pricingMode, - price: values.price || '', + price: + pricingMode === 'per-request' && perRequestSubMode === 'fixed' + ? values.price || '' + : '', ratio: values.ratio || '', cacheRatio: values.cacheRatio || '', createCacheRatio: values.createCacheRatio || '', @@ -715,6 +774,20 @@ export function ModelPricingEditorPanel({ imageRatio: values.imageRatio || '', audioRatio: values.audioRatio || '', audioCompletionRatio: values.audioCompletionRatio || '', + price1k: + pricingMode === 'per-request' && perRequestSubMode === 'per-resolution' + ? price1k || '' + : '', + price2k: + pricingMode === 'per-request' && perRequestSubMode === 'per-resolution' + ? price2k || '' + : '', + price4k: + pricingMode === 'per-request' && perRequestSubMode === 'per-resolution' + ? price4k || '' + : '', + perRequestSubMode: + pricingMode === 'per-request' ? perRequestSubMode : undefined, } if (pricingMode === 'tiered_expr') { @@ -851,40 +924,126 @@ export function ModelPricingEditorPanel({ value='per-request' className='flex flex-col gap-5' > - ( - - {t('Fixed price')} - - - $ - { - const value = event.target.value - if (numericDraftRegex.test(value)) { - field.onChange(value) - } - }} - /> - - {t('per request')} - - - - - {t( - 'Cost in USD per request, regardless of tokens used.' - )} - - - - )} - /> + {/* Sub-mode selector: fixed price vs per-resolution */} +
+ + +
+ + {perRequestSubMode === 'fixed' && ( + ( + + {t('Fixed price')} + + + $ + { + const value = event.target.value + if (numericDraftRegex.test(value)) { + field.onChange(value) + } + }} + /> + + {t('per request')} + + + + + {t( + 'Cost in USD per request, regardless of tokens used.' + )} + + + + )} + /> + )} + + {perRequestSubMode === 'per-resolution' && ( + + + {t( + 'Charge a flat price per image based on output resolution tier. 1K = long edge ≤ 1024px, 2K = ≤ 2048px, 4K = > 2048px. Leave empty to use the built-in default price.' + )} + +
+ {( + [ + { + label: '1K (≤ 1024px)', + value: price1k, + setter: setPrice1k, + placeholder: '0.011', + }, + { + label: '2K (≤ 2048px)', + value: price2k, + setter: setPrice2k, + placeholder: '0.042', + }, + { + label: '4K (> 2048px)', + value: price4k, + setter: setPrice4k, + placeholder: '0.167', + }, + ] as const + ).map(({ label, value, setter, placeholder }) => ( + + {label} + + + $ + { + if ( + numericDraftRegex.test(e.target.value) + ) { + setter(e.target.value) + } + }} + /> + + {t('/ img')} + + + + + ))} +
+
+ )} { const fieldMap: Record = { 'billing_setting.billing_mode': 'BillingMode', 'billing_setting.billing_expr': 'BillingExpr', + image_model_setting: 'ImageModelSetting', + 'image_model_setting.models': 'ImageModelSetting', } const formField = fieldMap[field] || (field as keyof ModelFormValues) diff --git a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx index 19d11af9d6e..297859eb877 100644 --- a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx @@ -78,6 +78,7 @@ type ModelRatioVisualEditorProps = { audioCompletionRatio: string billingMode: string billingExpr: string + imageModelSetting: string onChange: (field: string, value: string) => void } @@ -95,6 +96,11 @@ type ModelRow = { billingExpr?: string requestRuleExpr?: string hasConflict: boolean + // Per-resolution pricing + price1k?: string + price2k?: string + price4k?: string + perRequestSubMode?: 'fixed' | 'per-resolution' } const STORAGE_KEY = 'model-ratio-column-visibility' @@ -122,14 +128,23 @@ const filterBySelectedValues = ( return filterValue.includes(String(rowValue)) } -const getModeLabel = (mode?: string) => { - if (mode === 'per-request') return 'Per-request' +const getModeLabel = (mode?: string, perRequestSubMode?: string) => { + if (mode === 'per-request') { + if (perRequestSubMode === 'per-resolution') return 'Per-resolution' + return 'Per-request' + } if (mode === 'tiered_expr') return 'Expression' return 'Per-token' } -const getModeVariant = (mode?: string): 'warning' | 'info' | 'success' => { - if (mode === 'per-request') return 'warning' +const getModeVariant = ( + mode?: string, + perRequestSubMode?: string +): 'warning' | 'info' | 'success' | 'orange' => { + if (mode === 'per-request') { + if (perRequestSubMode === 'per-resolution') return 'orange' + return 'warning' + } if (mode === 'tiered_expr') return 'info' return 'success' } @@ -147,7 +162,19 @@ const getPriceSummary = (row: ModelRow, t: (key: string) => string) => { return getExpressionSummary(row, t) } if (row.billingMode === 'per-request') { - return row.price ? `$${row.price} / ${t('request')}` : t('Unset price') + if (row.perRequestSubMode === 'per-resolution') { + const parts = [ + row.price1k ? `1K $${row.price1k}` : null, + row.price2k ? `2K $${row.price2k}` : null, + row.price4k ? `4K $${row.price4k}` : null, + ].filter(Boolean) + return parts.length > 0 + ? parts.join(' · ') + : t('Per-resolution (default prices)') + } + return row.price && row.price !== '0' + ? `$${row.price} / ${t('request')}` + : t('Unset price') } const inputPrice = ratioToPrice(row.ratio) @@ -174,6 +201,9 @@ const getPriceDetail = (row: ModelRow, t: (key: string) => string) => { : t('Expression based') } if (row.billingMode === 'per-request') { + if (row.perRequestSubMode === 'per-resolution') { + return t('Billed per image by resolution tier') + } return t('Fixed request price') } @@ -204,6 +234,7 @@ export const ModelRatioVisualEditor = memo( audioCompletionRatio, billingMode, billingExpr, + imageModelSetting, onChange, }: ModelRatioVisualEditorProps) { const { t } = useTranslation() @@ -329,6 +360,20 @@ export const ModelRatioVisualEditor = memo( const audio = audioMap[name]?.toString() || '' const audioCompletion = audioCompletionMap[name]?.toString() || '' + // Read per-resolution config from image_model_setting.models + // The prop value is the raw image model settings map JSON. + type ImgCfg = { + billing_mode?: string + price_1k?: number + price_2k?: number + price_4k?: number + } + const imgModelsMap = safeJsonParse>( + imageModelSetting, + { fallback: {}, silent: true } + ) + const imgCfg: ImgCfg | undefined = imgModelsMap[name] + const isPerResolution = imgCfg?.billing_mode === 'per_size' const modeForModel = billingModeMap[name] if (modeForModel === 'tiered_expr') { // Tiered_expr models may also retain ratio/price values as fallback @@ -365,6 +410,10 @@ export const ModelRatioVisualEditor = memo( audioRatio: audio, audioCompletionRatio: audioCompletion, billingMode: price !== '' ? 'per-request' : 'per-token', + perRequestSubMode: isPerResolution ? 'per-resolution' : 'fixed', + price1k: imgCfg?.price_1k != null ? String(imgCfg.price_1k) : '', + price2k: imgCfg?.price_2k != null ? String(imgCfg.price_2k) : '', + price4k: imgCfg?.price_4k != null ? String(imgCfg.price_4k) : '', hasConflict: price !== '' && (ratio !== '' || @@ -389,6 +438,7 @@ export const ModelRatioVisualEditor = memo( audioCompletionRatio, billingMode, billingExpr, + imageModelSetting, ]) const modeCounts = useMemo( @@ -432,6 +482,10 @@ export const ModelRatioVisualEditor = memo( : 'per-token', billingExpr: model.billingExpr, requestRuleExpr: model.requestRuleExpr, + price1k: model.price1k, + price2k: model.price2k, + price4k: model.price4k, + perRequestSubMode: model.perRequestSubMode, }) setEditorOpen(true) if (isMobile) setSheetOpen(true) @@ -616,8 +670,16 @@ export const ModelRatioVisualEditor = memo( ), cell: ({ row }) => ( ), @@ -788,6 +850,14 @@ export const ModelRatioVisualEditor = memo( setIfPresent(imageMap, name, data.imageRatio) setIfPresent(audioMap, name, data.audioRatio) setIfPresent(audioCompletionMap, name, data.audioCompletionRatio) + } else if ( + data.billingMode === 'per-request' && + data.perRequestSubMode === 'per-resolution' + ) { + // Per-resolution billing: write a sentinel price of 0 so the UI + // shows "Per-request" mode. The actual per-image prices are stored + // in image_model_setting and read by the backend billing engine. + priceMap[name] = 0 } else if (data.price && data.price !== '') { setIfPresent(priceMap, name, data.price) } else { @@ -820,6 +890,42 @@ export const ModelRatioVisualEditor = memo( 'billing_setting.billing_expr', JSON.stringify(billingExprMap, null, 2) ) + + type ImgCfg = { + billing_mode: string + price_1k?: number + price_2k?: number + price_4k?: number + } + const imgModels: Record = safeJsonParse< + Record + >(imageModelSetting, { fallback: {}, silent: true }) + const previousImageModelSetting = JSON.stringify(imgModels) + + targetNames.forEach((name) => { + if ( + data.billingMode === 'per-request' && + data.perRequestSubMode === 'per-resolution' + ) { + const cfg: ImgCfg = { billing_mode: 'per_size' } + const p1k = parseFloat(data.price1k ?? '') + const p2k = parseFloat(data.price2k ?? '') + const p4k = parseFloat(data.price4k ?? '') + if (Number.isFinite(p1k)) cfg.price_1k = p1k + if (Number.isFinite(p2k)) cfg.price_2k = p2k + if (Number.isFinite(p4k)) cfg.price_4k = p4k + imgModels[name] = cfg + } else { + delete imgModels[name] + } + }) + + if (JSON.stringify(imgModels) !== previousImageModelSetting) { + onChange( + 'image_model_setting.models', + JSON.stringify(imgModels, null, 2) + ) + } }, [ modelPrice, @@ -832,6 +938,7 @@ export const ModelRatioVisualEditor = memo( audioCompletionRatio, billingMode, billingExpr, + imageModelSetting, onChange, ] ) diff --git a/web/default/src/features/system-settings/models/ratio-settings-card.tsx b/web/default/src/features/system-settings/models/ratio-settings-card.tsx index 27cb5af2607..6ce75ff28f0 100644 --- a/web/default/src/features/system-settings/models/ratio-settings-card.tsx +++ b/web/default/src/features/system-settings/models/ratio-settings-card.tsx @@ -130,6 +130,7 @@ const modelSchema = z.object({ }) } }), + ImageModelSetting: z.string(), }) const groupSchema = z.object({ @@ -251,6 +252,7 @@ export function RatioSettingsCard({ ExposeRatioEnabled: modelDefaults.ExposeRatioEnabled, BillingMode: normalizeJsonString(modelDefaults.BillingMode), BillingExpr: normalizeJsonString(modelDefaults.BillingExpr), + ImageModelSetting: modelDefaults.ImageModelSetting ?? '', }) const groupNormalizedDefaults = useRef({ @@ -282,6 +284,7 @@ export function RatioSettingsCard({ ), BillingMode: formatJsonForTextarea(modelDefaults.BillingMode), BillingExpr: formatJsonForTextarea(modelDefaults.BillingExpr), + ImageModelSetting: modelDefaults.ImageModelSetting ?? '', }, }) @@ -316,6 +319,7 @@ export function RatioSettingsCard({ ExposeRatioEnabled: modelDefaults.ExposeRatioEnabled, BillingMode: normalizeJsonString(modelDefaults.BillingMode), BillingExpr: normalizeJsonString(modelDefaults.BillingExpr), + ImageModelSetting: modelDefaults.ImageModelSetting ?? '', } modelForm.reset({ @@ -332,6 +336,7 @@ export function RatioSettingsCard({ ), BillingMode: formatJsonForTextarea(modelDefaults.BillingMode), BillingExpr: formatJsonForTextarea(modelDefaults.BillingExpr), + ImageModelSetting: modelDefaults.ImageModelSetting ?? '', }) }, [modelDefaults, modelForm]) @@ -375,11 +380,13 @@ export function RatioSettingsCard({ ExposeRatioEnabled: values.ExposeRatioEnabled, BillingMode: normalizeJsonString(values.BillingMode), BillingExpr: normalizeJsonString(values.BillingExpr), + ImageModelSetting: values.ImageModelSetting ?? '', } const apiKeyMap: Record = { BillingMode: 'billing_setting.billing_mode', BillingExpr: 'billing_setting.billing_expr', + ImageModelSetting: 'image_model_setting.models', } const updates = ( diff --git a/web/default/src/features/system-settings/types.ts b/web/default/src/features/system-settings/types.ts index f6addabb709..4b1ad2c3431 100644 --- a/web/default/src/features/system-settings/types.ts +++ b/web/default/src/features/system-settings/types.ts @@ -210,6 +210,7 @@ export type BillingSettings = { 'billing_setting.billing_mode': string 'billing_setting.billing_expr': string 'tool_price_setting.prices': string + 'image_model_setting.models': string TopupGroupRatio: string GroupRatio: string UserUsableGroups: string diff --git a/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx b/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx index d512f79fc25..09c2e9a0487 100644 --- a/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx +++ b/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx @@ -185,8 +185,46 @@ function buildDetailSegments( }) } } else { - const isPerCall = isPerCallBilling(other.model_price) - if (isPerCall) { + const groupRatioText = getGroupRatioText(other) + const hasPerSizePrice = + other.image_per_size_price != null && + Number.isFinite(other.image_per_size_price) + const hasPerCallImagePrice = + other.image_generation_call_price != null && + Number.isFinite(other.image_generation_call_price) + + if (other.image_per_size_billing) { + const sizeTier = other.image_size_tier || t('Default') + const imageCount = + other.image_per_size_count != null && other.image_per_size_count > 0 + ? other.image_per_size_count + : 1 + const parts = [`${t('Per-resolution')} · ${sizeTier}`, `× ${imageCount}`] + if (hasPerSizePrice) { + parts.push( + formatBillingCurrencyFromUSD(other.image_per_size_price!, priceOpts) + ) + } + segments.push({ + text: parts.join(' · '), + }) + if (groupRatioText) { + segments.push({ + text: `${t('Group Ratio')} ${groupRatioText}`, + muted: true, + }) + } + } else if (hasPerCallImagePrice) { + segments.push({ + text: `${t('Image Generation')} · ${t('Per-call')} · ${formatBillingCurrencyFromUSD(other.image_generation_call_price!, priceOpts)}`, + }) + if (groupRatioText) { + segments.push({ + text: `${t('Group Ratio')} ${groupRatioText}`, + muted: true, + }) + } + } else if (isPerCallBilling(other.model_price)) { segments.push({ text: `${t('Per-call')} · ${formatBillingCurrencyFromUSD(other.model_price!, priceOpts)}`, }) @@ -441,9 +479,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] { {sensitiveVisible ? log.username : '••••'} {sensitiveVisible && log.username.length > 12 && ( - - {log.username} - + {log.username} )} @@ -484,11 +520,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
- - } - > + }> 0 && ( - - )} + {other?.po && Array.isArray(other.po) && other.po.length > 0 && ( + + )} {/* Content */} {details && ( diff --git a/web/default/src/features/usage-logs/types.ts b/web/default/src/features/usage-logs/types.ts index 8db88c49910..611a4b2b866 100644 --- a/web/default/src/features/usage-logs/types.ts +++ b/web/default/src/features/usage-logs/types.ts @@ -158,6 +158,11 @@ export interface LogOtherData { audio_input_price?: number image_generation_call?: boolean image_generation_call_price?: number + // Per-size image billing for the Image API path. + image_per_size_billing?: boolean + image_size_tier?: string // "1K" | "2K" | "4K" + image_per_size_count?: number + image_per_size_price?: number // unit price per image (USD) is_system_prompt_overwritten?: boolean po?: string[] billing_source?: string diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index ab736093e2f..7a9c8aaf729 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -54,6 +54,7 @@ "{{value}}s": "{{value}}s", "@lobehub/icons key": "@lobehub/icons key", "@lobehub/icons key name": "@lobehub/icons key name", + "/ img": "/ img", "/status/": "/status/", "/your/endpoint": "/your/endpoint", "#1 App": "#1 App", @@ -422,6 +423,11 @@ "Audio Tokens": "Audio Tokens", "Auth configured": "Auth configured", "Auth Style": "Auth Style", + "auth.resetPasswordConfirm.backToLogin": "Return to login", + "auth.resetPasswordConfirm.confirm": "Confirm reset password", + "auth.resetPasswordConfirm.description": "Confirm the reset request to generate a new password.", + "auth.resetPasswordConfirm.retry": "Retry ({{seconds}}s)", + "auth.resetPasswordConfirm.success": "Your password has been reset successfully", "Authentication": "Authentication", "Authenticator code": "Authenticator code", "Authorization Endpoint": "Authorization Endpoint", @@ -521,6 +527,7 @@ "Best TTFT": "Best TTFT", "Billable input tokens": "Billable input tokens", "Billable output tokens": "Billable output tokens", + "Billed per image by resolution tier": "Billed per image by resolution tier", "Billing": "Billing", "Billing & Payment": "Billing & Payment", "Billing currency": "Billing currency", @@ -653,6 +660,7 @@ "Channels": "Channels", "Channels deleted successfully": "Channels deleted successfully", "Character chat, storytelling, persona": "Character chat, storytelling, persona", + "Charge a flat price per image based on output resolution tier. 1K = long edge ≤ 1024px, 2K = ≤ 2048px, 4K = > 2048px. Leave empty to use the built-in default price.": "Charge a flat price per image based on output resolution tier. 1K = long edge ≤ 1024px, 2K = ≤ 2048px, 4K = > 2048px. Leave empty to use the built-in default price.", "Chart Preferences": "Chart Preferences", "Chart Settings": "Chart Settings", "Chat": "Chat", @@ -886,8 +894,6 @@ "Confirm New Password": "Confirm New Password", "Confirm password": "Confirm password", "Confirm Payment": "Confirm Payment", - "auth.resetPasswordConfirm.confirm": "Confirm reset password", - "auth.resetPasswordConfirm.description": "Confirm the reset request to generate a new password.", "Confirm Selection": "Confirm Selection", "Confirm settings and finish setup": "Confirm settings and finish setup", "confirm that I bear legal responsibility arising from deployment": "confirm that I bear legal responsibility arising from deployment", @@ -2002,6 +2008,7 @@ "If this keeps happening, please report it on GitHub Issues.": "If this keeps happening, please report it on GitHub Issues.", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.", "Ignored upstream models": "Ignored upstream models", + "image": "image", "Image": "Image", "Image Generation": "Image Generation", "Image In": "Image In", @@ -2013,6 +2020,7 @@ "Image ratio": "Image ratio", "Image to Video": "Image to Video", "Image Tokens": "Image Tokens", + "image(s)": "image(s)", "Import to CC Switch": "Import to CC Switch", "In Progress": "In Progress", "In:": "In:", @@ -2362,8 +2370,8 @@ "Model not found": "Model not found", "Model performance metrics": "Model performance metrics", "Model Price": "Model Price", - "Model Price Not Configured": "Model Price Not Configured", "Model price is not configured. Please complete model pricing in settings.": "Model price is not configured. Please complete model pricing in settings.", + "Model Price Not Configured": "Model Price Not Configured", "Model prices": "Model prices", "Model prices reset successfully": "Model prices reset successfully", "Model Pricing": "Model Pricing", @@ -2888,6 +2896,8 @@ "Per-group performance": "Per-group performance", "Per-request": "Per-request", "Per-request (fixed price)": "Per-request (fixed price)", + "Per-resolution": "Per-resolution", + "Per-resolution (default prices)": "Per-resolution (default prices)", "Per-token": "Per-token", "Per-token (ratio based)": "Per-token (ratio based)", "Per-token logit bias map": "Per-token logit bias map", @@ -3352,7 +3362,6 @@ "Retain last N files": "Retain last N files", "Retention days": "Retention days", "Retry": "Retry", - "auth.resetPasswordConfirm.retry": "Retry ({{seconds}}s)", "Retry Chain": "Retry Chain", "Retry Suggestion": "Retry Suggestion", "Retry Times": "Retry Times", @@ -3362,7 +3371,6 @@ "Return Error": "Return Error", "Return per-token log probabilities": "Return per-token log probabilities", "Return to dashboard": "Return to dashboard", - "auth.resetPasswordConfirm.backToLogin": "Return to login", "Return vector embeddings for inputs": "Return vector embeddings for inputs", "Reveal API key": "Reveal API key", "Reveal key": "Reveal key", @@ -3513,7 +3521,6 @@ "Select all (filtered)": "Select all (filtered)", "Select all models": "Select all models", "Select All Visible": "Select All Visible", - "Select model {{model}}": "Select model {{model}}", "Select an operation mode and enter the amount": "Select an operation mode and enter the amount", "Select announcement type": "Select announcement type", "Select at least one field to overwrite.": "Select at least one field to overwrite.", @@ -3540,6 +3547,7 @@ "Select layout style": "Select layout style", "Select locations": "Select locations", "Select Model": "Select Model", + "Select model {{model}}": "Select model {{model}}", "Select models (empty for allow all)": "Select models (empty for allow all)", "Select models and apply to channel models list.": "Select models and apply to channel models list.", "Select models or add custom ones": "Select models or add custom ones", @@ -4475,7 +4483,6 @@ "Your GitHub OAuth Client ID": "Your GitHub OAuth Client ID", "Your GitHub OAuth Client Secret": "Your GitHub OAuth Client Secret", "Your new backup codes are ready": "Your new backup codes are ready", - "auth.resetPasswordConfirm.success": "Your password has been reset successfully", "Your Referral Link": "Your Referral Link", "Your setup guide is collapsed so usage stays in focus.": "Your setup guide is collapsed so usage stays in focus.", "Your system access token for API authentication. Keep it secure and don't share it with others.": "Your system access token for API authentication. Keep it secure and don't share it with others.", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 7f1aae94734..03e5a4006f4 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -54,6 +54,7 @@ "{{value}}s": "{{value}} s", "@lobehub/icons key": "@lobehub/icons clé", "@lobehub/icons key name": "@lobehub/icons nom de la clé", + "/ img": "/ image", "/status/": "/status/", "/your/endpoint": "/votre/endpoint", "#1 App": "#1 App", @@ -422,6 +423,11 @@ "Audio Tokens": "Jetons audio", "Auth configured": "Authentification configurée", "Auth Style": "Style d'authentification", + "auth.resetPasswordConfirm.backToLogin": "Retour à la connexion", + "auth.resetPasswordConfirm.confirm": "Confirmer la réinitialisation du mot de passe", + "auth.resetPasswordConfirm.description": "Confirmez la demande de réinitialisation pour générer un nouveau mot de passe.", + "auth.resetPasswordConfirm.retry": "Réessayer ({{seconds}}s)", + "auth.resetPasswordConfirm.success": "Votre mot de passe a été réinitialisé avec succès", "Authentication": "Authentification", "Authenticator code": "Code d'authentification", "Authorization Endpoint": "Point de terminaison d'autorisation", @@ -521,6 +527,7 @@ "Best TTFT": "Meilleur TTFT", "Billable input tokens": "Tokens d’entrée facturables", "Billable output tokens": "Tokens de sortie facturables", + "Billed per image by resolution tier": "Facturé par image selon le niveau de résolution", "Billing": "Facturation", "Billing & Payment": "Facturation et paiement", "Billing currency": "Devise de facturation", @@ -653,6 +660,7 @@ "Channels": "Canaux", "Channels deleted successfully": "Canaux supprimés avec succès", "Character chat, storytelling, persona": "Discussion de personnages, narration, persona", + "Charge a flat price per image based on output resolution tier. 1K = long edge ≤ 1024px, 2K = ≤ 2048px, 4K = > 2048px. Leave empty to use the built-in default price.": "Facturez un prix fixe par image selon le niveau de résolution de sortie. 1K = côté long ≤ 1024 px, 2K = ≤ 2048 px, 4K = > 2048 px. Laissez vide pour utiliser le prix par défaut intégré.", "Chart Preferences": "Préférences des graphiques", "Chart Settings": "Paramètres du graphique", "Chat": "Discuter", @@ -886,8 +894,6 @@ "Confirm New Password": "Confirmer le nouveau mot de passe", "Confirm password": "Confirmer le mot de passe", "Confirm Payment": "Confirmer le paiement", - "auth.resetPasswordConfirm.confirm": "Confirmer la réinitialisation du mot de passe", - "auth.resetPasswordConfirm.description": "Confirmez la demande de réinitialisation pour générer un nouveau mot de passe.", "Confirm Selection": "Confirmer la sélection", "Confirm settings and finish setup": "Confirmez les paramètres et terminez la configuration", "confirm that I bear legal responsibility arising from deployment": "confirm that I bear legal responsibility arising from deployment", @@ -2002,6 +2008,7 @@ "If this keeps happening, please report it on GitHub Issues.": "Si cela continue, veuillez le signaler sur GitHub Issues.", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.", "Ignored upstream models": "Modèles amont ignorés", + "image": "image", "Image": "Image", "Image Generation": "Génération d'images", "Image In": "Entrée d’image", @@ -2013,6 +2020,7 @@ "Image ratio": "Ratio d'image", "Image to Video": "Image vers vidéo", "Image Tokens": "Tokens image", + "image(s)": "image(s)", "Import to CC Switch": "Importer vers CC Switch", "In Progress": "En cours", "In:": "Entrée :", @@ -2362,8 +2370,8 @@ "Model not found": "Modèle introuvable", "Model performance metrics": "Indicateurs de performance des modèles", "Model Price": "Prix du modèle", - "Model Price Not Configured": "Prix du modèle non configuré", "Model price is not configured. Please complete model pricing in settings.": "Le prix du modèle n'est pas configuré. Veuillez compléter la tarification du modèle dans les paramètres.", + "Model Price Not Configured": "Prix du modèle non configuré", "Model prices": "Prix des modèles", "Model prices reset successfully": "Prix des modèles réinitialisés avec succès", "Model Pricing": "Tarification des modèles", @@ -2888,6 +2896,8 @@ "Per-group performance": "Performance par groupe", "Per-request": "Par requête", "Per-request (fixed price)": "Par requête (prix fixe)", + "Per-resolution": "Par résolution", + "Per-resolution (default prices)": "Par résolution (prix par défaut)", "Per-token": "Par jeton", "Per-token (ratio based)": "Par jeton (basé sur un ratio)", "Per-token logit bias map": "Carte de biais des logits par jeton", @@ -3352,7 +3362,6 @@ "Retain last N files": "Conserver les N derniers fichiers", "Retention days": "Jours de rétention", "Retry": "Réessayer", - "auth.resetPasswordConfirm.retry": "Réessayer ({{seconds}}s)", "Retry Chain": "Chaîne de tentatives", "Retry Suggestion": "Suggestion de relance", "Retry Times": "Nombre de tentatives", @@ -3362,7 +3371,6 @@ "Return Error": "Retourner l'erreur", "Return per-token log probabilities": "Retourner les log-probabilités par jeton", "Return to dashboard": "Retour au tableau de bord", - "auth.resetPasswordConfirm.backToLogin": "Retour à la connexion", "Return vector embeddings for inputs": "Renvoyer des embeddings vectoriels pour les entrées", "Reveal API key": "Afficher la clé API", "Reveal key": "Révéler la clé", @@ -3513,7 +3521,6 @@ "Select all (filtered)": "Tout sélectionner (filtré)", "Select all models": "Sélectionner tous les modèles", "Select All Visible": "Sélectionner tout ce qui est visible", - "Select model {{model}}": "Sélectionner le modèle {{model}}", "Select an operation mode and enter the amount": "Sélectionnez un mode d'opération et entrez le montant", "Select announcement type": "Sélectionner le type d'annonce", "Select at least one field to overwrite.": "Sélectionnez au moins un champ à écraser.", @@ -3540,6 +3547,7 @@ "Select layout style": "Sélectionner le style de mise en page", "Select locations": "Sélectionner des emplacements", "Select Model": "Sélectionner le modèle", + "Select model {{model}}": "Sélectionner le modèle {{model}}", "Select models (empty for allow all)": "Sélectionner les modèles (vide pour autoriser tout)", "Select models and apply to channel models list.": "Sélectionnez les modèles et appliquez-les à la liste des modèles de canaux.", "Select models or add custom ones": "Sélectionner des modèles ou en ajouter des personnalisés", @@ -4475,7 +4483,6 @@ "Your GitHub OAuth Client ID": "Votre ID Client OAuth GitHub", "Your GitHub OAuth Client Secret": "Votre Secret Client OAuth GitHub", "Your new backup codes are ready": "Vos nouveaux codes de secours sont prêts", - "auth.resetPasswordConfirm.success": "Votre mot de passe a été réinitialisé avec succès", "Your Referral Link": "Votre lien de parrainage", "Your setup guide is collapsed so usage stays in focus.": "Le guide de configuration est réduit afin de garder l'utilisation au premier plan.", "Your system access token for API authentication. Keep it secure and don't share it with others.": "Votre jeton d'accès système pour l'authentification API. Gardez-le en sécurité et ne le partagez pas avec d'autres.", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index c317c61264f..1cf50cfcb06 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -54,6 +54,7 @@ "{{value}}s": "{{value}}秒", "@lobehub/icons key": "@lobehub/icons キー", "@lobehub/icons key name": "@lobehub/icons キー名", + "/ img": "/ 画像", "/status/": "/status/", "/your/endpoint": "/your/endpoint", "#1 App": "#1 アプリ", @@ -422,6 +423,11 @@ "Audio Tokens": "音声トークン", "Auth configured": "認証設定済み", "Auth Style": "認証スタイル", + "auth.resetPasswordConfirm.backToLogin": "ログインに戻る", + "auth.resetPasswordConfirm.confirm": "パスワードリセットを確認", + "auth.resetPasswordConfirm.description": "新しいパスワードを生成するには、リセット要求を確認してください。", + "auth.resetPasswordConfirm.retry": "再試行 ({{seconds}}秒)", + "auth.resetPasswordConfirm.success": "パスワードが正常にリセットされました", "Authentication": "認証", "Authenticator code": "認証コード", "Authorization Endpoint": "認可エンドポイント", @@ -521,6 +527,7 @@ "Best TTFT": "最良 TTFT", "Billable input tokens": "課金対象の入力トークン", "Billable output tokens": "課金対象の出力トークン", + "Billed per image by resolution tier": "解像度の階層ごとに画像単位で課金", "Billing": "請求", "Billing & Payment": "請求と支払い", "Billing currency": "請求通貨", @@ -653,6 +660,7 @@ "Channels": "チャネル", "Channels deleted successfully": "チャンネルが正常に削除されました", "Character chat, storytelling, persona": "キャラクター会話・ストーリーテリング・ペルソナ", + "Charge a flat price per image based on output resolution tier. 1K = long edge ≤ 1024px, 2K = ≤ 2048px, 4K = > 2048px. Leave empty to use the built-in default price.": "出力解像度の階層に基づいて画像ごとに固定価格を請求します。1K = 長辺 ≤ 1024px、2K = ≤ 2048px、4K = > 2048px。空欄の場合は組み込みのデフォルト価格を使用します。", "Chart Preferences": "チャートの環境設定", "Chart Settings": "チャート設定", "Chat": "チャット", @@ -886,8 +894,6 @@ "Confirm New Password": "新しいパスワードの確認", "Confirm password": "パスワードの確認", "Confirm Payment": "支払いの確認", - "auth.resetPasswordConfirm.confirm": "パスワードリセットを確認", - "auth.resetPasswordConfirm.description": "新しいパスワードを生成するには、リセット要求を確認してください。", "Confirm Selection": "選択の確認", "Confirm settings and finish setup": "設定を確認してセットアップを完了", "confirm that I bear legal responsibility arising from deployment": "confirm that I bear legal responsibility arising from deployment", @@ -2002,6 +2008,7 @@ "If this keeps happening, please report it on GitHub Issues.": "この問題が続く場合は、GitHub Issues で報告してください。", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.", "Ignored upstream models": "無視する上流モデル", + "image": "画像", "Image": "画像", "Image Generation": "画像生成", "Image In": "画像入力", @@ -2013,6 +2020,7 @@ "Image ratio": "画像倍率", "Image to Video": "画像から動画", "Image Tokens": "画像トークン", + "image(s)": "枚", "Import to CC Switch": "CC Switch にインポート", "In Progress": "処理中", "In:": "入力:", @@ -2362,8 +2370,8 @@ "Model not found": "モデルが見つかりません", "Model performance metrics": "モデル性能メトリクス", "Model Price": "モデル価格", - "Model Price Not Configured": "モデル価格が未設定", "Model price is not configured. Please complete model pricing in settings.": "モデル価格が未設定です。設定でモデル料金を補完してください。", + "Model Price Not Configured": "モデル価格が未設定", "Model prices": "モデル価格", "Model prices reset successfully": "モデル価格が正常にリセットされました", "Model Pricing": "モデル料金", @@ -2888,6 +2896,8 @@ "Per-group performance": "グループ別パフォーマンス", "Per-request": "リクエスト単位", "Per-request (fixed price)": "リクエストごと (固定価格)", + "Per-resolution": "解像度別", + "Per-resolution (default prices)": "解像度別(デフォルト価格)", "Per-token": "トークン単位", "Per-token (ratio based)": "トークンごと (比率ベース)", "Per-token logit bias map": "トークンごとの logit バイアス", @@ -3352,7 +3362,6 @@ "Retain last N files": "最新N個のファイルを保持", "Retention days": "保持日数", "Retry": "再試行", - "auth.resetPasswordConfirm.retry": "再試行 ({{seconds}}秒)", "Retry Chain": "リトライチェーン", "Retry Suggestion": "リトライ提案", "Retry Times": "再試行回数", @@ -3362,7 +3371,6 @@ "Return Error": "エラーを返す", "Return per-token log probabilities": "トークンごとの対数確率を返します", "Return to dashboard": "ダッシュボードに戻る", - "auth.resetPasswordConfirm.backToLogin": "ログインに戻る", "Return vector embeddings for inputs": "入力に対してベクトル埋め込みを返却", "Reveal API key": "APIキーを表示", "Reveal key": "キーを表示", @@ -3513,7 +3521,6 @@ "Select all (filtered)": "フィルタ結果をすべて選択(S)", "Select all models": "すべてのモデルを選択", "Select All Visible": "表示中のすべてを選択", - "Select model {{model}}": "モデル {{model}} を選択", "Select an operation mode and enter the amount": "操作モードを選択し、金額を入力してください", "Select announcement type": "アナウンスメントタイプを選択", "Select at least one field to overwrite.": "上書きするフィールドを少なくとも 1 つ選択してください。", @@ -3540,6 +3547,7 @@ "Select layout style": "レイアウトスタイルを選択", "Select locations": "ロケーションを選択", "Select Model": "モデルを選択", + "Select model {{model}}": "モデル {{model}} を選択", "Select models (empty for allow all)": "モデルを選択 (すべて許可する場合は空)", "Select models and apply to channel models list.": "モデルを選択し、チャンネルモデルリストに適用します。", "Select models or add custom ones": "モデルを選択するか、カスタムモデルを追加", @@ -4475,7 +4483,6 @@ "Your GitHub OAuth Client ID": "あなたのGitHub OAuthクライアントID", "Your GitHub OAuth Client Secret": "あなたのGitHub OAuthクライアントシークレット", "Your new backup codes are ready": "新しいバックアップコードの準備ができました", - "auth.resetPasswordConfirm.success": "パスワードが正常にリセットされました", "Your Referral Link": "あなたの紹介リンク", "Your setup guide is collapsed so usage stays in focus.": "利用状況に集中できるよう、セットアップガイドを折りたたみました。", "Your system access token for API authentication. Keep it secure and don't share it with others.": "API認証用のシステムアクセストークンです。安全に保管し、他者と共有しないでください。", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index baa9f2909fb..6eff7679b29 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -54,6 +54,7 @@ "{{value}}s": "{{value}} с", "@lobehub/icons key": "Ключ @lobehub/icons", "@lobehub/icons key name": "Имя ключа @lobehub/icons", + "/ img": "/ изображение", "/status/": "/status/", "/your/endpoint": "/your/endpoint", "#1 App": "#1 Приложение", @@ -422,6 +423,11 @@ "Audio Tokens": "Аудио токены", "Auth configured": "Аутентификация настроена", "Auth Style": "Стиль аутентификации", + "auth.resetPasswordConfirm.backToLogin": "Вернуться ко входу", + "auth.resetPasswordConfirm.confirm": "Подтвердить сброс пароля", + "auth.resetPasswordConfirm.description": "Подтвердите запрос на сброс, чтобы создать новый пароль.", + "auth.resetPasswordConfirm.retry": "Повторить ({{seconds}}с)", + "auth.resetPasswordConfirm.success": "Ваш пароль успешно сброшен", "Authentication": "Аутентификация", "Authenticator code": "Код аутентификатора", "Authorization Endpoint": "Конечная точка авторизации", @@ -521,6 +527,7 @@ "Best TTFT": "Лучший TTFT", "Billable input tokens": "Оплачиваемые входные токены", "Billable output tokens": "Оплачиваемые выходные токены", + "Billed per image by resolution tier": "Оплата за изображение по уровню разрешения", "Billing": "Биллинг", "Billing & Payment": "Биллинг и платежи", "Billing currency": "Валюта оплаты", @@ -653,6 +660,7 @@ "Channels": "Каналы", "Channels deleted successfully": "Каналы успешно удалены", "Character chat, storytelling, persona": "Диалог с персонажем, сторителлинг, персона", + "Charge a flat price per image based on output resolution tier. 1K = long edge ≤ 1024px, 2K = ≤ 2048px, 4K = > 2048px. Leave empty to use the built-in default price.": "Взимайте фиксированную цену за изображение в зависимости от уровня выходного разрешения. 1K = длинная сторона ≤ 1024 px, 2K = ≤ 2048 px, 4K = > 2048 px. Оставьте пустым, чтобы использовать встроенную цену по умолчанию.", "Chart Preferences": "Настройки графиков", "Chart Settings": "Настройки диаграммы", "Chat": "Чат", @@ -886,8 +894,6 @@ "Confirm New Password": "Подтвердить новый пароль", "Confirm password": "Подтвердить пароль", "Confirm Payment": "Подтвердить оплату", - "auth.resetPasswordConfirm.confirm": "Подтвердить сброс пароля", - "auth.resetPasswordConfirm.description": "Подтвердите запрос на сброс, чтобы создать новый пароль.", "Confirm Selection": "Подтвердить выбор", "Confirm settings and finish setup": "Подтвердите настройки и завершите установку", "confirm that I bear legal responsibility arising from deployment": "confirm that I bear legal responsibility arising from deployment", @@ -2002,6 +2008,7 @@ "If this keeps happening, please report it on GitHub Issues.": "Если проблема повторяется, сообщите о ней в GitHub Issues.", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.", "Ignored upstream models": "Игнорируемые upstream-модели", + "image": "изображение", "Image": "Изображение", "Image Generation": "Генерация изображений", "Image In": "Вход изображения", @@ -2013,6 +2020,7 @@ "Image ratio": "Коэффициент изображения", "Image to Video": "Изображение в видео", "Image Tokens": "Токены изображений", + "image(s)": "изобр.", "Import to CC Switch": "Импорт в CC Switch", "In Progress": "Выполняется", "In:": "Вх:", @@ -2362,8 +2370,8 @@ "Model not found": "Модель не найдена", "Model performance metrics": "Метрики производительности моделей", "Model Price": "Цена модели", - "Model Price Not Configured": "Цена модели не настроена", "Model price is not configured. Please complete model pricing in settings.": "Цена модели не настроена. Заполните тарификацию модели в настройках.", + "Model Price Not Configured": "Цена модели не настроена", "Model prices": "Цены моделей", "Model prices reset successfully": "Цены моделей успешно сброшены", "Model Pricing": "Тарификация моделей", @@ -2888,6 +2896,8 @@ "Per-group performance": "Производительность по группам", "Per-request": "За запрос", "Per-request (fixed price)": "За запрос (фиксированная цена)", + "Per-resolution": "По разрешению", + "Per-resolution (default prices)": "По разрешению (цены по умолчанию)", "Per-token": "За токен", "Per-token (ratio based)": "За токен (на основе соотношения)", "Per-token logit bias map": "Карта смещений логитов по токенам", @@ -3352,7 +3362,6 @@ "Retain last N files": "Хранить последние N файлов", "Retention days": "Дней хранения", "Retry": "Повторить попытку", - "auth.resetPasswordConfirm.retry": "Повторить ({{seconds}}с)", "Retry Chain": "Цепочка повторов", "Retry Suggestion": "Рекомендация по повтору", "Retry Times": "Количество повторных попыток", @@ -3362,7 +3371,6 @@ "Return Error": "Вернуть ошибку", "Return per-token log probabilities": "Возвращать логарифмические вероятности по токенам", "Return to dashboard": "Вернуться на панель управления", - "auth.resetPasswordConfirm.backToLogin": "Вернуться ко входу", "Return vector embeddings for inputs": "Возвращать векторные эмбеддинги для входных данных", "Reveal API key": "Показать API ключ", "Reveal key": "Показать ключ", @@ -3513,7 +3521,6 @@ "Select all (filtered)": "& Выбрать все отфильтрованные", "Select all models": "Выбрать все модели", "Select All Visible": "Выбрать все видимые", - "Select model {{model}}": "Выбрать модель {{model}}", "Select an operation mode and enter the amount": "Выберите режим операции и введите сумму", "Select announcement type": "Выбрать тип объявления", "Select at least one field to overwrite.": "Выберите хотя бы одно поле для перезаписи.", @@ -3540,6 +3547,7 @@ "Select layout style": "Выбрать стиль макета", "Select locations": "Выбрать локации", "Select Model": "Выбрать модель", + "Select model {{model}}": "Выбрать модель {{model}}", "Select models (empty for allow all)": "Выбрать модели (пусто для разрешения всех)", "Select models and apply to channel models list.": "Выберите модели и примените к списку моделей каналов.", "Select models or add custom ones": "Выбрать модели или добавить пользовательские", @@ -4475,7 +4483,6 @@ "Your GitHub OAuth Client ID": "Ваш ID клиента GitHub OAuth", "Your GitHub OAuth Client Secret": "Ваш секрет клиента GitHub OAuth", "Your new backup codes are ready": "Ваши новые резервные коды готовы", - "auth.resetPasswordConfirm.success": "Ваш пароль успешно сброшен", "Your Referral Link": "Ваша реферальная ссылка", "Your setup guide is collapsed so usage stays in focus.": "Руководство свернуто, чтобы основные показатели оставались в фокусе.", "Your system access token for API authentication. Keep it secure and don't share it with others.": "Ваш системный токен доступа для аутентификации API. Храните его в безопасности и не делитесь им с другими.", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 2bc63aae329..d6d800d66fa 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -54,6 +54,7 @@ "{{value}}s": "{{value}} s", "@lobehub/icons key": "@lobehub/icons khóa", "@lobehub/icons key name": "@lobehub/icons tên khóa", + "/ img": "/ ảnh", "/status/": "/status/", "/your/endpoint": "/điểm cuối của bạn", "#1 App": "#1 Ứng dụng", @@ -422,6 +423,11 @@ "Audio Tokens": "Token âm thanh", "Auth configured": "Đã cấu hình xác thực", "Auth Style": "Kiểu xác thực", + "auth.resetPasswordConfirm.backToLogin": "Quay lại đăng nhập", + "auth.resetPasswordConfirm.confirm": "Xác nhận đặt lại mật khẩu", + "auth.resetPasswordConfirm.description": "Xác nhận yêu cầu đặt lại để tạo mật khẩu mới.", + "auth.resetPasswordConfirm.retry": "Thử lại ({{seconds}} giây)", + "auth.resetPasswordConfirm.success": "Mật khẩu của bạn đã được đặt lại thành công", "Authentication": "Xác thực", "Authenticator code": "Mã xác thực", "Authorization Endpoint": "Điểm cuối ủy quyền", @@ -521,6 +527,7 @@ "Best TTFT": "TTFT tốt nhất", "Billable input tokens": "Token đầu vào tính phí", "Billable output tokens": "Token đầu ra tính phí", + "Billed per image by resolution tier": "Tính phí theo từng ảnh dựa trên cấp độ phân giải", "Billing": "Thanh toán", "Billing & Payment": "Thanh toán & chi phí", "Billing currency": "Loại tiền thanh toán", @@ -653,6 +660,7 @@ "Channels": "Kênh", "Channels deleted successfully": "Xóa kênh thành công", "Character chat, storytelling, persona": "Trò chuyện nhân vật, kể chuyện, nhân cách hoá", + "Charge a flat price per image based on output resolution tier. 1K = long edge ≤ 1024px, 2K = ≤ 2048px, 4K = > 2048px. Leave empty to use the built-in default price.": "Tính giá cố định cho mỗi ảnh dựa trên cấp độ phân giải đầu ra. 1K = cạnh dài ≤ 1024px, 2K = ≤ 2048px, 4K = > 2048px. Để trống để dùng giá mặc định tích hợp.", "Chart Preferences": "Tùy chọn biểu đồ", "Chart Settings": "Cài đặt Biểu đồ", "Chat": "Trò chuyện", @@ -886,8 +894,6 @@ "Confirm New Password": "Xác nhận mật khẩu mới", "Confirm password": "Xác nhận mật khẩu", "Confirm Payment": "Xác nhận Thanh toán", - "auth.resetPasswordConfirm.confirm": "Xác nhận đặt lại mật khẩu", - "auth.resetPasswordConfirm.description": "Xác nhận yêu cầu đặt lại để tạo mật khẩu mới.", "Confirm Selection": "Xác nhận lựa chọn", "Confirm settings and finish setup": "Xác nhận cài đặt và hoàn tất thiết lập", "confirm that I bear legal responsibility arising from deployment": "confirm that I bear legal responsibility arising from deployment", @@ -2002,6 +2008,7 @@ "If this keeps happening, please report it on GitHub Issues.": "Nếu sự cố tiếp tục xảy ra, vui lòng báo cáo trên GitHub Issues.", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.", "Ignored upstream models": "Mô hình upstream bị bỏ qua", + "image": "ảnh", "Image": "Hình ảnh", "Image Generation": "Tạo hình ảnh", "Image In": "Ảnh vào", @@ -2013,6 +2020,7 @@ "Image ratio": "Tỷ lệ hình ảnh", "Image to Video": "Ảnh sang video", "Image Tokens": "Token hình ảnh", + "image(s)": "ảnh", "Import to CC Switch": "Nhập vào CC Switch", "In Progress": "Đang xử lý", "In:": "Vào:", @@ -2362,8 +2370,8 @@ "Model not found": "Không tìm thấy mô hình", "Model performance metrics": "Chỉ số hiệu năng mô hình", "Model Price": "Giá mô hình", - "Model Price Not Configured": "Giá mô hình chưa được cấu hình", "Model price is not configured. Please complete model pricing in settings.": "Giá mô hình chưa được cấu hình. Vui lòng hoàn tất định giá mô hình trong cài đặt.", + "Model Price Not Configured": "Giá mô hình chưa được cấu hình", "Model prices": "Giá mô hình", "Model prices reset successfully": "Đã đặt lại giá mô hình thành công", "Model Pricing": "Định giá mô hình", @@ -2888,6 +2896,8 @@ "Per-group performance": "Hiệu năng theo nhóm", "Per-request": "Theo yêu cầu", "Per-request (fixed price)": "Theo yêu cầu (giá cố định)", + "Per-resolution": "Theo độ phân giải", + "Per-resolution (default prices)": "Theo độ phân giải (giá mặc định)", "Per-token": "Theo token", "Per-token (ratio based)": "Mỗi token (dựa trên tỷ lệ)", "Per-token logit bias map": "Bảng logit bias theo token", @@ -3352,7 +3362,6 @@ "Retain last N files": "Giữ lại N tệp gần nhất", "Retention days": "Số ngày lưu giữ", "Retry": "Thử lại", - "auth.resetPasswordConfirm.retry": "Thử lại ({{seconds}} giây)", "Retry Chain": "Chuỗi thử lại", "Retry Suggestion": "Gợi ý thử lại", "Retry Times": "Số lần thử lại", @@ -3362,7 +3371,6 @@ "Return Error": "Trả về lỗi", "Return per-token log probabilities": "Trả về log probabilities cho từng token", "Return to dashboard": "Quay lại bảng điều khiển", - "auth.resetPasswordConfirm.backToLogin": "Quay lại đăng nhập", "Return vector embeddings for inputs": "Trả về vector embedding cho đầu vào", "Reveal API key": "Hiển thị khóa API", "Reveal key": "Display key", @@ -3513,7 +3521,6 @@ "Select all (filtered)": "Chọn tất cả (đã lọc)", "Select all models": "Chọn tất cả mô hình", "Select All Visible": "Chọn tất cả hiển thị", - "Select model {{model}}": "Chọn mô hình {{model}}", "Select an operation mode and enter the amount": "Chọn chế độ thao tác và nhập số tiền", "Select announcement type": "Select notification type", "Select at least one field to overwrite.": "Chọn ít nhất một trường để ghi đè.", @@ -3540,6 +3547,7 @@ "Select layout style": "Chọn kiểu bố cục", "Select locations": "Chọn vị trí", "Select Model": "Chọn mẫu", + "Select model {{model}}": "Chọn mô hình {{model}}", "Select models (empty for allow all)": "Chọn model (để trống nếu muốn cho", "Select models and apply to channel models list.": "Chọn mô hình và áp dụng cho danh sách mô hình kênh.", "Select models or add custom ones": "Chọn các mô hình hoặc thêm các mô hình tùy chỉnh", @@ -4475,7 +4483,6 @@ "Your GitHub OAuth Client ID": "Client ID OAuth GitHub của bạn", "Your GitHub OAuth Client Secret": "Bí mật ứng dụng OAuth của GitHub của bạn", "Your new backup codes are ready": "Mã dự phòng mới của bạn đã sẵn sàng", - "auth.resetPasswordConfirm.success": "Mật khẩu của bạn đã được đặt lại thành công", "Your Referral Link": "Liên kết giới thiệu của bạn", "Your setup guide is collapsed so usage stays in focus.": "Hướng dẫn thiết lập đã thu gọn để giữ phần sử dụng ở vị trí nổi bật.", "Your system access token for API authentication. Keep it secure and don't share it with others.": "Mã truy cập hệ thống của bạn để xác thực API. Hãy giữ nó an toàn và đừng chia sẻ nó với người khác.", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index d1c5a906d47..53a4b15504b 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -54,6 +54,7 @@ "{{value}}s": "{{value}} 秒", "@lobehub/icons key": "@lobehub/icons 键", "@lobehub/icons key name": "@lobehub/icons 键名", + "/ img": "/ 张", "/status/": "/status/", "/your/endpoint": "/your/endpoint", "#1 App": "#1 应用", @@ -422,6 +423,11 @@ "Audio Tokens": "语音 Token", "Auth configured": "认证已配置", "Auth Style": "认证方式", + "auth.resetPasswordConfirm.backToLogin": "返回登录", + "auth.resetPasswordConfirm.confirm": "确认重置密码", + "auth.resetPasswordConfirm.description": "确认重置请求以生成新密码。", + "auth.resetPasswordConfirm.retry": "重试 ({{seconds}}s)", + "auth.resetPasswordConfirm.success": "您的密码已成功重置", "Authentication": "身份验证", "Authenticator code": "身份验证器代码", "Authorization Endpoint": "授权端点", @@ -521,6 +527,7 @@ "Best TTFT": "最优 TTFT", "Billable input tokens": "计费输入 token", "Billable output tokens": "计费输出 token", + "Billed per image by resolution tier": "按分辨率档位对每张图片计费", "Billing": "计费", "Billing & Payment": "计费与支付", "Billing currency": "计费货币", @@ -653,6 +660,7 @@ "Channels": "渠道", "Channels deleted successfully": "渠道删除成功", "Character chat, storytelling, persona": "角色对话、剧情创作、人设扮演", + "Charge a flat price per image based on output resolution tier. 1K = long edge ≤ 1024px, 2K = ≤ 2048px, 4K = > 2048px. Leave empty to use the built-in default price.": "按输出分辨率档位对每张图片收取固定费用。1K = 长边 ≤ 1024px,2K = ≤ 2048px,4K = > 2048px。留空则使用内置默认价格。", "Chart Preferences": "图表偏好设置", "Chart Settings": "图表设置", "Chat": "聊天", @@ -886,8 +894,6 @@ "Confirm New Password": "确认新密码", "Confirm password": "确认密码", "Confirm Payment": "确认付款", - "auth.resetPasswordConfirm.confirm": "确认重置密码", - "auth.resetPasswordConfirm.description": "确认重置请求以生成新密码。", "Confirm Selection": "确认选择", "Confirm settings and finish setup": "确认设置并完成安装", "confirm that I bear legal responsibility arising from deployment": "并确认自行承担部署", @@ -2002,6 +2008,7 @@ "If this keeps happening, please report it on GitHub Issues.": "如果问题持续出现,请到 GitHub Issues 反馈。", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "如向中华人民共和国境内公众提供生成式人工智能服务,你将依法履行备案登记、安全评估、内容安全、投诉举报、生成合成内容标识、日志留存、个人信息保护等合规义务。", "Ignored upstream models": "已忽略上游模型", + "image": "张", "Image": "图片", "Image Generation": "图片生成", "Image In": "图像输入", @@ -2013,6 +2020,7 @@ "Image ratio": "图片倍率", "Image to Video": "图生视频", "Image Tokens": "图像 Token", + "image(s)": "张", "Import to CC Switch": "填入 CC Switch", "In Progress": "进行中", "In:": "入:", @@ -2362,8 +2370,8 @@ "Model not found": "模型未找到", "Model performance metrics": "模型性能指标", "Model Price": "模型价格", - "Model Price Not Configured": "模型价格未配置", "Model price is not configured. Please complete model pricing in settings.": "模型价格未配置,请前往设置补充模型价格。", + "Model Price Not Configured": "模型价格未配置", "Model prices": "模型价格", "Model prices reset successfully": "模型价格重置成功", "Model Pricing": "模型定价", @@ -2888,6 +2896,8 @@ "Per-group performance": "各分组性能", "Per-request": "按次", "Per-request (fixed price)": "按请求计费(固定价格)", + "Per-resolution": "按分辨率", + "Per-resolution (default prices)": "按分辨率(默认价格)", "Per-token": "按 Token", "Per-token (ratio based)": "按令牌计费(基于比例)", "Per-token logit bias map": "按 token 的 logit 偏置映射", @@ -3352,7 +3362,6 @@ "Retain last N files": "保留最近 N 个文件", "Retention days": "保留天数", "Retry": "重试", - "auth.resetPasswordConfirm.retry": "重试 ({{seconds}}s)", "Retry Chain": "重试链路", "Retry Suggestion": "重试建议", "Retry Times": "重试次数", @@ -3362,7 +3371,6 @@ "Return Error": "返回错误", "Return per-token log probabilities": "返回每个 token 的对数概率", "Return to dashboard": "返回仪表盘", - "auth.resetPasswordConfirm.backToLogin": "返回登录", "Return vector embeddings for inputs": "为输入返回向量嵌入", "Reveal API key": "显示 API 密钥", "Reveal key": "显示密钥", @@ -3513,7 +3521,6 @@ "Select all (filtered)": "全选(筛选结果)", "Select all models": "选择所有模型", "Select All Visible": "全选当前", - "Select model {{model}}": "选择模型 {{model}}", "Select an operation mode and enter the amount": "选择操作模式并输入金额", "Select announcement type": "选择公告类型", "Select at least one field to overwrite.": "请选择至少一个要覆盖的字段。", @@ -3540,6 +3547,7 @@ "Select layout style": "选择布局样式", "Select locations": "选择位置", "Select Model": "选择模型", + "Select model {{model}}": "选择模型 {{model}}", "Select models (empty for allow all)": "选择模型(留空表示允许所有)", "Select models and apply to channel models list.": "选择模型并应用到渠道模型列表。", "Select models or add custom ones": "选择模型或添加自定义模型", @@ -4475,7 +4483,6 @@ "Your GitHub OAuth Client ID": "您的 GitHub OAuth 客户端 ID", "Your GitHub OAuth Client Secret": "您的 GitHub OAuth 客户端密钥", "Your new backup codes are ready": "您的新备份代码已准备就绪", - "auth.resetPasswordConfirm.success": "您的密码已成功重置", "Your Referral Link": "您的推荐链接", "Your setup guide is collapsed so usage stays in focus.": "设置引导已收起,让用量信息保持在焦点位置。", "Your system access token for API authentication. Keep it secure and don't share it with others.": "您的系统访问令牌,用于 API 认证。请妥善保管,不要与他人分享。", From 491e622bcf9f54a7417f90324a9f3ab311db5e34 Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Fri, 22 May 2026 10:38:14 +0800 Subject: [PATCH 2/9] fix: address CodeRabbit review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model-pricing-sheet: reset perRequestSubMode/price1k/price2k/price4k in else branch when switching to add-new-model mode - model-pricing-sheet: wrap Sub-mode/1K price/2K price/4K price and resolution tier labels with t() for i18n consistency - model-ratio-visual-editor: add imageModelSetting to React.memo comparator so prop updates are not silently ignored - model-ratio-visual-editor: hoist safeJsonParse(imageModelSetting) out of per-model .map() to avoid redundant JSON parsing - ratio-settings-card: validate ImageModelSetting as JSON in zod schema - ratio-settings-card: normalize ImageModelSetting in modelNormalizedDefaults and saveModelRatios to prevent false-positive dirty diffs - image_model_setting.go: add overflow guard to parsePositiveInt - zh.json: fix auth.resetPasswordConfirm.retry unit from 's' to '秒' - i18n: add Sub-mode/1K price/2K price/4K price/tier label keys to all locales Signed-off-by: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> --- .../operation_setting/image_model_setting.go | 4 ++++ .../models/model-pricing-sheet.tsx | 24 +++++++++++-------- .../models/model-ratio-visual-editor.tsx | 24 +++++++++++-------- .../models/ratio-settings-card.tsx | 17 +++++++++---- .../i18n/locales/_reports/_sync-report.json | 16 ++++++------- .../locales/_reports/ja.untranslated.json | 4 ++++ .../locales/_reports/ru.untranslated.json | 4 ++++ .../locales/_reports/zh.untranslated.json | 4 ++++ web/default/src/i18n/locales/en.json | 7 ++++++ web/default/src/i18n/locales/fr.json | 7 ++++++ web/default/src/i18n/locales/ja.json | 7 ++++++ web/default/src/i18n/locales/ru.json | 7 ++++++ web/default/src/i18n/locales/vi.json | 7 ++++++ web/default/src/i18n/locales/zh.json | 9 ++++++- 14 files changed, 108 insertions(+), 33 deletions(-) diff --git a/setting/operation_setting/image_model_setting.go b/setting/operation_setting/image_model_setting.go index b72004eeae5..8939dee5ca6 100644 --- a/setting/operation_setting/image_model_setting.go +++ b/setting/operation_setting/image_model_setting.go @@ -225,11 +225,15 @@ func ClassifyImageSizeTier(size string) (string, bool) { func parsePositiveInt(s string) int { s = strings.TrimSpace(s) + const maxDim = 65536 // image dimensions realistically never exceed this n := 0 for _, c := range s { if c < '0' || c > '9' { return -1 } + if n > (maxDim-9)/10 { + return -1 // overflow guard + } n = n*10 + int(c-'0') } return n diff --git a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx index b25ba832235..85bf72c454a 100644 --- a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx +++ b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx @@ -319,20 +319,20 @@ function buildPreviewRows( if (mode === 'per-request') { if (perRequestSubMode === 'per-resolution') { return [ - { key: 'submode', label: 'Sub-mode', value: 'per-resolution' }, + { key: 'submode', label: t('Sub-mode'), value: 'per-resolution' }, { key: 'p1k', - label: '1K price', + label: t('1K price'), value: price1k ? `$${price1k}` : t('Default'), }, { key: 'p2k', - label: '2K price', + label: t('2K price'), value: price2k ? `$${price2k}` : t('Default'), }, { key: 'p4k', - label: '4K price', + label: t('4K price'), value: price4k ? `$${price4k}` : t('Default'), }, ] @@ -529,6 +529,10 @@ export function ModelPricingEditorPanel({ setPricingMode('per-token') setBillingExpr('') setRequestRuleExpr('') + setPerRequestSubMode('fixed') + setPrice1k('') + setPrice2k('') + setPrice4k('') } setPromptPrice(nextLaneState.promptPrice) @@ -998,27 +1002,27 @@ export function ModelPricingEditorPanel({ {( [ { - label: '1K (≤ 1024px)', + labelKey: '1K (≤ 1024px)', value: price1k, setter: setPrice1k, placeholder: '0.011', }, { - label: '2K (≤ 2048px)', + labelKey: '2K (≤ 2048px)', value: price2k, setter: setPrice2k, placeholder: '0.042', }, { - label: '4K (> 2048px)', + labelKey: '4K (> 2048px)', value: price4k, setter: setPrice4k, placeholder: '0.167', }, ] as const - ).map(({ label, value, setter, placeholder }) => ( - - {label} + ).map(({ labelKey, value, setter, placeholder }) => ( + + {t(labelKey)} $ diff --git a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx index 297859eb877..9c8ac2c96b3 100644 --- a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx @@ -350,6 +350,19 @@ export const ModelRatioVisualEditor = memo( ...Object.keys(billingExprMap), ]) + // Parse imageModelSetting once outside the per-model map to avoid + // redundant JSON parsing on every iteration. + type ImgCfg = { + billing_mode?: string + price_1k?: number + price_2k?: number + price_4k?: number + } + const imgModelsMap = safeJsonParse>( + imageModelSetting, + { fallback: {}, silent: true } + ) + const modelData: ModelRow[] = Array.from(modelNames).map((name) => { const price = priceMap[name]?.toString() || '' const ratio = ratioMap[name]?.toString() || '' @@ -362,16 +375,6 @@ export const ModelRatioVisualEditor = memo( // Read per-resolution config from image_model_setting.models // The prop value is the raw image model settings map JSON. - type ImgCfg = { - billing_mode?: string - price_1k?: number - price_2k?: number - price_4k?: number - } - const imgModelsMap = safeJsonParse>( - imageModelSetting, - { fallback: {}, silent: true } - ) const imgCfg: ImgCfg | undefined = imgModelsMap[name] const isPerResolution = imgCfg?.billing_mode === 'per_size' const modeForModel = billingModeMap[name] @@ -1144,6 +1147,7 @@ export const ModelRatioVisualEditor = memo( prevProps.audioCompletionRatio === nextProps.audioCompletionRatio && prevProps.billingMode === nextProps.billingMode && prevProps.billingExpr === nextProps.billingExpr && + prevProps.imageModelSetting === nextProps.imageModelSetting && prevProps.onChange === nextProps.onChange ) } diff --git a/web/default/src/features/system-settings/models/ratio-settings-card.tsx b/web/default/src/features/system-settings/models/ratio-settings-card.tsx index 6ce75ff28f0..d5101f6a693 100644 --- a/web/default/src/features/system-settings/models/ratio-settings-card.tsx +++ b/web/default/src/features/system-settings/models/ratio-settings-card.tsx @@ -130,7 +130,16 @@ const modelSchema = z.object({ }) } }), - ImageModelSetting: z.string(), + ImageModelSetting: z.string().superRefine((value, ctx) => { + if (value === '' || value === null || value === undefined) return + const result = validateJsonString(value) + if (!result.valid) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: result.message || 'Invalid JSON', + }) + } + }), }) const groupSchema = z.object({ @@ -252,7 +261,7 @@ export function RatioSettingsCard({ ExposeRatioEnabled: modelDefaults.ExposeRatioEnabled, BillingMode: normalizeJsonString(modelDefaults.BillingMode), BillingExpr: normalizeJsonString(modelDefaults.BillingExpr), - ImageModelSetting: modelDefaults.ImageModelSetting ?? '', + ImageModelSetting: normalizeJsonString(modelDefaults.ImageModelSetting ?? ''), }) const groupNormalizedDefaults = useRef({ @@ -319,7 +328,7 @@ export function RatioSettingsCard({ ExposeRatioEnabled: modelDefaults.ExposeRatioEnabled, BillingMode: normalizeJsonString(modelDefaults.BillingMode), BillingExpr: normalizeJsonString(modelDefaults.BillingExpr), - ImageModelSetting: modelDefaults.ImageModelSetting ?? '', + ImageModelSetting: normalizeJsonString(modelDefaults.ImageModelSetting ?? ''), } modelForm.reset({ @@ -380,7 +389,7 @@ export function RatioSettingsCard({ ExposeRatioEnabled: values.ExposeRatioEnabled, BillingMode: normalizeJsonString(values.BillingMode), BillingExpr: normalizeJsonString(values.BillingExpr), - ImageModelSetting: values.ImageModelSetting ?? '', + ImageModelSetting: normalizeJsonString(values.ImageModelSetting ?? ''), } const apiKeyMap: Record = { diff --git a/web/default/src/i18n/locales/_reports/_sync-report.json b/web/default/src/i18n/locales/_reports/_sync-report.json index 771b6f6550a..cd0b41d0afb 100644 --- a/web/default/src/i18n/locales/_reports/_sync-report.json +++ b/web/default/src/i18n/locales/_reports/_sync-report.json @@ -9,33 +9,33 @@ }, "fr": { "file": "fr.json", - "missingCount": 0, + "missingCount": 7, "extrasCount": 0, "untranslatedCount": 21 }, "ja": { "file": "ja.json", - "missingCount": 0, + "missingCount": 7, "extrasCount": 0, - "untranslatedCount": 120 + "untranslatedCount": 124 }, "ru": { "file": "ru.json", - "missingCount": 0, + "missingCount": 7, "extrasCount": 0, - "untranslatedCount": 135 + "untranslatedCount": 139 }, "vi": { "file": "vi.json", - "missingCount": 0, + "missingCount": 7, "extrasCount": 0, "untranslatedCount": 23 }, "zh": { "file": "zh.json", - "missingCount": 0, + "missingCount": 7, "extrasCount": 0, - "untranslatedCount": 99 + "untranslatedCount": 103 } } } diff --git a/web/default/src/i18n/locales/_reports/ja.untranslated.json b/web/default/src/i18n/locales/_reports/ja.untranslated.json index b6d34b23ff9..4306e1bef45 100644 --- a/web/default/src/i18n/locales/_reports/ja.untranslated.json +++ b/web/default/src/i18n/locales/_reports/ja.untranslated.json @@ -84,6 +84,10 @@ "org-...": "org-...", "Passkey": "Passkey", "Payment, redemption codes, subscription plans, and invitation rewards are locked until the root administrator confirms the compliance terms.": "Payment, redemption codes, subscription plans, and invitation rewards are locked until the root administrator confirms the compliance terms.", + "Sub-mode": "Sub-mode", + "1K price": "1K price", + "2K price": "2K price", + "4K price": "4K price", "Perplexity": "Perplexity", "Please type the following text to confirm:": "Please type the following text to confirm:", "price_xxx": "price_xxx", diff --git a/web/default/src/i18n/locales/_reports/ru.untranslated.json b/web/default/src/i18n/locales/_reports/ru.untranslated.json index fa683ab7587..e73f4c529f3 100644 --- a/web/default/src/i18n/locales/_reports/ru.untranslated.json +++ b/web/default/src/i18n/locales/_reports/ru.untranslated.json @@ -94,6 +94,10 @@ "operation and charging behavior": "operation and charging behavior", "Passkey": "Passkey", "Payment, redemption codes, subscription plans, and invitation rewards are locked until the root administrator confirms the compliance terms.": "Payment, redemption codes, subscription plans, and invitation rewards are locked until the root administrator confirms the compliance terms.", + "Sub-mode": "Sub-mode", + "1K price": "1K price", + "2K price": "2K price", + "4K price": "4K price", "Perplexity": "Perplexity", "Please type the following text to confirm:": "Please type the following text to confirm:", "price_xxx": "price_xxx", diff --git a/web/default/src/i18n/locales/_reports/zh.untranslated.json b/web/default/src/i18n/locales/_reports/zh.untranslated.json index 12e5e08677f..d1fefb5b4a8 100644 --- a/web/default/src/i18n/locales/_reports/zh.untranslated.json +++ b/web/default/src/i18n/locales/_reports/zh.untranslated.json @@ -75,6 +75,10 @@ "OpenRouter": "OpenRouter", "org-...": "org-...", "Passkey": "Passkey", + "Sub-mode": "Sub-mode", + "1K price": "1K price", + "2K price": "2K price", + "4K price": "4K price", "Perplexity": "Perplexity", "price_xxx": "price_xxx", "QuantumNous": "QuantumNous", diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 7a9c8aaf729..f832c276a61 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -2898,6 +2898,13 @@ "Per-request (fixed price)": "Per-request (fixed price)", "Per-resolution": "Per-resolution", "Per-resolution (default prices)": "Per-resolution (default prices)", + "Sub-mode": "Sub-mode", + "1K price": "1K price", + "2K price": "2K price", + "4K price": "4K price", + "1K (≤ 1024px)": "1K (≤ 1024px)", + "2K (≤ 2048px)": "2K (≤ 2048px)", + "4K (> 2048px)": "4K (> 2048px)", "Per-token": "Per-token", "Per-token (ratio based)": "Per-token (ratio based)", "Per-token logit bias map": "Per-token logit bias map", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 03e5a4006f4..88ce883bd91 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -2898,6 +2898,13 @@ "Per-request (fixed price)": "Par requête (prix fixe)", "Per-resolution": "Par résolution", "Per-resolution (default prices)": "Par résolution (prix par défaut)", + "Sub-mode": "Sub-mode", + "1K price": "1K price", + "2K price": "2K price", + "4K price": "4K price", + "1K (≤ 1024px)": "1K (≤ 1024px)", + "2K (≤ 2048px)": "2K (≤ 2048px)", + "4K (> 2048px)": "4K (> 2048px)", "Per-token": "Par jeton", "Per-token (ratio based)": "Par jeton (basé sur un ratio)", "Per-token logit bias map": "Carte de biais des logits par jeton", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 1cf50cfcb06..d3bbb38c33e 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -2898,6 +2898,13 @@ "Per-request (fixed price)": "リクエストごと (固定価格)", "Per-resolution": "解像度別", "Per-resolution (default prices)": "解像度別(デフォルト価格)", + "Sub-mode": "Sub-mode", + "1K price": "1K price", + "2K price": "2K price", + "4K price": "4K price", + "1K (≤ 1024px)": "1K (≤ 1024px)", + "2K (≤ 2048px)": "2K (≤ 2048px)", + "4K (> 2048px)": "4K (> 2048px)", "Per-token": "トークン単位", "Per-token (ratio based)": "トークンごと (比率ベース)", "Per-token logit bias map": "トークンごとの logit バイアス", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 6eff7679b29..a14ef5b1eec 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -2898,6 +2898,13 @@ "Per-request (fixed price)": "За запрос (фиксированная цена)", "Per-resolution": "По разрешению", "Per-resolution (default prices)": "По разрешению (цены по умолчанию)", + "Sub-mode": "Sub-mode", + "1K price": "1K price", + "2K price": "2K price", + "4K price": "4K price", + "1K (≤ 1024px)": "1K (≤ 1024px)", + "2K (≤ 2048px)": "2K (≤ 2048px)", + "4K (> 2048px)": "4K (> 2048px)", "Per-token": "За токен", "Per-token (ratio based)": "За токен (на основе соотношения)", "Per-token logit bias map": "Карта смещений логитов по токенам", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index d6d800d66fa..7fc24286a01 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -2898,6 +2898,13 @@ "Per-request (fixed price)": "Theo yêu cầu (giá cố định)", "Per-resolution": "Theo độ phân giải", "Per-resolution (default prices)": "Theo độ phân giải (giá mặc định)", + "Sub-mode": "Sub-mode", + "1K price": "1K price", + "2K price": "2K price", + "4K price": "4K price", + "1K (≤ 1024px)": "1K (≤ 1024px)", + "2K (≤ 2048px)": "2K (≤ 2048px)", + "4K (> 2048px)": "4K (> 2048px)", "Per-token": "Theo token", "Per-token (ratio based)": "Mỗi token (dựa trên tỷ lệ)", "Per-token logit bias map": "Bảng logit bias theo token", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 53a4b15504b..25ecfcc1f86 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -426,7 +426,7 @@ "auth.resetPasswordConfirm.backToLogin": "返回登录", "auth.resetPasswordConfirm.confirm": "确认重置密码", "auth.resetPasswordConfirm.description": "确认重置请求以生成新密码。", - "auth.resetPasswordConfirm.retry": "重试 ({{seconds}}s)", + "auth.resetPasswordConfirm.retry": "重试 ({{seconds}}秒)", "auth.resetPasswordConfirm.success": "您的密码已成功重置", "Authentication": "身份验证", "Authenticator code": "身份验证器代码", @@ -2898,6 +2898,13 @@ "Per-request (fixed price)": "按请求计费(固定价格)", "Per-resolution": "按分辨率", "Per-resolution (default prices)": "按分辨率(默认价格)", + "Sub-mode": "Sub-mode", + "1K price": "1K price", + "2K price": "2K price", + "4K price": "4K price", + "1K (≤ 1024px)": "1K (≤ 1024px)", + "2K (≤ 2048px)": "2K (≤ 2048px)", + "4K (> 2048px)": "4K (> 2048px)", "Per-token": "按 Token", "Per-token (ratio based)": "按令牌计费(基于比例)", "Per-token logit bias map": "按 token 的 logit 偏置映射", From 41163d3777da316e7736d7e652302786568d2ad8 Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Fri, 22 May 2026 10:43:48 +0800 Subject: [PATCH 3/9] fix: add gpt-image-2 to defaultImageModels Signed-off-by: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> --- setting/operation_setting/image_model_setting.go | 1 + 1 file changed, 1 insertion(+) diff --git a/setting/operation_setting/image_model_setting.go b/setting/operation_setting/image_model_setting.go index 8939dee5ca6..5f814d0fbdc 100644 --- a/setting/operation_setting/image_model_setting.go +++ b/setting/operation_setting/image_model_setting.go @@ -51,6 +51,7 @@ var defaultImageModels = map[string]ImageModelConfig{ "gpt-image-1": {BillingMode: ImageBillingModeToken}, "gpt-image-1-mini": {BillingMode: ImageBillingModeToken}, "gpt-image-1.5": {BillingMode: ImageBillingModeToken}, + "gpt-image-2": {BillingMode: ImageBillingModeToken}, "chatgpt-image-latest": {BillingMode: ImageBillingModeToken}, "dall-e-2": {BillingMode: ImageBillingModeToken}, "dall-e-3": {BillingMode: ImageBillingModeToken}, From 2c0070fc7da3dfcf9f349237b7e8ab84049f7f24 Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Fri, 22 May 2026 11:06:17 +0800 Subject: [PATCH 4/9] fix: clamp image_per_size_count to >=1 and default tier to 2K in details dialog Signed-off-by: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> --- .../features/usage-logs/components/dialogs/details-dialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx index fc57e290566..fc605cb3d1e 100644 --- a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx +++ b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx @@ -294,8 +294,8 @@ function BillingBreakdown(props: { } if (other.image_per_size_billing && other.image_per_size_price != null) { - const count = other.image_per_size_count ?? 1 - const tier = other.image_size_tier ?? '' + const count = Math.max(1, other.image_per_size_count ?? 1) + const tier = other.image_size_tier || '2K' rows.push({ label: t('Image Generation'), value: `${tier} × ${count} ${t('image(s)')} (${fmtPrice(other.image_per_size_price)}/${t('image')})`, From 1bea566341109f48070a6f11c413282d3544a8cd Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Sun, 24 May 2026 06:16:09 +0800 Subject: [PATCH 5/9] feat(pricing): show per-resolution image billing in model marketplace - Backend: add ImageBillingMode and ImagePerSizePrices fields to Pricing struct; populate from operation_setting in updatePricing() - Frontend types: add image_billing_mode and image_per_size_prices to PricingModel - model-card: display 1K/2K/4K tier prices and Per-resolution badge for per_size models - pricing-columns: show three-tier price in the Price column for per_size models - model-details: add Per-resolution badge in header; render resolution pricing grid in Base Price section - i18n: add 'Flat price per image by output resolution' key to en/zh --- model/pricing.go | 23 +++++++++ .../pricing/components/model-card.tsx | 27 ++++++++++ .../pricing/components/model-details.tsx | 49 +++++++++++++++++++ .../pricing/components/pricing-columns.tsx | 18 +++++++ web/default/src/features/pricing/types.ts | 11 +++++ web/default/src/i18n/locales/en.json | 18 +++++++ web/default/src/i18n/locales/zh.json | 18 +++++++ 7 files changed, 164 insertions(+) diff --git a/model/pricing.go b/model/pricing.go index b9574a38858..d5766ae0d83 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -11,10 +11,19 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/setting/billing_setting" + "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/types" ) +// ImagePerSizePrices holds the per-resolution flat prices (USD/image) for +// image models that use the "per_size" billing mode. +type ImagePerSizePrices struct { + Price1K float64 `json:"price_1k"` + Price2K float64 `json:"price_2k"` + Price4K float64 `json:"price_4k"` +} + type Pricing struct { ModelName string `json:"model_name"` Description string `json:"description,omitempty"` @@ -36,6 +45,11 @@ type Pricing struct { BillingMode string `json:"billing_mode,omitempty"` BillingExpr string `json:"billing_expr,omitempty"` PricingVersion string `json:"pricing_version,omitempty"` + // ImageBillingMode is "per_size" when the model charges a flat per-image + // price based on output resolution, or empty/absent for token billing. + ImageBillingMode string `json:"image_billing_mode,omitempty"` + // ImagePerSizePrices is populated when ImageBillingMode == "per_size". + ImagePerSizePrices *ImagePerSizePrices `json:"image_per_size_prices,omitempty"` } type PricingVendor struct { @@ -337,6 +351,15 @@ func updatePricing() { pricing.BillingExpr = expr } } + // Populate per-resolution image billing info when configured. + if operation_setting.IsImagePerSizeBilling(model) { + pricing.ImageBillingMode = operation_setting.ImageBillingModePerSize + pricing.ImagePerSizePrices = &ImagePerSizePrices{ + Price1K: operation_setting.GetImagePerSizePrice(model, operation_setting.ImageSizeTier1K), + Price2K: operation_setting.GetImagePerSizePrice(model, operation_setting.ImageSizeTier2K), + Price4K: operation_setting.GetImagePerSizePrice(model, operation_setting.ImageSizeTier4K), + } + } pricingMap = append(pricingMap, pricing) } diff --git a/web/default/src/features/pricing/components/model-card.tsx b/web/default/src/features/pricing/components/model-card.tsx index a8d792bc87e..0f708016364 100644 --- a/web/default/src/features/pricing/components/model-card.tsx +++ b/web/default/src/features/pricing/components/model-card.tsx @@ -63,6 +63,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { const isDynamicPricing = props.model.billing_mode === 'tiered_expr' && Boolean(props.model.billing_expr) + const isImagePerSize = props.model.image_billing_mode === 'per_size' const hasCachedPrice = isTokenBased && props.model.cache_ratio != null const dynamicSummary = isDynamicPricing ? getDynamicPricingSummary(props.model, { @@ -138,6 +139,27 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { {t('Dynamic Pricing')} ) + ) : isImagePerSize && props.model.image_per_size_prices ? ( + <> + + + ${props.model.image_per_size_prices.price_1k.toFixed(3)} + {' '} + / 1K + + + + ${props.model.image_per_size_prices.price_2k.toFixed(3)} + {' '} + / 2K + + + + ${props.model.image_per_size_prices.price_4k.toFixed(3)} + {' '} + / 4K + + ) : isTokenBased ? ( <> @@ -237,6 +259,11 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { {isTokenBased ? t('Token-based') : t('Per Request')} + {isImagePerSize && ( + + {t('Per-resolution')} + + )} {isDynamicPricing && ( )} + {model.image_billing_mode === 'per_size' && ( + <> + · + + {t('Per-resolution')} + + + )}
{description && (

@@ -498,6 +506,47 @@ function PriceSection(props: { ) } + // Per-resolution image billing + if (props.model.image_billing_mode === 'per_size' && props.model.image_per_size_prices) { + const prices = props.model.image_per_size_prices + const tiers = [ + { label: '1K', key: 'price_1k' as const, desc: '≤ 1024px' }, + { label: '2K', key: 'price_2k' as const, desc: '≤ 2048px' }, + { label: '4K', key: 'price_4k' as const, desc: '> 2048px' }, + ] + return ( +

+ {t('Base Price')} +
+ + {t('Per-resolution')} + + + {t('Flat price per image by output resolution')} + +
+
+ {tiers.map((tier) => ( +
+
+ {tier.label} + + ({tier.desc}) + +
+
+ ${prices[tier.key].toFixed(3)} + + / {t('image')} + +
+
+ ))} +
+
+ ) + } + const secondaryItems = secondaryPriceTypes.filter((p) => p.available) const renderPrice = (type: PriceType) => ( <> diff --git a/web/default/src/features/pricing/components/pricing-columns.tsx b/web/default/src/features/pricing/components/pricing-columns.tsx index c6f26406f2b..4c48d754835 100644 --- a/web/default/src/features/pricing/components/pricing-columns.tsx +++ b/web/default/src/features/pricing/components/pricing-columns.tsx @@ -218,6 +218,24 @@ export function usePricingColumns( const isTokenBased = isTokenBasedModel(model) + if (model.image_billing_mode === 'per_size' && model.image_per_size_prices) { + const p = model.image_per_size_prices + return ( +
+ + ${p.price_1k.toFixed(3)} + / + ${p.price_2k.toFixed(3)} + / + ${p.price_4k.toFixed(3)} + +
+ 1K / 2K / 4K · {t('Per-resolution')} +
+
+ ) + } + if (isTokenBased) { const inputPrice = stripTrailingZeros( formatPrice( diff --git a/web/default/src/features/pricing/types.ts b/web/default/src/features/pricing/types.ts index 9e643c913b2..85f1dbde483 100644 --- a/web/default/src/features/pricing/types.ts +++ b/web/default/src/features/pricing/types.ts @@ -55,6 +55,17 @@ export type PricingModel = { billing_expr?: string /** Pricing version returned by backend, useful for cache busting */ pricing_version?: string + /** + * Image billing mode: "per_size" means flat per-image price by resolution. + * Absent or empty means standard token billing. + */ + image_billing_mode?: string + /** Per-resolution prices (USD/image). Present when image_billing_mode === "per_size". */ + image_per_size_prices?: { + price_1k: number + price_2k: number + price_4k: number + } /** * Optional model metadata fields. These are not yet returned by the backend * and are populated client-side from {@link inferModelMetadata}. diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index f832c276a61..9bb1ada5885 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -2374,6 +2374,24 @@ "Model Price Not Configured": "Model Price Not Configured", "Model prices": "Model prices", "Model prices reset successfully": "Model prices reset successfully", +"Configure billing mode for image generation models. \"Per-token\" uses the standard token ratio. \"Per-resolution\" charges a flat price per image based on output size (1K ≤ 1024px, 2K ≤ 2048px, 4K > 2048px).": "Configure billing mode for image generation models. \"Per-token\" uses the standard token ratio. \"Per-resolution\" charges a flat price per image based on output size (1K ≤ 1024px, 2K ≤ 2048px, 4K > 2048px).", +"Leave price fields empty to use the built-in default prices. Custom prices override the defaults.": "Leave price fields empty to use the built-in default prices. Custom prices override the defaults.", +"Billing mode": "Billing mode", +"Per-resolution": "Per-resolution", +"Flat price per image by output resolution": "Flat price per image by output resolution", +"1K price ($/img)": "1K price ($/img)", +"2K price ($/img)": "2K price ($/img)", +"4K price ($/img)": "4K price ($/img)", +"No image models configured": "No image models configured", +"Save image model settings": "Save image model settings", +"image(s)": "image(s)", +"image": "image", +"Per-resolution pricing": "Per-resolution pricing", +"Override the flat price per image by output resolution tier. Leave empty to use the built-in default. When set, the billing engine uses these prices instead of the fixed price above.": "Override the flat price per image by output resolution tier. Leave empty to use the built-in default. When set, the billing engine uses these prices instead of the fixed price above.", +"/ img": "/ img", +"Charge a flat price per image based on output resolution tier. 1K = long edge ≤ 1024px, 2K = ≤ 2048px, 4K = > 2048px. Leave empty to use the built-in default price.": "Charge a flat price per image based on output resolution tier. 1K = long edge ≤ 1024px, 2K = ≤ 2048px, 4K = > 2048px. Leave empty to use the built-in default price.", +"Per-resolution (default prices)": "Per-resolution (default prices)", +"Billed per image by resolution tier": "Billed per image by resolution tier", "Model Pricing": "Model Pricing", "Model pull failed: {{msg}}": "Model pull failed: {{msg}}", "Model ratio": "Model ratio", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 25ecfcc1f86..7b63caf49c2 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -2374,6 +2374,24 @@ "Model Price Not Configured": "模型价格未配置", "Model prices": "模型价格", "Model prices reset successfully": "模型价格重置成功", +"Configure billing mode for image generation models. \"Per-token\" uses the standard token ratio. \"Per-resolution\" charges a flat price per image based on output size (1K ≤ 1024px, 2K ≤ 2048px, 4K > 2048px).": "配置图片生成模型的计费方式。「按 Token」使用标准 Token 比率计费;「按分辨率」根据输出尺寸(1K ≤ 1024px、2K ≤ 2048px、4K > 2048px)按张收取固定费用。", +"Leave price fields empty to use the built-in default prices. Custom prices override the defaults.": "价格字段留空则使用内置默认价格,填写后将覆盖默认值。", +"Billing mode": "计费方式", +"Per-resolution": "按分辨率", +"Flat price per image by output resolution": "按输出分辨率按张计费", +"1K price ($/img)": "1K 价格($/张)", +"2K price ($/img)": "2K 价格($/张)", +"4K price ($/img)": "4K 价格($/张)", +"No image models configured": "暂无图片模型配置", +"Save image model settings": "保存图片模型设置", +"image(s)": "张", +"image": "张", +"Per-resolution pricing": "按分辨率定价", +"Override the flat price per image by output resolution tier. Leave empty to use the built-in default. When set, the billing engine uses these prices instead of the fixed price above.": "按输出分辨率档位覆盖每张图片的固定价格。留空则使用内置默认值。设置后,计费引擎将使用这些价格而非上方的固定价格。", +"/ img": "/ 张", +"Charge a flat price per image based on output resolution tier. 1K = long edge ≤ 1024px, 2K = ≤ 2048px, 4K = > 2048px. Leave empty to use the built-in default price.": "按输出分辨率档位对每张图片收取固定费用。1K = 长边 ≤ 1024px,2K = ≤ 2048px,4K = > 2048px。留空则使用内置默认价格。", +"Per-resolution (default prices)": "按分辨率(默认价格)", +"Billed per image by resolution tier": "按分辨率档位对每张图片计费", "Model Pricing": "模型定价", "Model pull failed: {{msg}}": "模型拉取失败:{{msg}}", "Model ratio": "模型倍率", From 556831f342c731b2d181b9ca14c2ab7905dd676d Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Sun, 24 May 2026 06:30:38 +0800 Subject: [PATCH 6/9] fix(pricing): check per_size before isTokenBased in PriceSection --- .../pricing/components/model-details.tsx | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index a14c06991cd..b61b33a55ad 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -483,30 +483,9 @@ function PriceSection(props: { ) } - if (!isTokenBased) { - return ( -
- {t('Base Price')} -
- - {t('Per request')} - - - {formatFixedPrice( - props.model, - baseGroupKey, - props.showRechargePrice, - props.priceRate, - props.usdExchangeRate, - baseGroupRatioMap - )} - -
-
- ) - } - - // Per-resolution image billing + // Per-resolution image billing — must be checked before the generic + // !isTokenBased branch, because per_size models have quota_type=1 (per-request) + // and would otherwise fall into the "Per request $0" display. if (props.model.image_billing_mode === 'per_size' && props.model.image_per_size_prices) { const prices = props.model.image_per_size_prices const tiers = [ @@ -547,6 +526,29 @@ function PriceSection(props: { ) } + if (!isTokenBased) { + return ( +
+ {t('Base Price')} +
+ + {t('Per request')} + + + {formatFixedPrice( + props.model, + baseGroupKey, + props.showRechargePrice, + props.priceRate, + props.usdExchangeRate, + baseGroupRatioMap + )} + +
+
+ ) + } + const secondaryItems = secondaryPriceTypes.filter((p) => p.available) const renderPrice = (type: PriceType) => ( <> From 2255105cad1e122ff5d079621112a45603ff2708 Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Sun, 24 May 2026 06:35:14 +0800 Subject: [PATCH 7/9] fix(pricing): show per-resolution tiers in group pricing table Replace the single Price column with 1K/2K/4K columns for per_size models. Each cell shows base_price * group_ratio * priceRate * fxRate. --- .../pricing/components/model-details.tsx | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index b61b33a55ad..fc59e8b15b0 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -827,7 +827,13 @@ function GroupPricingSection(props: { {t('Group')} {t('Ratio')} - {isTokenBased ? ( + {props.model.image_billing_mode === 'per_size' ? ( + <> + 1K + 2K + 4K + + ) : isTokenBased ? ( <> {t('Input')} @@ -862,7 +868,29 @@ function GroupPricingSection(props: { {ratio}x - {isTokenBased ? ( + {props.model.image_billing_mode === 'per_size' && + props.model.image_per_size_prices ? ( + (() => { + const p = props.model.image_per_size_prices + const fmt = (base: number) => { + const val = base * ratio * props.priceRate * props.usdExchangeRate + return `$${val.toFixed(3)}` + } + return ( + <> + + {fmt(p.price_1k)} + + + {fmt(p.price_2k)} + + + {fmt(p.price_4k)} + + + ) + })() + ) : isTokenBased ? ( <> {formatGroupPrice( From ef2d0e20cd9f6f31418f0cd33eb7bc2bbf8ebfa1 Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Sun, 24 May 2026 06:40:35 +0800 Subject: [PATCH 8/9] feat(pricing): add capability badges to model detail header Replace plain text Token-based/Per Request with styled badges. Add Cached (green), Audio (purple), Image input (orange) badges when the model has those pricing ratios configured. --- .../pricing/components/model-details.tsx | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index fc59e8b15b0..10eee59da66 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -298,28 +298,43 @@ function ModelHeader(props: { model: PricingModel }) { {model.vendor_name} )} · - - {model.quota_type === QUOTA_TYPE_VALUES.TOKEN - ? t('Token-based') - : t('Per Request')} - + {/* Billing type badge */} + {model.image_billing_mode === 'per_size' ? ( + + {t('Per-resolution')} + + ) : model.quota_type === QUOTA_TYPE_VALUES.TOKEN ? ( + + {t('Token-based')} + + ) : ( + + {t('Per Request')} + + )} + {/* Dynamic pricing badge */} {model.billing_mode === 'tiered_expr' && model.billing_expr && ( - <> - · - - {isSpecialExpression - ? t('Special billing expression') - : t('Dynamic Pricing')} - - + + {isSpecialExpression + ? t('Special billing expression') + : t('Dynamic Pricing')} + )} - {model.image_billing_mode === 'per_size' && ( - <> - · - - {t('Per-resolution')} - - + {/* Capability badges */} + {model.cache_ratio != null && ( + + {t('Cached')} + + )} + {model.audio_ratio != null && ( + + {t('Audio')} + + )} + {model.image_ratio != null && ( + + {t('Image input')} + )} {description && ( From 8e6d7158c8bfe40977f6a395e163b8e6ff202057 Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Sun, 24 May 2026 07:04:32 +0800 Subject: [PATCH 9/9] fix(pricing): address CodeRabbit review on marketplace display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model-card, pricing-columns: multiply per-size prices by priceRate and usdExchangeRate so they respond to currency/rate toggles like other prices - model-details: align group-pricing table header condition with body (require image_per_size_prices to be present, not just image_billing_mode) - model-details: wrap size-tier desc strings ('≤ 1024px' etc.) in t() - en.json, zh.json: remove duplicate billing keys introduced by cherry-pick --- .../pricing/components/model-card.tsx | 39 ++++++++++--------- .../pricing/components/model-details.tsx | 9 +++-- .../pricing/components/pricing-columns.tsx | 8 ++-- web/default/src/i18n/locales/en.json | 10 ++--- web/default/src/i18n/locales/zh.json | 10 ++--- 5 files changed, 37 insertions(+), 39 deletions(-) diff --git a/web/default/src/features/pricing/components/model-card.tsx b/web/default/src/features/pricing/components/model-card.tsx index 0f708016364..b87164ef903 100644 --- a/web/default/src/features/pricing/components/model-card.tsx +++ b/web/default/src/features/pricing/components/model-card.tsx @@ -141,24 +141,27 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { ) ) : isImagePerSize && props.model.image_per_size_prices ? ( <> - - - ${props.model.image_per_size_prices.price_1k.toFixed(3)} - {' '} - / 1K - - - - ${props.model.image_per_size_prices.price_2k.toFixed(3)} - {' '} - / 2K - - - - ${props.model.image_per_size_prices.price_4k.toFixed(3)} - {' '} - / 4K - + {(['price_1k', 'price_2k', 'price_4k'] as const).map( + (key, i) => { + const label = ['1K', '2K', '4K'][i] + const val = ( + props.model.image_per_size_prices![key] * + priceRate * + usdExchangeRate + ).toFixed(3) + return ( + + + ${val} + {' '} + / {label} + + ) + } + )} ) : isTokenBased ? ( <> diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index 10eee59da66..ec702c3ea04 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -504,9 +504,9 @@ function PriceSection(props: { if (props.model.image_billing_mode === 'per_size' && props.model.image_per_size_prices) { const prices = props.model.image_per_size_prices const tiers = [ - { label: '1K', key: 'price_1k' as const, desc: '≤ 1024px' }, - { label: '2K', key: 'price_2k' as const, desc: '≤ 2048px' }, - { label: '4K', key: 'price_4k' as const, desc: '> 2048px' }, + { label: '1K', key: 'price_1k' as const, desc: t('≤ 1024px') }, + { label: '2K', key: 'price_2k' as const, desc: t('≤ 2048px') }, + { label: '4K', key: 'price_4k' as const, desc: t('> 2048px') }, ] return (
@@ -842,7 +842,8 @@ function GroupPricingSection(props: { {t('Group')} {t('Ratio')} - {props.model.image_billing_mode === 'per_size' ? ( + {props.model.image_billing_mode === 'per_size' && + props.model.image_per_size_prices ? ( <> 1K 2K diff --git a/web/default/src/features/pricing/components/pricing-columns.tsx b/web/default/src/features/pricing/components/pricing-columns.tsx index 4c48d754835..5fb99b5db9d 100644 --- a/web/default/src/features/pricing/components/pricing-columns.tsx +++ b/web/default/src/features/pricing/components/pricing-columns.tsx @@ -220,14 +220,16 @@ export function usePricingColumns( if (model.image_billing_mode === 'per_size' && model.image_per_size_prices) { const p = model.image_per_size_prices + const fmt = (base: number) => + '$' + (base * priceRate * usdExchangeRate).toFixed(3) return (
- ${p.price_1k.toFixed(3)} + {fmt(p.price_1k)} / - ${p.price_2k.toFixed(3)} + {fmt(p.price_2k)} / - ${p.price_4k.toFixed(3)} + {fmt(p.price_4k)}
1K / 2K / 4K · {t('Per-resolution')} diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 9bb1ada5885..ad4660e6d3f 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -2377,21 +2377,17 @@ "Configure billing mode for image generation models. \"Per-token\" uses the standard token ratio. \"Per-resolution\" charges a flat price per image based on output size (1K ≤ 1024px, 2K ≤ 2048px, 4K > 2048px).": "Configure billing mode for image generation models. \"Per-token\" uses the standard token ratio. \"Per-resolution\" charges a flat price per image based on output size (1K ≤ 1024px, 2K ≤ 2048px, 4K > 2048px).", "Leave price fields empty to use the built-in default prices. Custom prices override the defaults.": "Leave price fields empty to use the built-in default prices. Custom prices override the defaults.", "Billing mode": "Billing mode", -"Per-resolution": "Per-resolution", "Flat price per image by output resolution": "Flat price per image by output resolution", +"≤ 1024px": "≤ 1024px", +"≤ 2048px": "≤ 2048px", +"> 2048px": "> 2048px", "1K price ($/img)": "1K price ($/img)", "2K price ($/img)": "2K price ($/img)", "4K price ($/img)": "4K price ($/img)", "No image models configured": "No image models configured", "Save image model settings": "Save image model settings", -"image(s)": "image(s)", -"image": "image", "Per-resolution pricing": "Per-resolution pricing", "Override the flat price per image by output resolution tier. Leave empty to use the built-in default. When set, the billing engine uses these prices instead of the fixed price above.": "Override the flat price per image by output resolution tier. Leave empty to use the built-in default. When set, the billing engine uses these prices instead of the fixed price above.", -"/ img": "/ img", -"Charge a flat price per image based on output resolution tier. 1K = long edge ≤ 1024px, 2K = ≤ 2048px, 4K = > 2048px. Leave empty to use the built-in default price.": "Charge a flat price per image based on output resolution tier. 1K = long edge ≤ 1024px, 2K = ≤ 2048px, 4K = > 2048px. Leave empty to use the built-in default price.", -"Per-resolution (default prices)": "Per-resolution (default prices)", -"Billed per image by resolution tier": "Billed per image by resolution tier", "Model Pricing": "Model Pricing", "Model pull failed: {{msg}}": "Model pull failed: {{msg}}", "Model ratio": "Model ratio", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 7b63caf49c2..77b21610611 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -2377,21 +2377,17 @@ "Configure billing mode for image generation models. \"Per-token\" uses the standard token ratio. \"Per-resolution\" charges a flat price per image based on output size (1K ≤ 1024px, 2K ≤ 2048px, 4K > 2048px).": "配置图片生成模型的计费方式。「按 Token」使用标准 Token 比率计费;「按分辨率」根据输出尺寸(1K ≤ 1024px、2K ≤ 2048px、4K > 2048px)按张收取固定费用。", "Leave price fields empty to use the built-in default prices. Custom prices override the defaults.": "价格字段留空则使用内置默认价格,填写后将覆盖默认值。", "Billing mode": "计费方式", -"Per-resolution": "按分辨率", "Flat price per image by output resolution": "按输出分辨率按张计费", +"≤ 1024px": "≤ 1024px", +"≤ 2048px": "≤ 2048px", +"> 2048px": "> 2048px", "1K price ($/img)": "1K 价格($/张)", "2K price ($/img)": "2K 价格($/张)", "4K price ($/img)": "4K 价格($/张)", "No image models configured": "暂无图片模型配置", "Save image model settings": "保存图片模型设置", -"image(s)": "张", -"image": "张", "Per-resolution pricing": "按分辨率定价", "Override the flat price per image by output resolution tier. Leave empty to use the built-in default. When set, the billing engine uses these prices instead of the fixed price above.": "按输出分辨率档位覆盖每张图片的固定价格。留空则使用内置默认值。设置后,计费引擎将使用这些价格而非上方的固定价格。", -"/ img": "/ 张", -"Charge a flat price per image based on output resolution tier. 1K = long edge ≤ 1024px, 2K = ≤ 2048px, 4K = > 2048px. Leave empty to use the built-in default price.": "按输出分辨率档位对每张图片收取固定费用。1K = 长边 ≤ 1024px,2K = ≤ 2048px,4K = > 2048px。留空则使用内置默认价格。", -"Per-resolution (default prices)": "按分辨率(默认价格)", -"Billed per image by resolution tier": "按分辨率档位对每张图片计费", "Model Pricing": "模型定价", "Model pull failed: {{msg}}": "模型拉取失败:{{msg}}", "Model ratio": "模型倍率",