mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-13 15:54:34 +00:00
fix: prevent model create from wiping existing pricing for same name (#6365)
* fix: prevent model create from wiping existing pricing for same name * fix(models): keep create from wiping pricing for any existing name Prefilling from the drawer's open-time model name only covered the missing-models entry point. Submit deletes the pricing entries for the name in the form, which is editable and starts empty from the toolbar "Create model" button, so both a hand-typed existing name and a renamed prefill still dropped the configured pricing. Track the name whose pricing was actually read into the form and scope the delete-then-readd to it, or to a name the user explicitly priced. Editing still clears pricing by emptying the fields, a prefilled create does too, and a name the form never loaded is left alone -- which also stops an edit that renames onto an existing name from destroying that name's pricing. Read the prefill through one shared readPricingConfig instead of duplicating the seven-map parse in both branches, and open the advanced section for ratios that are configured as 0 rather than only truthy ones.
This commit is contained in:
@@ -112,6 +112,120 @@ type ExtendedModelFormValues = z.infer<typeof extendedModelFormSchema>
|
|||||||
type PricingMode = 'per-token' | 'per-request'
|
type PricingMode = 'per-token' | 'per-request'
|
||||||
type PricingSubMode = 'ratio' | 'price'
|
type PricingSubMode = 'ratio' | 'price'
|
||||||
|
|
||||||
|
type PricingFields = Pick<
|
||||||
|
ExtendedModelFormValues,
|
||||||
|
| 'price'
|
||||||
|
| 'ratio'
|
||||||
|
| 'cacheRatio'
|
||||||
|
| 'completionRatio'
|
||||||
|
| 'imageRatio'
|
||||||
|
| 'audioRatio'
|
||||||
|
| 'audioCompletionRatio'
|
||||||
|
>
|
||||||
|
|
||||||
|
// Form state describing the pricing currently configured for one model name.
|
||||||
|
type PricingConfig = {
|
||||||
|
mode: PricingMode
|
||||||
|
fields: PricingFields
|
||||||
|
promptPrice: string
|
||||||
|
completionPrice: string
|
||||||
|
advancedOpen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_PRICING_FIELDS: PricingFields = {
|
||||||
|
price: '',
|
||||||
|
ratio: '',
|
||||||
|
cacheRatio: '',
|
||||||
|
completionRatio: '',
|
||||||
|
imageRatio: '',
|
||||||
|
audioRatio: '',
|
||||||
|
audioCompletionRatio: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_PRICING_CONFIG: PricingConfig = {
|
||||||
|
mode: 'per-token',
|
||||||
|
fields: EMPTY_PRICING_FIELDS,
|
||||||
|
promptPrice: '',
|
||||||
|
completionPrice: '',
|
||||||
|
advancedOpen: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupModelRatio(
|
||||||
|
rawMap: string,
|
||||||
|
modelName: string
|
||||||
|
): number | undefined {
|
||||||
|
return safeJsonParse<Record<string, number>>(rawMap, {
|
||||||
|
fallback: {},
|
||||||
|
silent: true,
|
||||||
|
})[modelName]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pricing is not stored on the model row: it lives in system options as
|
||||||
|
// model-name keyed JSON maps, so it has to be read back out of those maps to
|
||||||
|
// populate the form. Both create and edit rely on this, because submit rebuilds
|
||||||
|
// the maps from the form and would otherwise drop pricing it never loaded.
|
||||||
|
function readPricingConfig(
|
||||||
|
settings: ModelSettings | null,
|
||||||
|
modelName: string
|
||||||
|
): PricingConfig {
|
||||||
|
if (!settings || !modelName) return EMPTY_PRICING_CONFIG
|
||||||
|
|
||||||
|
const price = lookupModelRatio(settings.ModelPrice, modelName)
|
||||||
|
const ratio = lookupModelRatio(settings.ModelRatio, modelName)
|
||||||
|
const cacheRatio = lookupModelRatio(settings.CacheRatio, modelName)
|
||||||
|
const completionRatio = lookupModelRatio(settings.CompletionRatio, modelName)
|
||||||
|
const imageRatio = lookupModelRatio(settings.ImageRatio, modelName)
|
||||||
|
const audioRatio = lookupModelRatio(settings.AudioRatio, modelName)
|
||||||
|
const audioCompletionRatio = lookupModelRatio(
|
||||||
|
settings.AudioCompletionRatio,
|
||||||
|
modelName
|
||||||
|
)
|
||||||
|
|
||||||
|
// A fixed per-request price wins outright at billing time (see
|
||||||
|
// GetModelRatioOrPrice), so a name that has one is shown, and saved back, as
|
||||||
|
// price-only: the ratios alongside it are dead weight.
|
||||||
|
if (price !== undefined && price !== null) {
|
||||||
|
return {
|
||||||
|
...EMPTY_PRICING_CONFIG,
|
||||||
|
mode: 'per-request',
|
||||||
|
fields: { ...EMPTY_PRICING_FIELDS, price: price.toString() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let promptPrice = ''
|
||||||
|
let completionPrice = ''
|
||||||
|
if (ratio !== undefined && ratio !== null) {
|
||||||
|
const tokenPrice = ratio * 2
|
||||||
|
promptPrice = tokenPrice.toString()
|
||||||
|
if (completionRatio !== undefined && completionRatio !== null) {
|
||||||
|
completionPrice = (tokenPrice * completionRatio).toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'per-token',
|
||||||
|
fields: {
|
||||||
|
price: '',
|
||||||
|
ratio: ratio?.toString() || '',
|
||||||
|
cacheRatio: cacheRatio?.toString() || '',
|
||||||
|
completionRatio: completionRatio?.toString() || '',
|
||||||
|
imageRatio: imageRatio?.toString() || '',
|
||||||
|
audioRatio: audioRatio?.toString() || '',
|
||||||
|
audioCompletionRatio: audioCompletionRatio?.toString() || '',
|
||||||
|
},
|
||||||
|
promptPrice,
|
||||||
|
completionPrice,
|
||||||
|
// Configured is not the same as non-zero: a 0 ratio (free cache reads, for
|
||||||
|
// instance) still has to be visible rather than hidden behind the collapse.
|
||||||
|
advancedOpen: [
|
||||||
|
cacheRatio,
|
||||||
|
imageRatio,
|
||||||
|
audioRatio,
|
||||||
|
audioCompletionRatio,
|
||||||
|
].some((value) => value !== undefined && value !== null),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ModelMutateDrawerProps = {
|
type ModelMutateDrawerProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
@@ -134,6 +248,10 @@ export function ModelMutateDrawer({
|
|||||||
const [promptPrice, setPromptPrice] = useState('')
|
const [promptPrice, setPromptPrice] = useState('')
|
||||||
const [completionPrice, setCompletionPrice] = useState('')
|
const [completionPrice, setCompletionPrice] = useState('')
|
||||||
const [oldModelName, setOldModelName] = useState<string>('')
|
const [oldModelName, setOldModelName] = useState<string>('')
|
||||||
|
// Model name whose pricing was read into the form when the drawer opened.
|
||||||
|
// Submit may only rewrite pricing for this name, or for a name the user
|
||||||
|
// explicitly priced; anything else it never saw and must leave alone.
|
||||||
|
const [loadedPricingName, setLoadedPricingName] = useState<string>('')
|
||||||
|
|
||||||
// Fetch vendors for dropdown
|
// Fetch vendors for dropdown
|
||||||
const { data: vendorsData } = useQuery({
|
const { data: vendorsData } = useQuery({
|
||||||
@@ -285,8 +403,13 @@ export function ModelMutateDrawer({
|
|||||||
const model = modelData.data
|
const model = modelData.data
|
||||||
setOldModelName(model.model_name)
|
setOldModelName(model.model_name)
|
||||||
|
|
||||||
// Base model data reset
|
const pricing = readPricingConfig(modelSettings, model.model_name)
|
||||||
const baseModelData = {
|
setLoadedPricingName(model.model_name)
|
||||||
|
setPricingMode(pricing.mode)
|
||||||
|
setPromptPrice(pricing.promptPrice)
|
||||||
|
setCompletionPrice(pricing.completionPrice)
|
||||||
|
setAdvancedOpen(pricing.advancedOpen)
|
||||||
|
form.reset({
|
||||||
id: model.id,
|
id: model.id,
|
||||||
model_name: model.model_name,
|
model_name: model.model_name,
|
||||||
description: model.description || '',
|
description: model.description || '',
|
||||||
@@ -297,102 +420,23 @@ export function ModelMutateDrawer({
|
|||||||
name_rule: model.name_rule || 0,
|
name_rule: model.name_rule || 0,
|
||||||
status: model.status === 1,
|
status: model.status === 1,
|
||||||
sync_official: model.sync_official === 1,
|
sync_official: model.sync_official === 1,
|
||||||
price: '',
|
...pricing.fields,
|
||||||
ratio: '',
|
})
|
||||||
cacheRatio: '',
|
|
||||||
completionRatio: '',
|
|
||||||
imageRatio: '',
|
|
||||||
audioRatio: '',
|
|
||||||
audioCompletionRatio: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse ratio configurations from system settings if available
|
|
||||||
if (modelSettings) {
|
|
||||||
const priceMap = safeJsonParse<Record<string, number>>(
|
|
||||||
modelSettings.ModelPrice,
|
|
||||||
{ fallback: {}, silent: true }
|
|
||||||
)
|
|
||||||
const ratioMap = safeJsonParse<Record<string, number>>(
|
|
||||||
modelSettings.ModelRatio,
|
|
||||||
{ fallback: {}, silent: true }
|
|
||||||
)
|
|
||||||
const cacheMap = safeJsonParse<Record<string, number>>(
|
|
||||||
modelSettings.CacheRatio,
|
|
||||||
{ fallback: {}, silent: true }
|
|
||||||
)
|
|
||||||
const completionMap = safeJsonParse<Record<string, number>>(
|
|
||||||
modelSettings.CompletionRatio,
|
|
||||||
{ fallback: {}, silent: true }
|
|
||||||
)
|
|
||||||
const imageMap = safeJsonParse<Record<string, number>>(
|
|
||||||
modelSettings.ImageRatio,
|
|
||||||
{ fallback: {}, silent: true }
|
|
||||||
)
|
|
||||||
const audioMap = safeJsonParse<Record<string, number>>(
|
|
||||||
modelSettings.AudioRatio,
|
|
||||||
{ fallback: {}, silent: true }
|
|
||||||
)
|
|
||||||
const audioCompletionMap = safeJsonParse<Record<string, number>>(
|
|
||||||
modelSettings.AudioCompletionRatio,
|
|
||||||
{ fallback: {}, silent: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Extract ratio config for this model
|
|
||||||
const modelName = model.model_name
|
|
||||||
const price = priceMap[modelName]
|
|
||||||
const ratio = ratioMap[modelName]
|
|
||||||
const cacheRatio = cacheMap[modelName]
|
|
||||||
const completionRatio = completionMap[modelName]
|
|
||||||
const imageRatio = imageMap[modelName]
|
|
||||||
const audioRatio = audioMap[modelName]
|
|
||||||
const audioCompletionRatio = audioCompletionMap[modelName]
|
|
||||||
|
|
||||||
// Determine pricing mode
|
|
||||||
if (price !== undefined && price !== null) {
|
|
||||||
setPricingMode('per-request')
|
|
||||||
form.reset({
|
|
||||||
...baseModelData,
|
|
||||||
price: price.toString(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
setPricingMode('per-token')
|
|
||||||
if (ratio !== undefined && ratio !== null) {
|
|
||||||
const tokenPrice = ratio * 2
|
|
||||||
setPromptPrice(tokenPrice.toString())
|
|
||||||
if (completionRatio !== undefined && completionRatio !== null) {
|
|
||||||
const compPrice = tokenPrice * completionRatio
|
|
||||||
setCompletionPrice(compPrice.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
form.reset({
|
|
||||||
...baseModelData,
|
|
||||||
ratio: ratio?.toString() || '',
|
|
||||||
cacheRatio: cacheRatio?.toString() || '',
|
|
||||||
completionRatio: completionRatio?.toString() || '',
|
|
||||||
imageRatio: imageRatio?.toString() || '',
|
|
||||||
audioRatio: audioRatio?.toString() || '',
|
|
||||||
audioCompletionRatio: audioCompletionRatio?.toString() || '',
|
|
||||||
})
|
|
||||||
setAdvancedOpen(
|
|
||||||
!!(cacheRatio || imageRatio || audioRatio || audioCompletionRatio)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If system settings not loaded yet, just load base model data
|
|
||||||
setPricingMode('per-token')
|
|
||||||
form.reset(baseModelData)
|
|
||||||
setAdvancedOpen(false)
|
|
||||||
}
|
|
||||||
} else if (open && !isEditing) {
|
} else if (open && !isEditing) {
|
||||||
// Pre-fill model name if passed from missing models
|
// Pre-fill model name if passed from missing models, along with any
|
||||||
|
// pricing that name already has, so the user edits it instead of being
|
||||||
|
// shown an empty form that hides existing configuration.
|
||||||
|
const modelName = currentRow?.model_name || ''
|
||||||
|
const pricing = readPricingConfig(modelSettings, modelName)
|
||||||
setOldModelName('')
|
setOldModelName('')
|
||||||
setPricingMode('per-token')
|
setLoadedPricingName(modelName)
|
||||||
setPricingSubMode('ratio')
|
setPricingSubMode('ratio')
|
||||||
setPromptPrice('')
|
setPricingMode(pricing.mode)
|
||||||
setCompletionPrice('')
|
setPromptPrice(pricing.promptPrice)
|
||||||
setAdvancedOpen(false)
|
setCompletionPrice(pricing.completionPrice)
|
||||||
|
setAdvancedOpen(pricing.advancedOpen)
|
||||||
form.reset({
|
form.reset({
|
||||||
model_name: currentRow?.model_name || '',
|
model_name: modelName,
|
||||||
description: '',
|
description: '',
|
||||||
icon: '',
|
icon: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -401,13 +445,7 @@ export function ModelMutateDrawer({
|
|||||||
name_rule: 0,
|
name_rule: 0,
|
||||||
status: true,
|
status: true,
|
||||||
sync_official: true,
|
sync_official: true,
|
||||||
price: '',
|
...pricing.fields,
|
||||||
ratio: '',
|
|
||||||
cacheRatio: '',
|
|
||||||
completionRatio: '',
|
|
||||||
imageRatio: '',
|
|
||||||
audioRatio: '',
|
|
||||||
audioCompletionRatio: '',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [open, isEditing, modelData, currentRow, form, modelSettings])
|
}, [open, isEditing, modelData, currentRow, form, modelSettings])
|
||||||
@@ -500,15 +538,24 @@ export function ModelMutateDrawer({
|
|||||||
delete audioCompletionMap[oldModelName]
|
delete audioCompletionMap[oldModelName]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove current model name from all maps first (always, to handle mode switches or clearing)
|
// Rebuild this model name's entries from the form, but only when
|
||||||
// This ensures stale entries are removed even when user clears all fields
|
// the form speaks for that name: it loaded the name's pricing when
|
||||||
delete priceMap[finalModelName]
|
// the drawer opened, so clearing every field means "remove
|
||||||
delete ratioMap[finalModelName]
|
// pricing", or the user typed pricing in, which then wins outright
|
||||||
delete cacheMap[finalModelName]
|
// (this is also what replaces the old entries across a mode
|
||||||
delete completionMap[finalModelName]
|
// switch). A name the form never loaded may still have pricing
|
||||||
delete imageMap[finalModelName]
|
// configured elsewhere, and an untouched pricing section must not
|
||||||
delete audioMap[finalModelName]
|
// wipe it -- that covers creating a model over an existing name,
|
||||||
delete audioCompletionMap[finalModelName]
|
// and renaming onto one.
|
||||||
|
if (hasRatioConfig || finalModelName === loadedPricingName) {
|
||||||
|
delete priceMap[finalModelName]
|
||||||
|
delete ratioMap[finalModelName]
|
||||||
|
delete cacheMap[finalModelName]
|
||||||
|
delete completionMap[finalModelName]
|
||||||
|
delete imageMap[finalModelName]
|
||||||
|
delete audioMap[finalModelName]
|
||||||
|
delete audioCompletionMap[finalModelName]
|
||||||
|
}
|
||||||
|
|
||||||
// Only add new entries if user provided new configuration
|
// Only add new entries if user provided new configuration
|
||||||
if (hasRatioConfig) {
|
if (hasRatioConfig) {
|
||||||
@@ -647,6 +694,7 @@ export function ModelMutateDrawer({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
pricingMode,
|
pricingMode,
|
||||||
oldModelName,
|
oldModelName,
|
||||||
|
loadedPricingName,
|
||||||
modelSettings,
|
modelSettings,
|
||||||
updateOption,
|
updateOption,
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user