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 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 = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -134,6 +248,10 @@ export function ModelMutateDrawer({
|
||||
const [promptPrice, setPromptPrice] = useState('')
|
||||
const [completionPrice, setCompletionPrice] = useState('')
|
||||
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
|
||||
const { data: vendorsData } = useQuery({
|
||||
@@ -285,8 +403,13 @@ export function ModelMutateDrawer({
|
||||
const model = modelData.data
|
||||
setOldModelName(model.model_name)
|
||||
|
||||
// Base model data reset
|
||||
const baseModelData = {
|
||||
const pricing = readPricingConfig(modelSettings, model.model_name)
|
||||
setLoadedPricingName(model.model_name)
|
||||
setPricingMode(pricing.mode)
|
||||
setPromptPrice(pricing.promptPrice)
|
||||
setCompletionPrice(pricing.completionPrice)
|
||||
setAdvancedOpen(pricing.advancedOpen)
|
||||
form.reset({
|
||||
id: model.id,
|
||||
model_name: model.model_name,
|
||||
description: model.description || '',
|
||||
@@ -297,102 +420,23 @@ export function ModelMutateDrawer({
|
||||
name_rule: model.name_rule || 0,
|
||||
status: model.status === 1,
|
||||
sync_official: model.sync_official === 1,
|
||||
price: '',
|
||||
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)
|
||||
}
|
||||
...pricing.fields,
|
||||
})
|
||||
} 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('')
|
||||
setPricingMode('per-token')
|
||||
setLoadedPricingName(modelName)
|
||||
setPricingSubMode('ratio')
|
||||
setPromptPrice('')
|
||||
setCompletionPrice('')
|
||||
setAdvancedOpen(false)
|
||||
setPricingMode(pricing.mode)
|
||||
setPromptPrice(pricing.promptPrice)
|
||||
setCompletionPrice(pricing.completionPrice)
|
||||
setAdvancedOpen(pricing.advancedOpen)
|
||||
form.reset({
|
||||
model_name: currentRow?.model_name || '',
|
||||
model_name: modelName,
|
||||
description: '',
|
||||
icon: '',
|
||||
tags: [],
|
||||
@@ -401,13 +445,7 @@ export function ModelMutateDrawer({
|
||||
name_rule: 0,
|
||||
status: true,
|
||||
sync_official: true,
|
||||
price: '',
|
||||
ratio: '',
|
||||
cacheRatio: '',
|
||||
completionRatio: '',
|
||||
imageRatio: '',
|
||||
audioRatio: '',
|
||||
audioCompletionRatio: '',
|
||||
...pricing.fields,
|
||||
})
|
||||
}
|
||||
}, [open, isEditing, modelData, currentRow, form, modelSettings])
|
||||
@@ -500,15 +538,24 @@ export function ModelMutateDrawer({
|
||||
delete audioCompletionMap[oldModelName]
|
||||
}
|
||||
|
||||
// Remove current model name from all maps first (always, to handle mode switches or clearing)
|
||||
// This ensures stale entries are removed even when user clears all fields
|
||||
delete priceMap[finalModelName]
|
||||
delete ratioMap[finalModelName]
|
||||
delete cacheMap[finalModelName]
|
||||
delete completionMap[finalModelName]
|
||||
delete imageMap[finalModelName]
|
||||
delete audioMap[finalModelName]
|
||||
delete audioCompletionMap[finalModelName]
|
||||
// Rebuild this model name's entries from the form, but only when
|
||||
// the form speaks for that name: it loaded the name's pricing when
|
||||
// the drawer opened, so clearing every field means "remove
|
||||
// pricing", or the user typed pricing in, which then wins outright
|
||||
// (this is also what replaces the old entries across a mode
|
||||
// switch). A name the form never loaded may still have pricing
|
||||
// configured elsewhere, and an untouched pricing section must not
|
||||
// wipe it -- that covers creating a model over an existing name,
|
||||
// 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
|
||||
if (hasRatioConfig) {
|
||||
@@ -647,6 +694,7 @@ export function ModelMutateDrawer({
|
||||
onOpenChange,
|
||||
pricingMode,
|
||||
oldModelName,
|
||||
loadedPricingName,
|
||||
modelSettings,
|
||||
updateOption,
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user