fix(model-pricing): commit visual pricing drafts on save

- Commit the open visual editor draft before saving model pricing settings
- Show unsaved draft differences against persisted model pricing values
- Move model pricing actions into the editor toolbar and refine the visual editor layout
This commit is contained in:
QuentinHsu
2026-06-04 17:22:50 +08:00
parent 39e05118ff
commit 77d3157592
10 changed files with 561 additions and 473 deletions
@@ -27,17 +27,10 @@ import {
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { AlertTriangle, ChevronDown } from 'lucide-react'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Field,
FieldDescription,
@@ -63,15 +56,11 @@ import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
sideDrawerContentClassName,
sideDrawerFooterClassName,
} from '@/components/drawer-layout'
import { sideDrawerContentClassName } from '@/components/drawer-layout'
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
import {
SettingsControlGroup,
@@ -124,10 +113,7 @@ export type ModelRatioData = {
type ModelPricingSheetProps = {
open: boolean
onOpenChange: (open: boolean) => void
onSave: (data: ModelRatioData) => void
onCancel?: () => void
editData?: ModelRatioData | null
selectedTargetCount?: number
}
type ModelPricingEditorPanelProps = Omit<
@@ -284,20 +270,6 @@ function createInitialLaneState(data?: ModelRatioData | null) {
}
}
function getModeLabel(mode: PricingMode) {
if (mode === 'per-request') return 'Per-request'
if (mode === 'tiered_expr') return 'Expression'
return 'Per-token'
}
function getModeBadgeVariant(
mode: PricingMode
): 'default' | 'secondary' | 'outline' {
if (mode === 'per-request') return 'secondary'
if (mode === 'tiered_expr') return 'default'
return 'outline'
}
function buildPreviewRows(
values: ModelPricingFormValues,
mode: PricingMode,
@@ -391,10 +363,7 @@ function buildPreviewRows(
export const ModelPricingSheet = forwardRef<
ModelPricingEditorPanelHandle,
ModelPricingSheetProps
>(function ModelPricingSheet(
{ open, onOpenChange, onSave, onCancel, editData, selectedTargetCount = 0 },
ref
) {
>(function ModelPricingSheet({ open, onOpenChange, editData }, ref) {
const { t } = useTranslation()
const title = editData ? t('Edit model pricing') : t('Add model pricing')
const description = editData?.name || t('New model')
@@ -411,13 +380,7 @@ export const ModelPricingSheet = forwardRef<
</SheetHeader>
<ModelPricingEditorPanel
ref={ref}
onSave={onSave}
editData={editData}
selectedTargetCount={selectedTargetCount}
onCancel={() => {
onCancel?.()
onOpenChange(false)
}}
className='h-full rounded-none border-0'
/>
</SheetContent>
@@ -428,10 +391,7 @@ export const ModelPricingSheet = forwardRef<
export const ModelPricingEditorPanel = forwardRef<
ModelPricingEditorPanelHandle,
ModelPricingEditorPanelProps
>(function ModelPricingEditorPanel(
{ onSave, editData, selectedTargetCount = 0, onCancel, className },
ref
) {
>(function ModelPricingEditorPanel({ editData, className }, ref) {
const { t } = useTranslation()
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
const [promptPrice, setPromptPrice] = useState('')
@@ -443,7 +403,6 @@ export const ModelPricingEditorPanel = forwardRef<
})
const [billingExpr, setBillingExpr] = useState('')
const [requestRuleExpr, setRequestRuleExpr] = useState('')
const [previewOpen, setPreviewOpen] = useState(true)
const isEditMode = !!editData
const form = useForm<ModelPricingFormValues>({
@@ -505,7 +464,6 @@ export const ModelPricingEditorPanel = forwardRef<
setPromptPrice(nextLaneState.promptPrice)
setLanePrices(nextLaneState.prices)
setLaneEnabled(nextLaneState.enabled)
setPreviewOpen(true)
}, [editData, form])
const setFormValue = (field: keyof ModelPricingFormValues, value: string) => {
@@ -763,15 +721,6 @@ export const ModelPricingEditorPanel = forwardRef<
[form, validatePricingValues, buildSubmitData]
)
const handleSubmit = (values: ModelPricingFormValues) => {
if (!validatePricingValues()) return
const data = buildSubmitData(values)
onSave(data)
form.reset()
onCancel?.()
}
const activeName = watchedValues.name || editData?.name || t('New model')
return (
@@ -791,232 +740,197 @@ export const ModelPricingEditorPanel = forwardRef<
{activeName}
</p>
</div>
<Badge variant={getModeBadgeVariant(pricingMode)}>
{t(getModeLabel(pricingMode))}
</Badge>
</div>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
onSubmit={(event) => event.preventDefault()}
className='flex min-h-0 flex-1 flex-col'
autoComplete='off'
>
<div className='min-h-0 flex-1 overflow-y-auto p-4'>
<FieldGroup>
{warnings.length > 0 && (
<Alert variant='destructive'>
<AlertTriangle data-icon='inline-start' />
<AlertDescription>
<div className='flex flex-col gap-1'>
{warnings.map((warning) => (
<span key={warning}>{warning}</span>
))}
</div>
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model name')}</FormLabel>
<FormControl>
<Input
placeholder={t('gpt-4')}
{...field}
disabled={isEditMode}
/>
</FormControl>
<FormDescription>
{t('The exact model identifier as used in API requests.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Tabs
value={pricingMode}
onValueChange={handleModeChange}
className='gap-4'
>
<TabsList className='grid w-full grid-cols-3'>
<TabsTrigger value='per-token'>{t('Per-token')}</TabsTrigger>
<TabsTrigger value='per-request'>
{t('Per-request')}
</TabsTrigger>
<TabsTrigger value='tiered_expr'>
{t('Expression')}
</TabsTrigger>
</TabsList>
<TabsContent value='per-token' className='pt-0'>
<FieldGroup className='gap-5'>
<Field>
<FieldLabel>{t('Input price')}</FieldLabel>
<PriceInput
value={promptPrice}
placeholder='3'
onChange={handlePromptPriceChange}
/>
<FieldDescription>
{t('USD price per 1M input tokens.')}
</FieldDescription>
</Field>
<div className='grid gap-3 sm:grid-cols-2'>
{laneConfigs.map((lane) => {
const disabled =
lane.key === 'audioOutput' &&
(!laneEnabled.audioInput ||
!hasValue(lanePrices.audioInput))
return (
<PriceLane
key={lane.key}
title={t(lane.titleKey)}
description={t(lane.descriptionKey)}
placeholder={lane.placeholder}
value={lanePrices[lane.key]}
enabled={laneEnabled[lane.key]}
disabled={disabled}
onEnabledChange={(checked) =>
handleLaneToggle(lane.key, checked)
}
onChange={(value) =>
handleLanePriceChange(lane.key, value)
}
/>
)
})}
</div>
</FieldGroup>
</TabsContent>
<TabsContent value='per-request' className='pt-0'>
<FieldGroup className='gap-5'>
<FormField
control={form.control}
name='price'
render={({ field }) => (
<FormItem className='contents'>
<Field>
<FieldLabel>{t('Fixed price')}</FieldLabel>
<FormControl>
<InputGroup>
<InputGroupAddon>$</InputGroupAddon>
<InputGroupInput
inputMode='decimal'
placeholder='0.01'
{...field}
onChange={(event) => {
const value = event.target.value
if (numericDraftRegex.test(value)) {
field.onChange(value)
}
}}
/>
<InputGroupAddon align='inline-end'>
{t('per request')}
</InputGroupAddon>
</InputGroup>
</FormControl>
<FieldDescription>
{t(
'Cost in USD per request, regardless of tokens used.'
)}
</FieldDescription>
<FormMessage />
</Field>
</FormItem>
)}
/>
</FieldGroup>
</TabsContent>
<TabsContent value='tiered_expr' className='pt-0'>
<FieldGroup className='gap-5'>
<TieredPricingEditor
modelName={watchedValues.name}
billingExpr={billingExpr}
requestRuleExpr={requestRuleExpr}
onBillingExprChange={setBillingExpr}
onRequestRuleExprChange={setRequestRuleExpr}
/>
</FieldGroup>
</TabsContent>
</Tabs>
<Collapsible open={previewOpen} onOpenChange={setPreviewOpen}>
<CollapsibleTrigger
render={
<Button
type='button'
variant='outline'
className='flex w-full justify-between'
/>
}
>
<span>{t('Save preview')}</span>
<ChevronDown
className={cn(
'transition-transform',
previewOpen && 'rotate-180'
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className='pt-3'>
<div className='rounded-lg border'>
{previewRows.map((row) => (
<div
key={row.key}
className='grid grid-cols-[140px_1fr] gap-3 border-b px-3 py-2 text-sm last:border-b-0'
>
<span className='text-muted-foreground text-xs'>
{row.label}
</span>
<span
className={cn(
'min-w-0',
row.multiline
? 'font-mono text-xs leading-5 break-words whitespace-pre-wrap'
: 'truncate'
)}
>
{row.value}
</span>
<div className='grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(220px,260px)]'>
<FieldGroup>
{warnings.length > 0 && (
<Alert variant='destructive'>
<AlertTriangle data-icon='inline-start' />
<AlertDescription>
<div className='flex flex-col gap-1'>
{warnings.map((warning) => (
<span key={warning}>{warning}</span>
))}
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
</FieldGroup>
</div>
</AlertDescription>
</Alert>
)}
<SheetFooter
className={sideDrawerFooterClassName(
'grid-cols-1 sm:items-center sm:justify-between'
)}
>
<div className='text-muted-foreground text-xs'>
{selectedTargetCount > 0
? t('{{count}} selected targets available for bulk copy.', {
count: selectedTargetCount,
})
: t('Changes are written to the settings draft on save.')}
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model name')}</FormLabel>
<FormControl>
<Input
placeholder={t('gpt-4')}
{...field}
disabled={isEditMode}
/>
</FormControl>
<FormDescription>
{t(
'The exact model identifier as used in API requests.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Tabs
value={pricingMode}
onValueChange={handleModeChange}
className='gap-4'
>
<TabsList className='grid w-full grid-cols-3'>
<TabsTrigger value='per-token'>
{t('Per-token')}
</TabsTrigger>
<TabsTrigger value='per-request'>
{t('Per-request')}
</TabsTrigger>
<TabsTrigger value='tiered_expr'>
{t('Expression')}
</TabsTrigger>
</TabsList>
<TabsContent value='per-token' className='pt-0'>
<FieldGroup className='gap-5'>
<Field>
<FieldLabel>{t('Input price')}</FieldLabel>
<PriceInput
value={promptPrice}
placeholder='3'
onChange={handlePromptPriceChange}
/>
<FieldDescription>
{t('USD price per 1M input tokens.')}
</FieldDescription>
</Field>
<div className='grid gap-3 sm:grid-cols-[repeat(auto-fit,minmax(400px,1fr))]'>
{laneConfigs.map((lane) => {
const disabled =
lane.key === 'audioOutput' &&
(!laneEnabled.audioInput ||
!hasValue(lanePrices.audioInput))
return (
<PriceLane
key={lane.key}
title={t(lane.titleKey)}
description={t(lane.descriptionKey)}
placeholder={lane.placeholder}
value={lanePrices[lane.key]}
enabled={laneEnabled[lane.key]}
disabled={disabled}
onEnabledChange={(checked) =>
handleLaneToggle(lane.key, checked)
}
onChange={(value) =>
handleLanePriceChange(lane.key, value)
}
/>
)
})}
</div>
</FieldGroup>
</TabsContent>
<TabsContent value='per-request' className='pt-0'>
<FieldGroup className='gap-5'>
<FormField
control={form.control}
name='price'
render={({ field }) => (
<FormItem className='contents'>
<Field>
<FieldLabel>{t('Fixed price')}</FieldLabel>
<FormControl>
<InputGroup>
<InputGroupAddon>$</InputGroupAddon>
<InputGroupInput
inputMode='decimal'
placeholder='0.01'
{...field}
onChange={(event) => {
const value = event.target.value
if (numericDraftRegex.test(value)) {
field.onChange(value)
}
}}
/>
<InputGroupAddon align='inline-end'>
{t('per request')}
</InputGroupAddon>
</InputGroup>
</FormControl>
<FieldDescription>
{t(
'Cost in USD per request, regardless of tokens used.'
)}
</FieldDescription>
<FormMessage />
</Field>
</FormItem>
)}
/>
</FieldGroup>
</TabsContent>
<TabsContent value='tiered_expr' className='pt-0'>
<FieldGroup className='gap-5'>
<TieredPricingEditor
modelName={watchedValues.name}
billingExpr={billingExpr}
requestRuleExpr={requestRuleExpr}
onBillingExprChange={setBillingExpr}
onRequestRuleExprChange={setRequestRuleExpr}
/>
</FieldGroup>
</TabsContent>
</Tabs>
</FieldGroup>
<aside className='bg-muted/20 sticky top-0 rounded-lg border'>
<div className='border-b px-3 py-2'>
<div className='text-sm font-medium'>{t('Preview')}</div>
<div className='text-muted-foreground text-xs'>
{activeName}
</div>
</div>
<div className='divide-y'>
{previewRows.map((row) => (
<div key={row.key} className='grid gap-1 px-3 py-2.5'>
<span className='text-muted-foreground text-xs'>
{row.label}
</span>
<span
className={cn(
'min-w-0 text-sm',
row.multiline
? 'font-mono text-xs leading-5 break-words whitespace-pre-wrap'
: 'truncate'
)}
>
{row.value}
</span>
</div>
))}
</div>
</aside>
</div>
<div className='flex justify-end gap-2'>
<Button type='button' variant='outline' onClick={onCancel}>
{t('Cancel')}
</Button>
<Button type='submit'>
{isEditMode ? t('Update') : t('Add')}
</Button>
</div>
</SheetFooter>
</div>
</form>
</Form>
</div>
@@ -37,7 +37,6 @@ import {
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context'
import {
ModelRatioVisualEditor,
type ModelRatioVisualEditorHandle,
@@ -59,6 +58,7 @@ type ModelFormValues = {
type ModelRatioFormProps = {
form: UseFormReturn<ModelFormValues>
savedValues: ModelFormValues
onSave: (values: ModelFormValues) => Promise<void>
onReset: () => void
isSaving: boolean
@@ -67,6 +67,7 @@ type ModelRatioFormProps = {
export const ModelRatioForm = memo(function ModelRatioForm({
form,
savedValues,
onSave,
onReset,
isSaving,
@@ -101,7 +102,24 @@ export const ModelRatioForm = memo(function ModelRatioForm({
return (
<div className='space-y-6'>
<div className='flex justify-end'>
<div className='flex flex-wrap justify-end gap-2'>
<Button
type='button'
variant='destructive'
size='sm'
onClick={onReset}
disabled={isResetting}
>
{t('Reset prices')}
</Button>
<Button
type='button'
size='sm'
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? t('Saving...') : t('Save model prices')}
</Button>
<Button variant='outline' size='sm' onClick={toggleEditMode}>
{editMode === 'visual' ? (
<>
@@ -118,29 +136,20 @@ export const ModelRatioForm = memo(function ModelRatioForm({
</div>
<Form {...form}>
<SettingsPageActionsPortal>
<Button
type='button'
variant='destructive'
size='sm'
onClick={onReset}
disabled={isResetting}
>
{t('Reset prices')}
</Button>
<Button
type='button'
size='sm'
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? t('Saving...') : t('Save model prices')}
</Button>
</SettingsPageActionsPortal>
{editMode === 'visual' ? (
<div className='space-y-6'>
<ModelRatioVisualEditor
ref={visualEditorRef}
savedModelPrice={savedValues.ModelPrice}
savedModelRatio={savedValues.ModelRatio}
savedCacheRatio={savedValues.CacheRatio}
savedCreateCacheRatio={savedValues.CreateCacheRatio}
savedCompletionRatio={savedValues.CompletionRatio}
savedImageRatio={savedValues.ImageRatio}
savedAudioRatio={savedValues.AudioRatio}
savedAudioCompletionRatio={savedValues.AudioCompletionRatio}
savedBillingMode={savedValues.BillingMode}
savedBillingExpr={savedValues.BillingExpr}
modelPrice={form.watch('ModelPrice')}
modelRatio={form.watch('ModelRatio')}
cacheRatio={form.watch('CacheRatio')}
@@ -49,14 +49,6 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
DataTableBulkActions,
DataTableColumnHeader,
@@ -78,6 +70,16 @@ import {
import { formatPricingNumber } from './pricing-format'
type ModelRatioVisualEditorProps = {
savedModelPrice: string
savedModelRatio: string
savedCacheRatio: string
savedCreateCacheRatio: string
savedCompletionRatio: string
savedImageRatio: string
savedAudioRatio: string
savedAudioCompletionRatio: string
savedBillingMode: string
savedBillingExpr: string
modelPrice: string
modelRatio: string
cacheRatio: string
@@ -91,7 +93,7 @@ type ModelRatioVisualEditorProps = {
onChange: (field: string, value: string) => void
}
type ModelRow = {
type ModelPricingSnapshot = {
name: string
price?: string
ratio?: string
@@ -107,6 +109,14 @@ type ModelRow = {
hasConflict: boolean
}
type ModelRow = ModelPricingSnapshot & {
saved?: ModelPricingSnapshot
draft?: ModelPricingSnapshot
isDraftChanged: boolean
isDraftDeleted: boolean
isDraftNew: boolean
}
export type ModelRatioVisualEditorHandle = {
commitOpenEditor: () => Promise<boolean>
}
@@ -148,7 +158,10 @@ const getModeVariant = (mode?: string): 'warning' | 'info' | 'success' => {
return 'success'
}
const getExpressionSummary = (row: ModelRow, t: (key: string) => string) => {
const getExpressionSummary = (
row: ModelPricingSnapshot,
t: (key: string) => string
) => {
const tierCount = (row.billingExpr?.match(/tier\(/g) || []).length
if (tierCount > 0) {
return `${t('Tiered pricing')} · ${tierCount} ${t('tiers')}`
@@ -156,7 +169,10 @@ const getExpressionSummary = (row: ModelRow, t: (key: string) => string) => {
return t('Expression pricing')
}
const getPriceSummary = (row: ModelRow, t: (key: string) => string) => {
const getPriceSummary = (
row: ModelPricingSnapshot,
t: (key: string) => string
) => {
if (row.billingMode === 'tiered_expr') {
return getExpressionSummary(row, t)
}
@@ -181,7 +197,10 @@ const getPriceSummary = (row: ModelRow, t: (key: string) => string) => {
: `${t('Input')} $${inputPrice}`
}
const getPriceDetail = (row: ModelRow, t: (key: string) => string) => {
const getPriceDetail = (
row: ModelPricingSnapshot,
t: (key: string) => string
) => {
if (row.billingMode === 'tiered_expr') {
return row.requestRuleExpr
? t('Includes request rules')
@@ -206,11 +225,172 @@ const getPriceDetail = (row: ModelRow, t: (key: string) => string) => {
return details.length > 0 ? details.join(' · ') : t('Base input price only')
}
const buildModelSnapshots = ({
modelPrice,
modelRatio,
cacheRatio,
createCacheRatio,
completionRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
billingMode,
billingExpr,
}: Pick<
ModelRatioVisualEditorProps,
| 'modelPrice'
| 'modelRatio'
| 'cacheRatio'
| 'createCacheRatio'
| 'completionRatio'
| 'imageRatio'
| 'audioRatio'
| 'audioCompletionRatio'
| 'billingMode'
| 'billingExpr'
>): ModelPricingSnapshot[] => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
fallback: {},
context: 'model prices',
})
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
fallback: {},
context: 'model ratios',
})
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
fallback: {},
context: 'cache ratios',
})
const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio,
{ fallback: {}, context: 'create cache ratios' }
)
const completionMap = safeJsonParse<Record<string, number>>(completionRatio, {
fallback: {},
context: 'completion ratios',
})
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {},
context: 'image ratios',
})
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {},
context: 'audio ratios',
})
const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio,
{ fallback: {}, context: 'audio completion ratios' }
)
const billingModeMap = safeJsonParse<Record<string, string>>(billingMode, {
fallback: {},
context: 'billing mode',
})
const billingExprMap = safeJsonParse<Record<string, string>>(billingExpr, {
fallback: {},
context: 'billing expression',
})
const modelNames = new Set([
...Object.keys(priceMap),
...Object.keys(ratioMap),
...Object.keys(cacheMap),
...Object.keys(createCacheMap),
...Object.keys(completionMap),
...Object.keys(imageMap),
...Object.keys(audioMap),
...Object.keys(audioCompletionMap),
...Object.keys(billingModeMap),
...Object.keys(billingExprMap),
])
return Array.from(modelNames).map((name) => {
const price = priceMap[name]?.toString() || ''
const ratio = ratioMap[name]?.toString() || ''
const cache = cacheMap[name]?.toString() || ''
const createCache = createCacheMap[name]?.toString() || ''
const completion = completionMap[name]?.toString() || ''
const image = imageMap[name]?.toString() || ''
const audio = audioMap[name]?.toString() || ''
const audioCompletion = audioCompletionMap[name]?.toString() || ''
const modeForModel = billingModeMap[name]
if (modeForModel === 'tiered_expr') {
const fullExpr = billingExprMap[name] || ''
const { billingExpr: pureExpr, requestRuleExpr } =
splitBillingExprAndRequestRules(fullExpr)
return {
name,
billingMode: 'tiered_expr',
billingExpr: pureExpr,
requestRuleExpr,
price,
ratio,
cacheRatio: cache,
createCacheRatio: createCache,
completionRatio: completion,
imageRatio: image,
audioRatio: audio,
audioCompletionRatio: audioCompletion,
hasConflict: false,
}
}
return {
name,
price,
ratio,
cacheRatio: cache,
createCacheRatio: createCache,
completionRatio: completion,
imageRatio: image,
audioRatio: audio,
audioCompletionRatio: audioCompletion,
billingMode: price !== '' ? 'per-request' : 'per-token',
hasConflict:
price !== '' &&
(ratio !== '' ||
completion !== '' ||
cache !== '' ||
createCache !== '' ||
image !== '' ||
audio !== '' ||
audioCompletion !== ''),
}
})
}
const getSnapshotSignature = (snapshot?: ModelPricingSnapshot) => {
if (!snapshot) return ''
return JSON.stringify({
price: snapshot.price || '',
ratio: snapshot.ratio || '',
cacheRatio: snapshot.cacheRatio || '',
createCacheRatio: snapshot.createCacheRatio || '',
completionRatio: snapshot.completionRatio || '',
imageRatio: snapshot.imageRatio || '',
audioRatio: snapshot.audioRatio || '',
audioCompletionRatio: snapshot.audioCompletionRatio || '',
billingMode: snapshot.billingMode || 'per-token',
billingExpr: snapshot.billingExpr || '',
requestRuleExpr: snapshot.requestRuleExpr || '',
})
}
const ModelRatioVisualEditorComponent = forwardRef<
ModelRatioVisualEditorHandle,
ModelRatioVisualEditorProps
>(function ModelRatioVisualEditor(
{
savedModelPrice,
savedModelRatio,
savedCacheRatio,
savedCreateCacheRatio,
savedCompletionRatio,
savedImageRatio,
savedAudioRatio,
savedAudioCompletionRatio,
savedBillingMode,
savedBillingExpr,
modelPrice,
modelRatio,
cacheRatio,
@@ -279,120 +459,64 @@ const ModelRatioVisualEditorComponent = forwardRef<
}, [columnVisibility])
const models = useMemo(() => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
fallback: {},
context: 'model prices',
const savedRows = buildModelSnapshots({
modelPrice: savedModelPrice,
modelRatio: savedModelRatio,
cacheRatio: savedCacheRatio,
createCacheRatio: savedCreateCacheRatio,
completionRatio: savedCompletionRatio,
imageRatio: savedImageRatio,
audioRatio: savedAudioRatio,
audioCompletionRatio: savedAudioCompletionRatio,
billingMode: savedBillingMode,
billingExpr: savedBillingExpr,
})
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
fallback: {},
context: 'model ratios',
})
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
fallback: {},
context: 'cache ratios',
})
const createCacheMap = safeJsonParse<Record<string, number>>(
const draftRows = buildModelSnapshots({
modelPrice,
modelRatio,
cacheRatio,
createCacheRatio,
{ fallback: {}, context: 'create cache ratios' }
)
const completionMap = safeJsonParse<Record<string, number>>(
completionRatio,
{ fallback: {}, context: 'completion ratios' }
)
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {},
context: 'image ratios',
})
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {},
context: 'audio ratios',
})
const audioCompletionMap = safeJsonParse<Record<string, number>>(
imageRatio,
audioRatio,
audioCompletionRatio,
{ fallback: {}, context: 'audio completion ratios' }
)
const billingModeMap = safeJsonParse<Record<string, string>>(billingMode, {
fallback: {},
context: 'billing mode',
})
const billingExprMap = safeJsonParse<Record<string, string>>(billingExpr, {
fallback: {},
context: 'billing expression',
billingMode,
billingExpr,
})
const modelNames = new Set([
...Object.keys(priceMap),
...Object.keys(ratioMap),
...Object.keys(cacheMap),
...Object.keys(createCacheMap),
...Object.keys(completionMap),
...Object.keys(imageMap),
...Object.keys(audioMap),
...Object.keys(audioCompletionMap),
...Object.keys(billingModeMap),
...Object.keys(billingExprMap),
])
const savedByName = new Map(savedRows.map((row) => [row.name, row]))
const draftByName = new Map(draftRows.map((row) => [row.name, row]))
const modelNames = new Set([...savedByName.keys(), ...draftByName.keys()])
const modelData: ModelRow[] = Array.from(modelNames).map((name) => {
const price = priceMap[name]?.toString() || ''
const ratio = ratioMap[name]?.toString() || ''
const cache = cacheMap[name]?.toString() || ''
const createCache = createCacheMap[name]?.toString() || ''
const completion = completionMap[name]?.toString() || ''
const image = imageMap[name]?.toString() || ''
const audio = audioMap[name]?.toString() || ''
const audioCompletion = audioCompletionMap[name]?.toString() || ''
return Array.from(modelNames)
.map((name) => {
const saved = savedByName.get(name)
const draft = draftByName.get(name)
const displayed = saved ?? draft
const savedSignature = getSnapshotSignature(saved)
const draftSignature = getSnapshotSignature(draft)
const modeForModel = billingModeMap[name]
if (modeForModel === 'tiered_expr') {
// Tiered_expr models may also retain ratio/price values as fallback
// during multi-instance sync delays. We preserve them in the row so
// the edit dialog round-trip and the next save don't drop them.
const fullExpr = billingExprMap[name] || ''
const { billingExpr: pureExpr, requestRuleExpr } =
splitBillingExprAndRequestRules(fullExpr)
return {
name,
billingMode: 'tiered_expr',
billingExpr: pureExpr,
requestRuleExpr,
price,
ratio,
cacheRatio: cache,
createCacheRatio: createCache,
completionRatio: completion,
imageRatio: image,
audioRatio: audio,
audioCompletionRatio: audioCompletion,
hasConflict: false,
...displayed!,
saved,
draft,
isDraftChanged: savedSignature !== draftSignature,
isDraftDeleted: Boolean(saved && !draft),
isDraftNew: Boolean(!saved && draft),
}
}
return {
name,
price,
ratio,
cacheRatio: cache,
createCacheRatio: createCache,
completionRatio: completion,
imageRatio: image,
audioRatio: audio,
audioCompletionRatio: audioCompletion,
billingMode: price !== '' ? 'per-request' : 'per-token',
hasConflict:
price !== '' &&
(ratio !== '' ||
completion !== '' ||
cache !== '' ||
createCache !== '' ||
image !== '' ||
audio !== '' ||
audioCompletion !== ''),
}
})
return modelData.sort((a, b) => a.name.localeCompare(b.name))
})
.sort((a, b) => a.name.localeCompare(b.name))
}, [
savedModelPrice,
savedModelRatio,
savedCacheRatio,
savedCreateCacheRatio,
savedCompletionRatio,
savedImageRatio,
savedAudioRatio,
savedAudioCompletionRatio,
savedBillingMode,
savedBillingExpr,
modelPrice,
modelRatio,
cacheRatio,
@@ -428,24 +552,25 @@ const ModelRatioVisualEditorComponent = forwardRef<
const handleEdit = useCallback(
(model: ModelRow) => {
const editableModel = model.draft ?? model.saved ?? model
setEditData({
name: model.name,
price: model.price,
ratio: model.ratio,
cacheRatio: model.cacheRatio,
createCacheRatio: model.createCacheRatio,
completionRatio: model.completionRatio,
imageRatio: model.imageRatio,
audioRatio: model.audioRatio,
audioCompletionRatio: model.audioCompletionRatio,
name: editableModel.name,
price: editableModel.price,
ratio: editableModel.ratio,
cacheRatio: editableModel.cacheRatio,
createCacheRatio: editableModel.createCacheRatio,
completionRatio: editableModel.completionRatio,
imageRatio: editableModel.imageRatio,
audioRatio: editableModel.audioRatio,
audioCompletionRatio: editableModel.audioCompletionRatio,
billingMode:
model.billingMode === 'tiered_expr'
editableModel.billingMode === 'tiered_expr'
? 'tiered_expr'
: model.price && model.price !== ''
: editableModel.price && editableModel.price !== ''
? 'per-request'
: 'per-token',
billingExpr: model.billingExpr,
requestRuleExpr: model.requestRuleExpr,
billingExpr: editableModel.billingExpr,
requestRuleExpr: editableModel.requestRuleExpr,
})
setEditorOpen(true)
if (isMobile) setSheetOpen(true)
@@ -459,12 +584,6 @@ const ModelRatioVisualEditorComponent = forwardRef<
if (isMobile) setSheetOpen(true)
}, [isMobile])
const handleCancel = useCallback(() => {
setEditData(null)
setEditorOpen(false)
setSheetOpen(false)
}, [])
const handleGlobalFilterChange = useCallback<OnChangeFn<string>>(
(updater) => {
setGlobalFilter((previous) => {
@@ -604,6 +723,13 @@ const ModelRatioVisualEditorComponent = forwardRef<
cell: ({ row }) => (
<div className='flex items-center gap-2 font-medium'>
{row.getValue('name')}
{row.original.isDraftChanged && (
<StatusBadge
label={t('Draft')}
variant={row.original.isDraftDeleted ? 'danger' : 'warning'}
copyable={false}
/>
)}
{row.original.billingMode === 'tiered_expr' && (
<StatusBadge
label={t('Tiered')}
@@ -644,13 +770,45 @@ const ModelRatioVisualEditorComponent = forwardRef<
<DataTableColumnHeader column={column} title={t('Price summary')} />
),
cell: ({ row }) => (
<div className='flex min-w-[180px] flex-col gap-1'>
<span className='font-medium'>
{getPriceSummary(row.original, t)}
</span>
<span className='text-muted-foreground max-w-[320px] truncate text-xs'>
{getPriceDetail(row.original, t)}
</span>
<div className='flex min-w-[180px] flex-col gap-2'>
<div className='flex flex-col gap-1'>
<span className='font-medium'>
{getPriceSummary(row.original, t)}
</span>
<span className='text-muted-foreground max-w-[320px] truncate text-xs'>
{getPriceDetail(row.original, t)}
</span>
</div>
{row.original.isDraftChanged && (
<div className='border-warning/45 bg-warning/10 text-foreground flex max-w-[360px] flex-col gap-1 rounded-md border px-2.5 py-2 shadow-sm'>
<div className='flex items-center gap-2'>
<StatusBadge
label={t('Draft')}
variant={row.original.isDraftDeleted ? 'danger' : 'warning'}
copyable={false}
className='bg-background/70'
/>
{!row.original.isDraftDeleted && row.original.draft && (
<StatusBadge
label={t(getModeLabel(row.original.draft.billingMode))}
variant={getModeVariant(row.original.draft.billingMode)}
copyable={false}
className='bg-background/70'
/>
)}
<span className='truncate text-sm font-medium'>
{row.original.isDraftDeleted
? t('Will be removed')
: getPriceSummary(row.original.draft ?? row.original, t)}
</span>
</div>
{!row.original.isDraftDeleted && row.original.draft && (
<span className='text-muted-foreground truncate text-xs'>
{getPriceDetail(row.original.draft, t)}
</span>
)}
</div>
)}
</div>
),
sortingFn: (rowA, rowB) =>
@@ -849,18 +1007,6 @@ const ModelRatioVisualEditorComponent = forwardRef<
]
)
const handleSave = useCallback(
(data: ModelRatioData) => {
persistPricingData(data)
setEditData(data)
setEditorOpen(true)
toast.success(
t('Pricing changes saved to draft. Click "Save model prices" to apply.')
)
},
[persistPricingData, t]
)
const handleBatchCopy = useCallback(() => {
if (!editData) {
toast.error(t('Open a source model first'))
@@ -901,12 +1047,10 @@ const ModelRatioVisualEditorComponent = forwardRef<
[editorOpen, persistPricingData]
)
const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length
return (
<div className='flex flex-col gap-4'>
<div className='grid min-h-0 gap-4 md:grid-cols-[minmax(0,1fr)_minmax(420px,0.82fr)] xl:grid-cols-[minmax(0,1.1fr)_minmax(520px,0.9fr)]'>
<div className='flex min-w-0 flex-col gap-4'>
<div className='grid h-[clamp(720px,calc(100vh-12rem),900px)] min-h-0 gap-4 md:grid-cols-[minmax(300px,0.72fr)_minmax(520px,1.28fr)] xl:grid-cols-[minmax(320px,0.68fr)_minmax(640px,1.32fr)]'>
<div className='flex min-h-0 min-w-0 flex-col gap-3'>
<DataTableToolbar
table={table}
searchPlaceholder={t('Search models...')}
@@ -948,33 +1092,37 @@ const ModelRatioVisualEditorComponent = forwardRef<
: t('No models configured. Use Add model to get started.')}
</div>
) : (
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
<table className='w-full caption-bottom text-sm tabular-nums'>
<thead className='bg-background sticky top-0 z-10'>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<tr key={headerGroup.id} className='border-b'>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} colSpan={header.colSpan}>
<th
key={header.id}
colSpan={header.colSpan}
className='text-foreground h-10 px-2 text-left align-middle text-sm font-medium whitespace-nowrap'
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
</th>
))}
</TableRow>
</tr>
))}
</TableHeader>
<TableBody>
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<TableRow
<tr
key={row.id}
data-state={row.getIsSelected() ? 'selected' : undefined}
className={
editData?.name === row.original.name
? 'bg-muted/45'
: undefined
? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors'
: 'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors'
}
onClick={(event) => {
const target = event.target as HTMLElement
@@ -983,17 +1131,20 @@ const ModelRatioVisualEditorComponent = forwardRef<
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
<td
key={cell.id}
className='p-2 align-middle text-sm whitespace-nowrap'
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
</td>
))}
</TableRow>
</tr>
))}
</TableBody>
</Table>
</tbody>
</table>
</div>
)}
@@ -1002,18 +1153,15 @@ const ModelRatioVisualEditorComponent = forwardRef<
)}
</div>
<div className='hidden min-w-0 md:block'>
<div className='hidden min-h-0 min-w-0 md:block'>
{editorOpen ? (
<ModelPricingEditorPanel
ref={editorPanelRef}
onSave={handleSave}
onCancel={handleCancel}
editData={editData}
selectedTargetCount={selectedTargetCount}
className='sticky top-4 h-[calc(100vh-8rem)] min-h-[620px]'
className='h-full min-h-0'
/>
) : (
<div className='bg-card text-muted-foreground sticky top-4 flex h-[calc(100vh-8rem)] min-h-[420px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed p-6 text-center'>
<div className='bg-card text-muted-foreground flex h-full min-h-0 flex-col items-center justify-center gap-3 rounded-xl border border-dashed p-6 text-center'>
<div className='text-foreground text-base font-medium'>
{t('Select a model to edit pricing')}
</div>
@@ -1045,10 +1193,7 @@ const ModelRatioVisualEditorComponent = forwardRef<
ref={editorPanelRef}
open={sheetOpen}
onOpenChange={setSheetOpen}
onSave={handleSave}
onCancel={handleCancel}
editData={editData}
selectedTargetCount={selectedTargetCount}
/>
)}
</div>
@@ -250,6 +250,9 @@ export function RatioSettingsCard({
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
})
const [savedModelValues, setSavedModelValues] = useState(
modelNormalizedDefaults.current
)
const groupNormalizedDefaults = useRef({
GroupRatio: normalizeJsonString(groupDefaults.GroupRatio),
@@ -315,6 +318,7 @@ export function RatioSettingsCard({
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
}
setSavedModelValues(modelNormalizedDefaults.current)
modelForm.reset({
...modelDefaults,
@@ -395,6 +399,9 @@ export function RatioSettingsCard({
const apiKey = apiKeyMap[key as string] || (key as string)
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
}
modelNormalizedDefaults.current = normalized
setSavedModelValues(normalized)
},
[t, updateOption]
)
@@ -462,6 +469,7 @@ export function RatioSettingsCard({
return (
<ModelRatioForm
form={modelForm}
savedValues={savedModelValues}
onSave={saveModelRatios}
onReset={handleResetRatios}
isSaving={updateOption.isPending}