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}
+2
View File
@@ -1253,6 +1253,7 @@
"DoubaoVideo": "DoubaoVideo",
"Double check the configuration below. Your system will be locked until initialization is complete.": "Double check the configuration below. Your system will be locked until initialization is complete.",
"Download": "Download",
"Draft": "Draft",
"Draw": "Draw",
"Drawing": "Drawing",
"Drawing logs": "Drawing logs",
@@ -4414,6 +4415,7 @@
"We could not load the setup status.": "We could not load the setup status.",
"We will prompt your device to confirm using biometrics or your hardware key.": "We will prompt your device to confirm using biometrics or your hardware key.",
"We'll be back online shortly.": "We'll be back online shortly.",
"Will be removed": "Will be removed",
"Web search": "Web search",
"Web Search": "Web Search",
"Webhook Configuration:": "Webhook Configuration:",
+2
View File
@@ -1253,6 +1253,7 @@
"DoubaoVideo": "DoubaoVideo",
"Double check the configuration below. Your system will be locked until initialization is complete.": "Vérifiez la configuration ci-dessous. Votre système sera verrouillé jusqu'à ce que l'initialisation soit terminée.",
"Download": "Télécharger",
"Draft": "Brouillon",
"Draw": "Dessin",
"Drawing": "Dessin",
"Drawing logs": "Journaux de dessin",
@@ -4414,6 +4415,7 @@
"We could not load the setup status.": "Nous n'avons pas pu charger l'état de la configuration.",
"We will prompt your device to confirm using biometrics or your hardware key.": "Nous allons demander à votre appareil de confirmer en utilisant la biométrie ou votre clé matérielle.",
"We'll be back online shortly.": "Nous serons de retour en ligne sous peu.",
"Will be removed": "Sera supprimé",
"Web search": "Recherche web",
"Web Search": "Recherche web",
"Webhook Configuration:": "Configuration du Webhook :",
+2
View File
@@ -1253,6 +1253,7 @@
"DoubaoVideo": "DoubaoVideo",
"Double check the configuration below. Your system will be locked until initialization is complete.": "下記の設定を再確認してください。初期化が完了するまでシステムはロックされます。",
"Download": "ダウンロード",
"Draft": "下書き",
"Draw": "描画",
"Drawing": "画像生成",
"Drawing logs": "描画ログ",
@@ -4414,6 +4415,7 @@
"We could not load the setup status.": "セットアップステータスを読み込めませんでした。",
"We will prompt your device to confirm using biometrics or your hardware key.": "生体認証またはハードウェアキーを使用して確認するよう、デバイスにプロンプトが表示されます。",
"We'll be back online shortly.": "まもなくオンラインに戻ります。",
"Will be removed": "削除予定",
"Web search": "ウェブ検索",
"Web Search": "Web 検索",
"Webhook Configuration:": "Webhook設定:",
+2
View File
@@ -1253,6 +1253,7 @@
"DoubaoVideo": "DoubaoVideo",
"Double check the configuration below. Your system will be locked until initialization is complete.": "Дважды проверьте конфигурацию ниже. Ваша система будет заблокирована до завершения инициализации.",
"Download": "Скачать",
"Draft": "Черновик",
"Draw": "Рисование",
"Drawing": "Рисование",
"Drawing logs": "Журналы рисования",
@@ -4414,6 +4415,7 @@
"We could not load the setup status.": "Не удалось загрузить статус настройки.",
"We will prompt your device to confirm using biometrics or your hardware key.": "Мы предложим вашему устройству подтвердить действие с помощью биометрии или аппаратного ключа.",
"We'll be back online shortly.": "Мы скоро вернемся в сеть.",
"Will be removed": "Будет удалено",
"Web search": "Веб-поиск",
"Web Search": "Веб-поиск",
"Webhook Configuration:": "Конфигурация веб-хука:",
+2
View File
@@ -1253,6 +1253,7 @@
"DoubaoVideo": "DoubaoVideo",
"Double check the configuration below. Your system will be locked until initialization is complete.": "Kiểm tra kỹ lại cấu hình bên dưới. Hệ thống của bạn sẽ bị khóa cho đến khi quá trình khởi tạo hoàn tất.",
"Download": "Tải xuống",
"Draft": "Bản nháp",
"Draw": "Vẽ",
"Drawing": "Vẽ",
"Drawing logs": "Nhật ký vẽ",
@@ -4414,6 +4415,7 @@
"We could not load the setup status.": "Chúng tôi không thể tải trạng thái thiết lập.",
"We will prompt your device to confirm using biometrics or your hardware key.": "Chúng tôi sẽ yêu cầu thiết bị của bạn xác nhận bằng cách sử dụng sinh trắc học hoặc khóa bảo mật phần cứng của bạn.",
"We'll be back online shortly.": "Chúng tôi sẽ sớm trực tuyến trở lại.",
"Will be removed": "Sẽ bị xóa",
"Web search": "Tìm kiếm web",
"Web Search": "Tìm kiếm web",
"Webhook Configuration:": "Cấu hình Webhook:",
+2
View File
@@ -1253,6 +1253,7 @@
"DoubaoVideo": "DoubaoVideo",
"Double check the configuration below. Your system will be locked until initialization is complete.": "仔细检查以下配置。您的系统将在初始化完成前保持锁定状态。",
"Download": "下载",
"Draft": "草稿",
"Draw": "绘图",
"Drawing": "绘图",
"Drawing logs": "绘制日志",
@@ -4414,6 +4415,7 @@
"We could not load the setup status.": "我们无法加载设置状态。",
"We will prompt your device to confirm using biometrics or your hardware key.": "我们将提示您的设备使用生物识别或硬件密钥进行确认。",
"We'll be back online shortly.": "我们将很快恢复在线。",
"Will be removed": "将被移除",
"Web search": "网络搜索",
"Web Search": "网页搜索",
"Webhook Configuration:": "Webhook 配置:",