From f424f906d876ac601e7a17efa3259701fb769826 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:17:35 +0800 Subject: [PATCH] feat: sync upstream pricing from pricing endpoint (#4452) * feat: sync upstream pricing from pricing endpoint * feat: sync upstream pricing with expression priority * fix: add feedback while syncing upstream pricing * fix: show loading state for empty upstream pricing sync --- controller/ratio_sync.go | 207 ++++-- setting/billing_setting/tiered_billing.go | 22 + setting/ratio_setting/model_ratio.go | 12 + .../settings/ChannelSelectorModal.jsx | 2 +- web/src/components/settings/RatioSetting.jsx | 2 +- web/src/constants/common.constant.js | 2 +- .../pages/Setting/Ratio/UpstreamRatioSync.jsx | 663 ++++++++++++------ 7 files changed, 643 insertions(+), 267 deletions(-) diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go index 8388e8af..1f57bcc2 100644 --- a/controller/ratio_sync.go +++ b/controller/ratio_sync.go @@ -21,14 +21,16 @@ import ( "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/billing_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/samber/lo" "github.com/gin-gonic/gin" ) const ( defaultTimeoutSeconds = 10 - defaultEndpoint = "/api/ratio_config" + defaultEndpoint = "/api/pricing" maxConcurrentFetches = 8 maxRatioConfigBytes = 10 << 20 // 10MB floatEpsilon = 1e-9 @@ -59,7 +61,29 @@ func valuesEqual(a, b interface{}) bool { return a == b } -var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"} +var pricingSyncFields = []string{ + "model_ratio", + "completion_ratio", + "cache_ratio", + "create_cache_ratio", + "image_ratio", + "audio_ratio", + "audio_completion_ratio", + "model_price", + billing_setting.BillingModeField, + billing_setting.BillingExprField, +} + +var numericPricingSyncFields = map[string]bool{ + "model_ratio": true, + "completion_ratio": true, + "cache_ratio": true, + "create_cache_ratio": true, + "image_ratio": true, + "audio_ratio": true, + "audio_completion_ratio": true, + "model_price": true, +} type upstreamResult struct { Name string `json:"name"` @@ -67,6 +91,54 @@ type upstreamResult struct { Err string `json:"err,omitempty"` } +func valueMap(value any) map[string]any { + switch typed := value.(type) { + case map[string]any: + return typed + case map[string]float64: + return lo.MapValues(typed, func(value float64, _ string) any { return value }) + case map[string]string: + return lo.MapValues(typed, func(value string, _ string) any { return value }) + default: + return nil + } +} + +func asFloat64(value any) (float64, bool) { + switch typed := value.(type) { + case float64: + return typed, true + case float32: + return float64(typed), true + case int: + return float64(typed), true + case int64: + return float64(typed), true + case json.Number: + parsed, err := typed.Float64() + return parsed, err == nil + default: + return 0, false + } +} + +func normalizeSyncValue(field string, value any) any { + if numericPricingSyncFields[field] { + if parsed, ok := asFloat64(value); ok { + return parsed + } + } + return value +} + +func getLocalPricingSyncData() map[string]any { + data := billing_setting.GetPricingSyncData(map[string]any(ratio_setting.GetExposedData())) + data["image_ratio"] = ratio_setting.GetImageRatioCopy() + data["audio_ratio"] = ratio_setting.GetAudioRatioCopy() + data["audio_completion_ratio"] = ratio_setting.GetAudioCompletionRatioCopy() + return data +} + func FetchUpstreamRatios(c *gin.Context) { var req dto.UpstreamRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -293,7 +365,7 @@ func FetchUpstreamRatios(c *gin.Context) { if err := common.Unmarshal(body.Data, &type1Data); err == nil { // 如果包含至少一个 ratioTypes 字段,则认为是 type1 isType1 := false - for _, rt := range ratioTypes { + for _, rt := range pricingSyncFields { if _, ok := type1Data[rt]; ok { isType1 = true break @@ -307,11 +379,18 @@ func FetchUpstreamRatios(c *gin.Context) { // 如果不是 type1,则尝试按 type2 (/api/pricing) 解析 var pricingItems []struct { - ModelName string `json:"model_name"` - QuotaType int `json:"quota_type"` - ModelRatio float64 `json:"model_ratio"` - ModelPrice float64 `json:"model_price"` - CompletionRatio float64 `json:"completion_ratio"` + ModelName string `json:"model_name"` + QuotaType int `json:"quota_type"` + ModelRatio float64 `json:"model_ratio"` + ModelPrice float64 `json:"model_price"` + CompletionRatio float64 `json:"completion_ratio"` + CacheRatio *float64 `json:"cache_ratio"` + CreateCacheRatio *float64 `json:"create_cache_ratio"` + ImageRatio *float64 `json:"image_ratio"` + AudioRatio *float64 `json:"audio_ratio"` + AudioCompletionRatio *float64 `json:"audio_completion_ratio"` + BillingMode string `json:"billing_mode"` + BillingExpr string `json:"billing_expr"` } if err := common.Unmarshal(body.Data, &pricingItems); err != nil { logger.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error()) @@ -321,9 +400,23 @@ func FetchUpstreamRatios(c *gin.Context) { modelRatioMap := make(map[string]float64) completionRatioMap := make(map[string]float64) + cacheRatioMap := make(map[string]float64) + createCacheRatioMap := make(map[string]float64) + imageRatioMap := make(map[string]float64) + audioRatioMap := make(map[string]float64) + audioCompletionRatioMap := make(map[string]float64) modelPriceMap := make(map[string]float64) + billingModeMap := make(map[string]string) + billingExprMap := make(map[string]string) for _, item := range pricingItems { + if item.ModelName == "" { + continue + } + if item.BillingMode == billing_setting.BillingModeTieredExpr && strings.TrimSpace(item.BillingExpr) != "" { + billingModeMap[item.ModelName] = billing_setting.BillingModeTieredExpr + billingExprMap[item.ModelName] = item.BillingExpr + } if item.QuotaType == 1 { modelPriceMap[item.ModelName] = item.ModelPrice } else { @@ -331,6 +424,21 @@ func FetchUpstreamRatios(c *gin.Context) { // completionRatio 可能为 0,此时也直接赋值,保持与上游一致 completionRatioMap[item.ModelName] = item.CompletionRatio } + if item.CacheRatio != nil { + cacheRatioMap[item.ModelName] = *item.CacheRatio + } + if item.CreateCacheRatio != nil { + createCacheRatioMap[item.ModelName] = *item.CreateCacheRatio + } + if item.ImageRatio != nil { + imageRatioMap[item.ModelName] = *item.ImageRatio + } + if item.AudioRatio != nil { + audioRatioMap[item.ModelName] = *item.AudioRatio + } + if item.AudioCompletionRatio != nil { + audioCompletionRatioMap[item.ModelName] = *item.AudioCompletionRatio + } } converted := make(map[string]any) @@ -350,6 +458,21 @@ func FetchUpstreamRatios(c *gin.Context) { } converted["completion_ratio"] = compAny } + if len(cacheRatioMap) > 0 { + converted["cache_ratio"] = valueMap(cacheRatioMap) + } + if len(createCacheRatioMap) > 0 { + converted["create_cache_ratio"] = valueMap(createCacheRatioMap) + } + if len(imageRatioMap) > 0 { + converted["image_ratio"] = valueMap(imageRatioMap) + } + if len(audioRatioMap) > 0 { + converted["audio_ratio"] = valueMap(audioRatioMap) + } + if len(audioCompletionRatioMap) > 0 { + converted["audio_completion_ratio"] = valueMap(audioCompletionRatioMap) + } if len(modelPriceMap) > 0 { priceAny := make(map[string]any, len(modelPriceMap)) @@ -358,6 +481,12 @@ func FetchUpstreamRatios(c *gin.Context) { } converted["model_price"] = priceAny } + if len(billingModeMap) > 0 { + converted[billing_setting.BillingModeField] = valueMap(billingModeMap) + } + if len(billingExprMap) > 0 { + converted[billing_setting.BillingExprField] = valueMap(billingExprMap) + } ch <- upstreamResult{Name: uniqueName, Data: converted} }(chn) @@ -366,7 +495,7 @@ func FetchUpstreamRatios(c *gin.Context) { wg.Wait() close(ch) - localData := ratio_setting.GetExposedData() + localData := getLocalPricingSyncData() var testResults []dto.TestResult var successfulChannels []struct { @@ -412,22 +541,16 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { allModels := make(map[string]struct{}) - for _, ratioType := range ratioTypes { - if localRatioAny, ok := localData[ratioType]; ok { - if localRatio, ok := localRatioAny.(map[string]float64); ok { - for modelName := range localRatio { - allModels[modelName] = struct{}{} - } - } + for _, field := range pricingSyncFields { + for modelName := range valueMap(localData[field]) { + allModels[modelName] = struct{}{} } } for _, channel := range successfulChannels { - for _, ratioType := range ratioTypes { - if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok { - for modelName := range upstreamRatio { - allModels[modelName] = struct{}{} - } + for _, field := range pricingSyncFields { + for modelName := range valueMap(channel.data[field]) { + allModels[modelName] = struct{}{} } } } @@ -438,10 +561,10 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { for _, channel := range successfulChannels { confidenceMap[channel.name] = make(map[string]bool) - modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any) - completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any) + modelRatios := valueMap(channel.data["model_ratio"]) + completionRatios := valueMap(channel.data["completion_ratio"]) - if hasModelRatio && hasCompletionRatio { + if len(modelRatios) > 0 && len(completionRatios) > 0 { // 遍历所有模型,检查是否满足不可信条件 for modelName := range allModels { // 默认为可信 @@ -451,12 +574,10 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { if modelRatioVal, ok := modelRatios[modelName]; ok { if completionRatioVal, ok := completionRatios[modelName]; ok { // 转换为float64进行比较 - if modelRatioFloat, ok := modelRatioVal.(float64); ok { - if completionRatioFloat, ok := completionRatioVal.(float64); ok { - if modelRatioFloat == 37.5 && completionRatioFloat == 1.0 { - confidenceMap[channel.name][modelName] = false - } - } + modelRatioFloat, modelRatioOK := asFloat64(modelRatioVal) + completionRatioFloat, completionRatioOK := asFloat64(completionRatioVal) + if modelRatioOK && completionRatioOK && nearlyEqual(modelRatioFloat, 37.5) && nearlyEqual(completionRatioFloat, 1.0) { + confidenceMap[channel.name][modelName] = false } } } @@ -470,14 +591,10 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { } for modelName := range allModels { - for _, ratioType := range ratioTypes { + for _, ratioType := range pricingSyncFields { var localValue interface{} = nil - if localRatioAny, ok := localData[ratioType]; ok { - if localRatio, ok := localRatioAny.(map[string]float64); ok { - if val, exists := localRatio[modelName]; exists { - localValue = val - } - } + if val, exists := valueMap(localData[ratioType])[modelName]; exists { + localValue = normalizeSyncValue(ratioType, val) } upstreamValues := make(map[string]interface{}) @@ -488,16 +605,14 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { for _, channel := range successfulChannels { var upstreamValue interface{} = nil - if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok { - if val, exists := upstreamRatio[modelName]; exists { - upstreamValue = val - hasUpstreamValue = true + if val, exists := valueMap(channel.data[ratioType])[modelName]; exists { + upstreamValue = normalizeSyncValue(ratioType, val) + hasUpstreamValue = true - if localValue != nil && !valuesEqual(localValue, val) { - hasDifference = true - } else if valuesEqual(localValue, val) { - upstreamValue = "same" - } + if localValue != nil && !valuesEqual(localValue, upstreamValue) { + hasDifference = true + } else if valuesEqual(localValue, upstreamValue) { + upstreamValue = "same" } } if upstreamValue == nil && localValue == nil { diff --git a/setting/billing_setting/tiered_billing.go b/setting/billing_setting/tiered_billing.go index 8d5b6f0f..46dc70de 100644 --- a/setting/billing_setting/tiered_billing.go +++ b/setting/billing_setting/tiered_billing.go @@ -5,11 +5,14 @@ import ( "github.com/QuantumNous/new-api/pkg/billingexpr" "github.com/QuantumNous/new-api/setting/config" + "github.com/samber/lo" ) const ( BillingModeRatio = "ratio" BillingModeTieredExpr = "tiered_expr" + BillingModeField = "billing_mode" + BillingExprField = "billing_expr" ) // BillingSetting is managed by config.GlobalConfig.Register. @@ -44,6 +47,25 @@ func GetBillingExpr(model string) (string, bool) { return expr, ok } +func GetBillingModeCopy() map[string]string { + return lo.Assign(billingSetting.BillingMode) +} + +func GetBillingExprCopy() map[string]string { + return lo.Assign(billingSetting.BillingExpr) +} + +func GetPricingSyncData(base map[string]any) map[string]any { + extra := make(map[string]any, 2) + if modes := GetBillingModeCopy(); len(modes) > 0 { + extra[BillingModeField] = modes + } + if exprs := GetBillingExprCopy(); len(exprs) > 0 { + extra[BillingExprField] = exprs + } + return lo.Assign(base, extra) +} + // --------------------------------------------------------------------------- // Smoke test (called externally for validation before save) // --------------------------------------------------------------------------- diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 42040d97..80702ee4 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -709,6 +709,18 @@ func GetCompletionRatioCopy() map[string]float64 { return completionRatioMap.ReadAll() } +func GetImageRatioCopy() map[string]float64 { + return imageRatioMap.ReadAll() +} + +func GetAudioRatioCopy() map[string]float64 { + return audioRatioMap.ReadAll() +} + +func GetAudioCompletionRatioCopy() map[string]float64 { + return audioCompletionRatioMap.ReadAll() +} + // 转换模型名,减少渠道必须配置各种带参数模型 func FormatMatchingModelName(name string) string { diff --git a/web/src/components/settings/ChannelSelectorModal.jsx b/web/src/components/settings/ChannelSelectorModal.jsx index 7864262d..165aafaf 100644 --- a/web/src/components/settings/ChannelSelectorModal.jsx +++ b/web/src/components/settings/ChannelSelectorModal.jsx @@ -155,8 +155,8 @@ const ChannelSelectorModal = forwardRef( onChange={handleTypeChange} style={{ width: 120 }} optionList={[ - { label: 'ratio_config', value: 'ratio_config' }, { label: 'pricing', value: 'pricing' }, + { label: 'ratio_config', value: 'ratio_config' }, { label: 'OpenRouter', value: 'openrouter' }, { label: 'custom', value: 'custom' }, ]} diff --git a/web/src/components/settings/RatioSetting.jsx b/web/src/components/settings/RatioSetting.jsx index d7051bd4..6f96e325 100644 --- a/web/src/components/settings/RatioSetting.jsx +++ b/web/src/components/settings/RatioSetting.jsx @@ -106,7 +106,7 @@ const RatioSetting = () => { - + diff --git a/web/src/constants/common.constant.js b/web/src/constants/common.constant.js index 8737f729..31635663 100644 --- a/web/src/constants/common.constant.js +++ b/web/src/constants/common.constant.js @@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend! -export const DEFAULT_ENDPOINT = '/api/ratio_config'; +export const DEFAULT_ENDPOINT = '/api/pricing'; export const TABLE_COMPACT_MODES_KEY = 'table_compact_modes'; diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx b/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx index 20ea45e9..20b0bdf3 100644 --- a/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx @@ -29,17 +29,14 @@ import { Tooltip, Select, Modal, + Spin, } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; -import { - RefreshCcw, - CheckSquare, - AlertTriangle, - CheckCircle, -} from 'lucide-react'; +import { RefreshCcw, CheckSquare, AlertTriangle } from 'lucide-react'; import { API, showError, + showInfo, showSuccess, showWarning, stringToColor, @@ -63,7 +60,7 @@ const MODELS_DEV_PRESET_NAME = 'models.dev 价格预设'; const MODELS_DEV_PRESET_BASE_URL = 'https://models.dev'; const MODELS_DEV_PRESET_ENDPOINT = 'https://models.dev/api.json'; -function ConflictConfirmModal({ t, visible, items, onOk, onCancel }) { +function ConflictConfirmModal({ t, visible, items, loading, onOk, onCancel }) { const isMobile = useIsMobile(); const columns = [ { title: t('渠道'), dataIndex: 'channel' }, @@ -84,7 +81,10 @@ function ConflictConfirmModal({ t, visible, items, onOk, onCancel }) { @@ -103,6 +103,7 @@ export default function UpstreamRatioSync(props) { const [modalVisible, setModalVisible] = useState(false); const [loading, setLoading] = useState(false); const [syncLoading, setSyncLoading] = useState(false); + const [confirmLoading, setConfirmLoading] = useState(false); const isMobile = useIsMobile(); // 渠道选择相关 @@ -251,7 +252,7 @@ export default function UpstreamRatioSync(props) { setHasSynced(true); if (Object.keys(differences).length === 0) { - showSuccess(t('未找到差异化倍率,无需同步')); + showSuccess(t('未找到差异化价格,无需同步')); } } catch (e) { showError(t('请求后端接口失败:') + e.message); @@ -260,32 +261,165 @@ export default function UpstreamRatioSync(props) { } }; + const ratioSyncFields = [ + 'model_ratio', + 'completion_ratio', + 'cache_ratio', + 'create_cache_ratio', + 'image_ratio', + 'audio_ratio', + 'audio_completion_ratio', + ]; + + const numericSyncFields = new Set([...ratioSyncFields, 'model_price']); + const syncFieldOrder = [ + ...ratioSyncFields, + 'model_price', + 'billing_mode', + 'billing_expr', + ]; + + function getSyncFieldLabel(ratioType) { + const typeMap = { + model_ratio: t('模型倍率'), + completion_ratio: t('补全倍率'), + cache_ratio: t('缓存倍率'), + create_cache_ratio: t('缓存创建倍率'), + image_ratio: t('图片倍率'), + audio_ratio: t('音频倍率'), + audio_completion_ratio: t('音频补全倍率'), + model_price: t('固定价格'), + billing_mode: t('计费模式'), + billing_expr: t('表达式计费'), + }; + return typeMap[ratioType] || ratioType; + } + + function getOrderedRatioTypes(ratioTypes) { + const keys = Object.keys(ratioTypes || {}); + const ordered = [ + ...syncFieldOrder.filter((field) => keys.includes(field)), + ...keys.filter((field) => !syncFieldOrder.includes(field)), + ]; + return ratioTypeFilter + ? ordered.filter((field) => field === ratioTypeFilter) + : ordered; + } + + function deleteResolutionField(newRes, model, ratioType) { + if (!newRes[model]) return; + delete newRes[model][ratioType]; + if (ratioType === 'billing_expr') { + delete newRes[model].billing_mode; + } + if (ratioType === 'billing_mode') { + delete newRes[model].billing_expr; + } + if (Object.keys(newRes[model]).length === 0) { + delete newRes[model]; + } + } + function getBillingCategory(ratioType) { - return ratioType === 'model_price' ? 'price' : 'ratio'; + if (ratioType === 'model_price') return 'price'; + if (ratioType === 'billing_mode' || ratioType === 'billing_expr') { + return 'tiered'; + } + return 'ratio'; + } + + function optionKeyBySyncField(ratioType) { + const explicit = { + billing_mode: 'billing_setting.billing_mode', + billing_expr: 'billing_setting.billing_expr', + }; + if (explicit[ratioType]) return explicit[ratioType]; + return ratioType + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); + } + + function getUpstreamValue(model, ratioType, sourceName) { + return differences[model]?.[ratioType]?.upstreams?.[sourceName]; + } + + function isSelectableUpstreamValue(value) { + return value !== null && value !== undefined && value !== 'same'; + } + + function getPreferredSyncField(model, ratioType, sourceName) { + const exprValue = getUpstreamValue(model, 'billing_expr', sourceName); + if (ratioType !== 'billing_expr' && isSelectableUpstreamValue(exprValue)) { + return 'billing_expr'; + } + return ratioType; + } + + function shouldShowSyncField(model, ratioType, sourceName) { + if (!sourceName) return true; + return getPreferredSyncField(model, ratioType, sourceName) === ratioType; } const selectValue = useCallback( - (model, ratioType, value) => { + (model, ratioType, value, sourceName) => { + const preferredRatioType = sourceName + ? getPreferredSyncField(model, ratioType, sourceName) + : ratioType; + const preferredValue = + preferredRatioType === ratioType + ? value + : getUpstreamValue(model, preferredRatioType, sourceName); + ratioType = preferredRatioType; + value = preferredValue; + const category = getBillingCategory(ratioType); setResolutions((prev) => { const newModelRes = { ...(prev[model] || {}) }; Object.keys(newModelRes).forEach((rt) => { - if (getBillingCategory(rt) !== category) { + if ( + category !== 'tiered' && + getBillingCategory(rt) !== 'tiered' && + getBillingCategory(rt) !== category + ) { delete newModelRes[rt]; } }); newModelRes[ratioType] = value; + if (category === 'tiered' && sourceName) { + const modeValue = + differences[model]?.billing_mode?.upstreams?.[sourceName]; + const exprValue = + differences[model]?.billing_expr?.upstreams?.[sourceName]; + if ( + modeValue !== undefined && + modeValue !== null && + modeValue !== 'same' + ) { + newModelRes.billing_mode = modeValue; + } else if (ratioType === 'billing_expr') { + newModelRes.billing_mode = 'tiered_expr'; + } + if ( + exprValue !== undefined && + exprValue !== null && + exprValue !== 'same' + ) { + newModelRes.billing_expr = exprValue; + } + } + return { ...prev, [model]: newModelRes, }; }); }, - [setResolutions], + [setResolutions, differences], ); const applySync = async () => { @@ -293,7 +427,19 @@ export default function UpstreamRatioSync(props) { ModelRatio: JSON.parse(props.options.ModelRatio || '{}'), CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'), CacheRatio: JSON.parse(props.options.CacheRatio || '{}'), + CreateCacheRatio: JSON.parse(props.options.CreateCacheRatio || '{}'), + ImageRatio: JSON.parse(props.options.ImageRatio || '{}'), + AudioRatio: JSON.parse(props.options.AudioRatio || '{}'), + AudioCompletionRatio: JSON.parse( + props.options.AudioCompletionRatio || '{}', + ), ModelPrice: JSON.parse(props.options.ModelPrice || '{}'), + 'billing_setting.billing_mode': JSON.parse( + props.options['billing_setting.billing_mode'] || '{}', + ), + 'billing_setting.billing_expr': JSON.parse( + props.options['billing_setting.billing_expr'] || '{}', + ), }; const conflicts = []; @@ -303,7 +449,11 @@ export default function UpstreamRatioSync(props) { if ( currentRatios.ModelRatio[model] !== undefined || currentRatios.CompletionRatio[model] !== undefined || - currentRatios.CacheRatio[model] !== undefined + currentRatios.CacheRatio[model] !== undefined || + currentRatios.CreateCacheRatio[model] !== undefined || + currentRatios.ImageRatio[model] !== undefined || + currentRatios.AudioRatio[model] !== undefined || + currentRatios.AudioCompletionRatio[model] !== undefined ) return 'ratio'; return null; @@ -320,9 +470,14 @@ export default function UpstreamRatioSync(props) { Object.entries(resolutions).forEach(([model, ratios]) => { const localCat = getLocalBillingCategory(model); - const newCat = 'model_price' in ratios ? 'price' : 'ratio'; + const newCat = + 'model_price' in ratios + ? 'price' + : ratioSyncFields.some((rt) => rt in ratios) + ? 'ratio' + : 'tiered'; - if (localCat && localCat !== newCat) { + if (localCat && newCat !== 'tiered' && localCat !== newCat) { const currentDesc = localCat === 'price' ? `${t('固定价格')} : ${currentRatios.ModelPrice[model]}` @@ -366,33 +521,50 @@ export default function UpstreamRatioSync(props) { ModelRatio: { ...currentRatios.ModelRatio }, CompletionRatio: { ...currentRatios.CompletionRatio }, CacheRatio: { ...currentRatios.CacheRatio }, + CreateCacheRatio: { ...currentRatios.CreateCacheRatio }, + ImageRatio: { ...currentRatios.ImageRatio }, + AudioRatio: { ...currentRatios.AudioRatio }, + AudioCompletionRatio: { ...currentRatios.AudioCompletionRatio }, ModelPrice: { ...currentRatios.ModelPrice }, + 'billing_setting.billing_mode': { + ...currentRatios['billing_setting.billing_mode'], + }, + 'billing_setting.billing_expr': { + ...currentRatios['billing_setting.billing_expr'], + }, }; Object.entries(resolutions).forEach(([model, ratios]) => { const selectedTypes = Object.keys(ratios); const hasPrice = selectedTypes.includes('model_price'); - const hasRatio = selectedTypes.some((rt) => rt !== 'model_price'); + const hasRatio = selectedTypes.some((rt) => + ratioSyncFields.includes(rt), + ); if (hasPrice) { delete finalRatios.ModelRatio[model]; delete finalRatios.CompletionRatio[model]; delete finalRatios.CacheRatio[model]; + delete finalRatios.CreateCacheRatio[model]; + delete finalRatios.ImageRatio[model]; + delete finalRatios.AudioRatio[model]; + delete finalRatios.AudioCompletionRatio[model]; } if (hasRatio) { delete finalRatios.ModelPrice[model]; } Object.entries(ratios).forEach(([ratioType, value]) => { - const optionKey = ratioType - .split('_') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(''); - finalRatios[optionKey][model] = parseFloat(value); + const optionKey = optionKeyBySyncField(ratioType); + finalRatios[optionKey][model] = numericSyncFields.has(ratioType) + ? parseFloat(value) + : value; }); }); setLoading(true); + showInfo(t('正在同步价格,请稍候')); + let success = false; try { const updates = Object.entries(finalRatios).map(([key, value]) => API.put('/api/option/', { @@ -426,6 +598,7 @@ export default function UpstreamRatioSync(props) { }); setResolutions({}); + success = true; } else { showError(t('部分保存失败')); } @@ -434,6 +607,7 @@ export default function UpstreamRatioSync(props) { } finally { setLoading(false); } + return success; }, [resolutions, props.options, props.refresh], ); @@ -451,6 +625,7 @@ export default function UpstreamRatioSync(props) {