Commit abad0d3c by QuentinHsu

fix(model-pricing): detect visual pricing draft changes on save

- expose a draft commit handle from the model pricing editor panel before saving.
- commit the open visual editor into the parent form before page-level save runs.
- support both desktop side editor and mobile sheet save paths.
parent 7aaa5332
...@@ -16,7 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useEffect, useMemo, useState } from 'react' import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react'
import * as z from 'zod' import * as z from 'zod'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
...@@ -130,6 +137,10 @@ type ModelPricingEditorPanelProps = Omit< ...@@ -130,6 +137,10 @@ type ModelPricingEditorPanelProps = Omit<
className?: string className?: string
} }
export type ModelPricingEditorPanelHandle = {
commitDraft: () => Promise<ModelRatioData | null>
}
type PreviewRow = { type PreviewRow = {
key: string key: string
label: string label: string
...@@ -377,14 +388,13 @@ function buildPreviewRows( ...@@ -377,14 +388,13 @@ function buildPreviewRows(
] ]
} }
export function ModelPricingSheet({ export const ModelPricingSheet = forwardRef<
open, ModelPricingEditorPanelHandle,
onOpenChange, ModelPricingSheetProps
onSave, >(function ModelPricingSheet(
onCancel, { open, onOpenChange, onSave, onCancel, editData, selectedTargetCount = 0 },
editData, ref
selectedTargetCount = 0, ) {
}: ModelPricingSheetProps) {
const { t } = useTranslation() const { t } = useTranslation()
const title = editData ? t('Edit model pricing') : t('Add model pricing') const title = editData ? t('Edit model pricing') : t('Add model pricing')
const description = editData?.name || t('New model') const description = editData?.name || t('New model')
...@@ -400,6 +410,7 @@ export function ModelPricingSheet({ ...@@ -400,6 +410,7 @@ export function ModelPricingSheet({
<SheetDescription>{description}</SheetDescription> <SheetDescription>{description}</SheetDescription>
</SheetHeader> </SheetHeader>
<ModelPricingEditorPanel <ModelPricingEditorPanel
ref={ref}
onSave={onSave} onSave={onSave}
editData={editData} editData={editData}
selectedTargetCount={selectedTargetCount} selectedTargetCount={selectedTargetCount}
...@@ -412,15 +423,15 @@ export function ModelPricingSheet({ ...@@ -412,15 +423,15 @@ export function ModelPricingSheet({
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) )
} })
export function ModelPricingEditorPanel({ export const ModelPricingEditorPanel = forwardRef<
onSave, ModelPricingEditorPanelHandle,
editData, ModelPricingEditorPanelProps
selectedTargetCount = 0, >(function ModelPricingEditorPanel(
onCancel, { onSave, editData, selectedTargetCount = 0, onCancel, className },
className, ref
}: ModelPricingEditorPanelProps) { ) {
const { t } = useTranslation() const { t } = useTranslation()
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token') const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
const [promptPrice, setPromptPrice] = useState('') const [promptPrice, setPromptPrice] = useState('')
...@@ -687,7 +698,7 @@ export function ModelPricingEditorPanel({ ...@@ -687,7 +698,7 @@ export function ModelPricingEditorPanel({
return nextWarnings return nextWarnings
}, [editData, laneEnabled, lanePrices, pricingMode, promptPrice, t]) }, [editData, laneEnabled, lanePrices, pricingMode, promptPrice, t])
const handleSubmit = (values: ModelPricingFormValues) => { const validatePricingValues = useCallback(() => {
if ( if (
pricingMode === 'per-token' && pricingMode === 'per-token' &&
toNumberOrNull(promptPrice) === null && toNumberOrNull(promptPrice) === null &&
...@@ -698,7 +709,7 @@ export function ModelPricingEditorPanel({ ...@@ -698,7 +709,7 @@ export function ModelPricingEditorPanel({
form.setError('ratio', { form.setError('ratio', {
message: t('Input price is required before saving dependent prices.'), message: t('Input price is required before saving dependent prices.'),
}) })
return return false
} }
if ( if (
...@@ -709,27 +720,53 @@ export function ModelPricingEditorPanel({ ...@@ -709,27 +720,53 @@ export function ModelPricingEditorPanel({
form.setError('audioRatio', { form.setError('audioRatio', {
message: t('Audio output price requires an audio input price.'), message: t('Audio output price requires an audio input price.'),
}) })
return return false
} }
const data: ModelRatioData = { return true
name: values.name.trim(), }, [form, laneEnabled, lanePrices, pricingMode, promptPrice, t])
billingMode: pricingMode,
price: values.price || '', const buildSubmitData = useCallback(
ratio: values.ratio || '', (values: ModelPricingFormValues) => {
cacheRatio: values.cacheRatio || '', const data: ModelRatioData = {
createCacheRatio: values.createCacheRatio || '', name: values.name.trim(),
completionRatio: values.completionRatio || '', billingMode: pricingMode,
imageRatio: values.imageRatio || '', price: values.price || '',
audioRatio: values.audioRatio || '', ratio: values.ratio || '',
audioCompletionRatio: values.audioCompletionRatio || '', cacheRatio: values.cacheRatio || '',
} createCacheRatio: values.createCacheRatio || '',
completionRatio: values.completionRatio || '',
imageRatio: values.imageRatio || '',
audioRatio: values.audioRatio || '',
audioCompletionRatio: values.audioCompletionRatio || '',
}
if (pricingMode === 'tiered_expr') { if (pricingMode === 'tiered_expr') {
data.billingExpr = billingExpr data.billingExpr = billingExpr
data.requestRuleExpr = requestRuleExpr data.requestRuleExpr = requestRuleExpr
} }
return data
},
[billingExpr, pricingMode, requestRuleExpr]
)
useImperativeHandle(
ref,
() => ({
commitDraft: async () => {
const isValid = await form.trigger()
if (!isValid || !validatePricingValues()) return null
return buildSubmitData(form.getValues())
},
}),
[form, validatePricingValues, buildSubmitData]
)
const handleSubmit = (values: ModelPricingFormValues) => {
if (!validatePricingValues()) return
const data = buildSubmitData(values)
onSave(data) onSave(data)
form.reset() form.reset()
onCancel?.() onCancel?.()
...@@ -980,7 +1017,7 @@ export function ModelPricingEditorPanel({ ...@@ -980,7 +1017,7 @@ export function ModelPricingEditorPanel({
</Form> </Form>
</div> </div>
) )
} })
function PriceInput(props: { function PriceInput(props: {
value: string value: string
......
...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useRef, useState } from 'react'
import { type UseFormReturn } from 'react-hook-form' import { type UseFormReturn } from 'react-hook-form'
import { Code2, Eye } from 'lucide-react' import { Code2, Eye } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
...@@ -38,7 +38,10 @@ import { ...@@ -38,7 +38,10 @@ import {
SettingsSwitchItem, SettingsSwitchItem,
} from '../components/settings-form-layout' } from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context' import { SettingsPageActionsPortal } from '../components/settings-page-context'
import { ModelRatioVisualEditor } from './model-ratio-visual-editor' import {
ModelRatioVisualEditor,
type ModelRatioVisualEditorHandle,
} from './model-ratio-visual-editor'
type ModelFormValues = { type ModelFormValues = {
ModelPrice: string ModelPrice: string
...@@ -71,6 +74,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({ ...@@ -71,6 +74,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
}: ModelRatioFormProps) { }: ModelRatioFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual') const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
const visualEditorRef = useRef<ModelRatioVisualEditorHandle>(null)
const handleFieldChange = useCallback( const handleFieldChange = useCallback(
(field: keyof ModelFormValues, value: string) => { (field: keyof ModelFormValues, value: string) => {
...@@ -86,6 +90,15 @@ export const ModelRatioForm = memo(function ModelRatioForm({ ...@@ -86,6 +90,15 @@ export const ModelRatioForm = memo(function ModelRatioForm({
setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual')) setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual'))
}, []) }, [])
const handleSave = useCallback(async () => {
if (editMode === 'visual') {
const committed = await visualEditorRef.current?.commitOpenEditor()
if (committed === false) return
}
await form.handleSubmit(onSave)()
}, [editMode, form, onSave])
return ( return (
<div className='space-y-6'> <div className='space-y-6'>
<div className='flex justify-end'> <div className='flex justify-end'>
...@@ -118,7 +131,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({ ...@@ -118,7 +131,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
<Button <Button
type='button' type='button'
size='sm' size='sm'
onClick={form.handleSubmit(onSave)} onClick={handleSave}
disabled={isSaving} disabled={isSaving}
> >
{isSaving ? t('Saving...') : t('Save model prices')} {isSaving ? t('Saving...') : t('Save model prices')}
...@@ -127,6 +140,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({ ...@@ -127,6 +140,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
{editMode === 'visual' ? ( {editMode === 'visual' ? (
<div className='space-y-6'> <div className='space-y-6'>
<ModelRatioVisualEditor <ModelRatioVisualEditor
ref={visualEditorRef}
modelPrice={form.watch('ModelPrice')} modelPrice={form.watch('ModelPrice')}
modelRatio={form.watch('ModelRatio')} modelRatio={form.watch('ModelRatio')}
cacheRatio={form.watch('CacheRatio')} cacheRatio={form.watch('CacheRatio')}
......
...@@ -16,7 +16,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useState, useMemo, memo, useCallback, useEffect } from 'react' import {
useState,
useMemo,
memo,
useCallback,
useEffect,
forwardRef,
useImperativeHandle,
useRef,
} from 'react'
import { import {
type ColumnDef, type ColumnDef,
type ColumnFiltersState, type ColumnFiltersState,
...@@ -62,6 +71,7 @@ import { ...@@ -62,6 +71,7 @@ import {
import { safeJsonParse } from '../utils/json-parser' import { safeJsonParse } from '../utils/json-parser'
import { import {
ModelPricingEditorPanel, ModelPricingEditorPanel,
type ModelPricingEditorPanelHandle,
ModelPricingSheet, ModelPricingSheet,
type ModelRatioData, type ModelRatioData,
} from './model-pricing-sheet' } from './model-pricing-sheet'
...@@ -97,6 +107,10 @@ type ModelRow = { ...@@ -97,6 +107,10 @@ type ModelRow = {
hasConflict: boolean hasConflict: boolean
} }
export type ModelRatioVisualEditorHandle = {
commitOpenEditor: () => Promise<boolean>
}
const STORAGE_KEY = 'model-ratio-column-visibility' const STORAGE_KEY = 'model-ratio-column-visibility'
const hasValue = (value?: string) => value !== undefined && value !== '' const hasValue = (value?: string) => value !== undefined && value !== ''
...@@ -192,8 +206,11 @@ const getPriceDetail = (row: ModelRow, t: (key: string) => string) => { ...@@ -192,8 +206,11 @@ const getPriceDetail = (row: ModelRow, t: (key: string) => string) => {
return details.length > 0 ? details.join(' · ') : t('Base input price only') return details.length > 0 ? details.join(' · ') : t('Base input price only')
} }
export const ModelRatioVisualEditor = memo( const ModelRatioVisualEditorComponent = forwardRef<
function ModelRatioVisualEditor({ ModelRatioVisualEditorHandle,
ModelRatioVisualEditorProps
>(function ModelRatioVisualEditor(
{
modelPrice, modelPrice,
modelRatio, modelRatio,
cacheRatio, cacheRatio,
...@@ -205,180 +222,339 @@ export const ModelRatioVisualEditor = memo( ...@@ -205,180 +222,339 @@ export const ModelRatioVisualEditor = memo(
billingMode, billingMode,
billingExpr, billingExpr,
onChange, onChange,
}: ModelRatioVisualEditorProps) { },
const { t } = useTranslation() ref
const isMobile = useMediaQuery('(max-width: 767px)') ) {
const [sheetOpen, setSheetOpen] = useState(false) const { t } = useTranslation()
const [editorOpen, setEditorOpen] = useState(false) const isMobile = useMediaQuery('(max-width: 767px)')
const [editData, setEditData] = useState<ModelRatioData | null>(null) const [sheetOpen, setSheetOpen] = useState(false)
const [sorting, setSorting] = useState<SortingState>([]) const [editorOpen, setEditorOpen] = useState(false)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [editData, setEditData] = useState<ModelRatioData | null>(null)
const [globalFilter, setGlobalFilter] = useState('') const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [pagination, setPagination] = useState<PaginationState>({ const [globalFilter, setGlobalFilter] = useState('')
pageIndex: 0, const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
pageSize: 20, const editorPanelRef = useRef<ModelPricingEditorPanelHandle>(null)
}) const [pagination, setPagination] = useState<PaginationState>({
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>( pageIndex: 0,
() => { pageSize: 20,
const saved = localStorage.getItem(STORAGE_KEY) })
if (saved) { const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
try { () => {
return safeJsonParse<VisibilityState>(saved, { const saved = localStorage.getItem(STORAGE_KEY)
fallback: { if (saved) {
cacheRatio: false, try {
createCacheRatio: false, return safeJsonParse<VisibilityState>(saved, {
imageRatio: false, fallback: {
audioRatio: false,
audioCompletionRatio: false,
},
silent: true,
})
} catch {
return {
cacheRatio: false, cacheRatio: false,
createCacheRatio: false, createCacheRatio: false,
imageRatio: false, imageRatio: false,
audioRatio: false, audioRatio: false,
audioCompletionRatio: false, audioCompletionRatio: false,
} },
silent: true,
})
} catch {
return {
cacheRatio: false,
createCacheRatio: false,
imageRatio: false,
audioRatio: false,
audioCompletionRatio: false,
} }
} }
}
return {
cacheRatio: false,
createCacheRatio: false,
imageRatio: false,
audioRatio: false,
audioCompletionRatio: false,
}
}
)
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(columnVisibility))
}, [columnVisibility])
const models = useMemo(() => {
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),
])
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() || ''
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 { return {
cacheRatio: false, name,
createCacheRatio: false, billingMode: 'tiered_expr',
imageRatio: false, billingExpr: pureExpr,
audioRatio: false, requestRuleExpr,
audioCompletionRatio: false, price,
ratio,
cacheRatio: cache,
createCacheRatio: createCache,
completionRatio: completion,
imageRatio: image,
audioRatio: audio,
audioCompletionRatio: audioCompletion,
hasConflict: false,
} }
} }
)
useEffect(() => { return {
localStorage.setItem(STORAGE_KEY, JSON.stringify(columnVisibility)) name,
}, [columnVisibility]) 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))
}, [
modelPrice,
modelRatio,
cacheRatio,
createCacheRatio,
completionRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
billingMode,
billingExpr,
])
const modeCounts = useMemo(
() =>
models.reduce(
(acc, model) => {
const mode =
model.billingMode === 'per-request' ||
model.billingMode === 'tiered_expr'
? model.billingMode
: 'per-token'
acc[mode] += 1
return acc
},
{
'per-token': 0,
'per-request': 0,
tiered_expr: 0,
} as Record<'per-token' | 'per-request' | 'tiered_expr', number>
),
[models]
)
const handleEdit = useCallback(
(model: ModelRow) => {
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,
billingMode:
model.billingMode === 'tiered_expr'
? 'tiered_expr'
: model.price && model.price !== ''
? 'per-request'
: 'per-token',
billingExpr: model.billingExpr,
requestRuleExpr: model.requestRuleExpr,
})
setEditorOpen(true)
if (isMobile) setSheetOpen(true)
},
[isMobile]
)
const handleAdd = useCallback(() => {
setEditData(null)
setEditorOpen(true)
if (isMobile) setSheetOpen(true)
}, [isMobile])
const handleCancel = useCallback(() => {
setEditData(null)
setEditorOpen(false)
setSheetOpen(false)
}, [])
const handleGlobalFilterChange = useCallback<OnChangeFn<string>>(
(updater) => {
setGlobalFilter((previous) => {
const next = typeof updater === 'function' ? updater(previous) : updater
if (next !== previous) {
setEditData(null)
setEditorOpen(false)
setSheetOpen(false)
}
return next
})
},
[]
)
const models = useMemo(() => { const handleDelete = useCallback(
(name: string) => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, { const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
fallback: {}, fallback: {},
context: 'model prices', silent: true,
}) })
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, { const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
fallback: {}, fallback: {},
context: 'model ratios', silent: true,
}) })
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, { const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
fallback: {}, fallback: {},
context: 'cache ratios', silent: true,
}) })
const createCacheMap = safeJsonParse<Record<string, number>>( const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio, createCacheRatio,
{ fallback: {}, context: 'create cache ratios' } { fallback: {}, silent: true }
) )
const completionMap = safeJsonParse<Record<string, number>>( const completionMap = safeJsonParse<Record<string, number>>(
completionRatio, completionRatio,
{ fallback: {}, context: 'completion ratios' } { fallback: {}, silent: true }
) )
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, { const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {}, fallback: {},
context: 'image ratios', silent: true,
}) })
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, { const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {}, fallback: {},
context: 'audio ratios', silent: true,
}) })
const audioCompletionMap = safeJsonParse<Record<string, number>>( const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio, audioCompletionRatio,
{ fallback: {}, context: 'audio completion ratios' } { fallback: {}, silent: true }
) )
const billingModeMap = safeJsonParse<Record<string, string>>( const billingModeMap = safeJsonParse<Record<string, string>>(
billingMode, billingMode,
{ { fallback: {}, silent: true }
fallback: {},
context: 'billing mode',
}
) )
const billingExprMap = safeJsonParse<Record<string, string>>( const billingExprMap = safeJsonParse<Record<string, string>>(
billingExpr, billingExpr,
{ { fallback: {}, silent: true }
fallback: {},
context: 'billing expression',
}
) )
const modelNames = new Set([ delete priceMap[name]
...Object.keys(priceMap), delete ratioMap[name]
...Object.keys(ratioMap), delete cacheMap[name]
...Object.keys(cacheMap), delete createCacheMap[name]
...Object.keys(createCacheMap), delete completionMap[name]
...Object.keys(completionMap), delete imageMap[name]
...Object.keys(imageMap), delete audioMap[name]
...Object.keys(audioMap), delete audioCompletionMap[name]
...Object.keys(audioCompletionMap), delete billingModeMap[name]
...Object.keys(billingModeMap), delete billingExprMap[name]
...Object.keys(billingExprMap),
])
const modelData: ModelRow[] = Array.from(modelNames).map((name) => { onChange('ModelPrice', JSON.stringify(priceMap, null, 2))
const price = priceMap[name]?.toString() || '' onChange('ModelRatio', JSON.stringify(ratioMap, null, 2))
const ratio = ratioMap[name]?.toString() || '' onChange('CacheRatio', JSON.stringify(cacheMap, null, 2))
const cache = cacheMap[name]?.toString() || '' onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2))
const createCache = createCacheMap[name]?.toString() || '' onChange('CompletionRatio', JSON.stringify(completionMap, null, 2))
const completion = completionMap[name]?.toString() || '' onChange('ImageRatio', JSON.stringify(imageMap, null, 2))
const image = imageMap[name]?.toString() || '' onChange('AudioRatio', JSON.stringify(audioMap, null, 2))
const audio = audioMap[name]?.toString() || '' onChange(
const audioCompletion = audioCompletionMap[name]?.toString() || '' 'AudioCompletionRatio',
JSON.stringify(audioCompletionMap, null, 2)
const modeForModel = billingModeMap[name] )
if (modeForModel === 'tiered_expr') { onChange(
// Tiered_expr models may also retain ratio/price values as fallback 'billing_setting.billing_mode',
// during multi-instance sync delays. We preserve them in the row so JSON.stringify(billingModeMap, null, 2)
// the edit dialog round-trip and the next save don't drop them. )
const fullExpr = billingExprMap[name] || '' onChange(
const { billingExpr: pureExpr, requestRuleExpr } = 'billing_setting.billing_expr',
splitBillingExprAndRequestRules(fullExpr) JSON.stringify(billingExprMap, null, 2)
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 !== ''),
}
})
return modelData.sort((a, b) => a.name.localeCompare(b.name))
}, [
modelPrice, modelPrice,
modelRatio, modelRatio,
cacheRatio, cacheRatio,
...@@ -389,127 +565,210 @@ export const ModelRatioVisualEditor = memo( ...@@ -389,127 +565,210 @@ export const ModelRatioVisualEditor = memo(
audioCompletionRatio, audioCompletionRatio,
billingMode, billingMode,
billingExpr, billingExpr,
]) onChange,
]
)
const modeCounts = useMemo( const columns = useMemo<ColumnDef<ModelRow>[]>(() => {
() => return [
models.reduce( {
(acc, model) => { id: 'select',
const mode = header: ({ table }) => (
model.billingMode === 'per-request' || <Checkbox
model.billingMode === 'tiered_expr' checked={table.getIsAllPageRowsSelected()}
? model.billingMode indeterminate={table.getIsSomePageRowsSelected()}
: 'per-token' onCheckedChange={(value) =>
acc[mode] += 1 table.toggleAllPageRowsSelected(!!value)
return acc }
}, aria-label={t('Select all')}
{ className='translate-y-[2px]'
'per-token': 0, />
'per-request': 0,
tiered_expr: 0,
} as Record<'per-token' | 'per-request' | 'tiered_expr', number>
), ),
[models] cell: ({ row }) => (
) <Checkbox
checked={row.getIsSelected()}
const handleEdit = useCallback( onCheckedChange={(value) => row.toggleSelected(!!value)}
(model: ModelRow) => { aria-label={t('Select row')}
setEditData({ className='translate-y-[2px]'
name: model.name, />
price: model.price, ),
ratio: model.ratio, enableSorting: false,
cacheRatio: model.cacheRatio, enableHiding: false,
createCacheRatio: model.createCacheRatio, meta: { label: t('Select') },
completionRatio: model.completionRatio,
imageRatio: model.imageRatio,
audioRatio: model.audioRatio,
audioCompletionRatio: model.audioCompletionRatio,
billingMode:
model.billingMode === 'tiered_expr'
? 'tiered_expr'
: model.price && model.price !== ''
? 'per-request'
: 'per-token',
billingExpr: model.billingExpr,
requestRuleExpr: model.requestRuleExpr,
})
setEditorOpen(true)
if (isMobile) setSheetOpen(true)
}, },
[isMobile] {
) accessorKey: 'name',
header: ({ column }) => (
const handleAdd = useCallback(() => { <DataTableColumnHeader column={column} title={t('Model name')} />
setEditData(null) ),
setEditorOpen(true) cell: ({ row }) => (
if (isMobile) setSheetOpen(true) <div className='flex items-center gap-2 font-medium'>
}, [isMobile]) {row.getValue('name')}
{row.original.billingMode === 'tiered_expr' && (
<StatusBadge
label={t('Tiered')}
variant='info'
copyable={false}
/>
)}
{row.original.hasConflict && (
<StatusBadge
label={t('Conflict')}
variant='danger'
copyable={false}
/>
)}
</div>
),
enableHiding: false,
},
{
accessorKey: 'billingMode',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Mode')} />
),
cell: ({ row }) => (
<StatusBadge
label={t(getModeLabel(row.original.billingMode))}
variant={getModeVariant(row.original.billingMode)}
copyable={false}
/>
),
filterFn: (row, id, value) =>
filterBySelectedValues(row.getValue(id), value),
meta: { label: t('Mode') },
},
{
id: 'priceSummary',
header: ({ column }) => (
<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>
),
sortingFn: (rowA, rowB) =>
getPriceSummary(rowA.original, t).localeCompare(
getPriceSummary(rowB.original, t)
),
meta: { label: t('Price summary') },
},
{
id: 'actions',
cell: ({ row }) => (
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => handleEdit(row.original)}
>
<Pencil />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleDelete(row.original.name)}
>
<Trash2 />
</Button>
</div>
),
enableHiding: false,
},
]
}, [handleEdit, handleDelete, t])
const handleCancel = useCallback(() => { const table = useReactTable({
setEditData(null) data: models,
setEditorOpen(false) columns,
setSheetOpen(false) state: {
}, []) sorting,
columnFilters,
globalFilter,
columnVisibility,
pagination,
rowSelection,
},
enableRowSelection: true,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: handleGlobalFilterChange,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
autoResetPageIndex: false,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
globalFilterFn: (row, _columnId, filterValue) => {
const searchValue = String(filterValue).toLowerCase()
return row.original.name.toLowerCase().includes(searchValue)
},
})
const handleGlobalFilterChange = useCallback<OnChangeFn<string>>( const persistPricingData = useCallback(
(updater) => { (data: ModelRatioData, targetNames: string[] = [data.name]) => {
setGlobalFilter((previous) => { const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
const next = fallback: {},
typeof updater === 'function' ? updater(previous) : updater silent: true,
if (next !== previous) { })
setEditData(null) const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
setEditorOpen(false) fallback: {},
setSheetOpen(false) silent: true,
} })
return next const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
}) fallback: {},
}, silent: true,
[] })
) const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio,
{ fallback: {}, silent: true }
)
const completionMap = safeJsonParse<Record<string, number>>(
completionRatio,
{ fallback: {}, silent: true }
)
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {},
silent: true,
})
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {},
silent: true,
})
const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio,
{ fallback: {}, silent: true }
)
const billingModeMap = safeJsonParse<Record<string, string>>(
billingMode,
{ fallback: {}, silent: true }
)
const billingExprMap = safeJsonParse<Record<string, string>>(
billingExpr,
{ fallback: {}, silent: true }
)
const handleDelete = useCallback( const setIfPresent = (
(name: string) => { target: Record<string, number>,
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, { name: string,
fallback: {}, value: string | undefined
silent: true, ) => {
}) if (!value || value === '') return
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, { const parsed = parseFloat(value)
fallback: {}, if (Number.isFinite(parsed)) target[name] = parsed
silent: true, }
})
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
fallback: {},
silent: true,
})
const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio,
{ fallback: {}, silent: true }
)
const completionMap = safeJsonParse<Record<string, number>>(
completionRatio,
{ fallback: {}, silent: true }
)
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {},
silent: true,
})
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {},
silent: true,
})
const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio,
{ fallback: {}, silent: true }
)
const billingModeMap = safeJsonParse<Record<string, string>>(
billingMode,
{ fallback: {}, silent: true }
)
const billingExprMap = safeJsonParse<Record<string, string>>(
billingExpr,
{ fallback: {}, silent: true }
)
targetNames.forEach((name) => {
delete priceMap[name] delete priceMap[name]
delete ratioMap[name] delete ratioMap[name]
delete cacheMap[name] delete cacheMap[name]
...@@ -521,514 +780,283 @@ export const ModelRatioVisualEditor = memo( ...@@ -521,514 +780,283 @@ export const ModelRatioVisualEditor = memo(
delete billingModeMap[name] delete billingModeMap[name]
delete billingExprMap[name] delete billingExprMap[name]
onChange('ModelPrice', JSON.stringify(priceMap, null, 2)) if (data.billingMode === 'tiered_expr') {
onChange('ModelRatio', JSON.stringify(ratioMap, null, 2)) const combined = combineBillingExpr(
onChange('CacheRatio', JSON.stringify(cacheMap, null, 2)) data.billingExpr || '',
onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2)) data.requestRuleExpr || ''
onChange('CompletionRatio', JSON.stringify(completionMap, null, 2)) )
onChange('ImageRatio', JSON.stringify(imageMap, null, 2)) if (combined) {
onChange('AudioRatio', JSON.stringify(audioMap, null, 2)) billingModeMap[name] = 'tiered_expr'
onChange( billingExprMap[name] = combined
'AudioCompletionRatio', }
JSON.stringify(audioCompletionMap, null, 2) // Always serialize ratio/price values for tiered_expr models so they
) // serve as fallback during multi-instance sync delays. The backend's
onChange( // ModelPriceHelper checks billing_mode first, so these values are
'billing_setting.billing_mode', // only consulted when billing_setting hasn't propagated yet.
JSON.stringify(billingModeMap, null, 2) setIfPresent(priceMap, name, data.price)
) setIfPresent(ratioMap, name, data.ratio)
onChange( setIfPresent(cacheMap, name, data.cacheRatio)
'billing_setting.billing_expr', setIfPresent(createCacheMap, name, data.createCacheRatio)
JSON.stringify(billingExprMap, null, 2) setIfPresent(completionMap, name, data.completionRatio)
) setIfPresent(imageMap, name, data.imageRatio)
}, setIfPresent(audioMap, name, data.audioRatio)
[ setIfPresent(audioCompletionMap, name, data.audioCompletionRatio)
modelPrice, } else if (data.price && data.price !== '') {
modelRatio, setIfPresent(priceMap, name, data.price)
cacheRatio, } else {
createCacheRatio, setIfPresent(ratioMap, name, data.ratio)
completionRatio, setIfPresent(cacheMap, name, data.cacheRatio)
imageRatio, setIfPresent(createCacheMap, name, data.createCacheRatio)
audioRatio, setIfPresent(completionMap, name, data.completionRatio)
audioCompletionRatio, setIfPresent(imageMap, name, data.imageRatio)
billingMode, setIfPresent(audioMap, name, data.audioRatio)
billingExpr, setIfPresent(audioCompletionMap, name, data.audioCompletionRatio)
onChange, }
] })
)
const columns = useMemo<ColumnDef<ModelRow>[]>(() => {
return [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label={t('Select all')}
className='translate-y-[2px]'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={t('Select row')}
className='translate-y-[2px]'
/>
),
enableSorting: false,
enableHiding: false,
meta: { label: t('Select') },
},
{
accessorKey: 'name',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Model name')} />
),
cell: ({ row }) => (
<div className='flex items-center gap-2 font-medium'>
{row.getValue('name')}
{row.original.billingMode === 'tiered_expr' && (
<StatusBadge
label={t('Tiered')}
variant='info'
copyable={false}
/>
)}
{row.original.hasConflict && (
<StatusBadge
label={t('Conflict')}
variant='danger'
copyable={false}
/>
)}
</div>
),
enableHiding: false,
},
{
accessorKey: 'billingMode',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Mode')} />
),
cell: ({ row }) => (
<StatusBadge
label={t(getModeLabel(row.original.billingMode))}
variant={getModeVariant(row.original.billingMode)}
copyable={false}
/>
),
filterFn: (row, id, value) =>
filterBySelectedValues(row.getValue(id), value),
meta: { label: t('Mode') },
},
{
id: 'priceSummary',
header: ({ column }) => (
<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>
),
sortingFn: (rowA, rowB) =>
getPriceSummary(rowA.original, t).localeCompare(
getPriceSummary(rowB.original, t)
),
meta: { label: t('Price summary') },
},
{
id: 'actions',
cell: ({ row }) => (
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => handleEdit(row.original)}
>
<Pencil />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleDelete(row.original.name)}
>
<Trash2 />
</Button>
</div>
),
enableHiding: false,
},
]
}, [handleEdit, handleDelete, t])
const table = useReactTable({ onChange('ModelPrice', JSON.stringify(priceMap, null, 2))
data: models, onChange('ModelRatio', JSON.stringify(ratioMap, null, 2))
columns, onChange('CacheRatio', JSON.stringify(cacheMap, null, 2))
state: { onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2))
sorting, onChange('CompletionRatio', JSON.stringify(completionMap, null, 2))
columnFilters, onChange('ImageRatio', JSON.stringify(imageMap, null, 2))
globalFilter, onChange('AudioRatio', JSON.stringify(audioMap, null, 2))
columnVisibility, onChange(
pagination, 'AudioCompletionRatio',
rowSelection, JSON.stringify(audioCompletionMap, null, 2)
}, )
enableRowSelection: true, onChange(
onSortingChange: setSorting, 'billing_setting.billing_mode',
onColumnFiltersChange: setColumnFilters, JSON.stringify(billingModeMap, null, 2)
onGlobalFilterChange: handleGlobalFilterChange, )
onColumnVisibilityChange: setColumnVisibility, onChange(
onPaginationChange: setPagination, 'billing_setting.billing_expr',
onRowSelectionChange: setRowSelection, JSON.stringify(billingExprMap, null, 2)
autoResetPageIndex: false, )
getCoreRowModel: getCoreRowModel(), },
getFilteredRowModel: getFilteredRowModel(), [
getSortedRowModel: getSortedRowModel(), modelPrice,
getPaginationRowModel: getPaginationRowModel(), modelRatio,
getFacetedRowModel: getFacetedRowModel(), cacheRatio,
getFacetedUniqueValues: getFacetedUniqueValues(), createCacheRatio,
globalFilterFn: (row, _columnId, filterValue) => { completionRatio,
const searchValue = String(filterValue).toLowerCase() imageRatio,
return row.original.name.toLowerCase().includes(searchValue) audioRatio,
}, audioCompletionRatio,
}) billingMode,
billingExpr,
onChange,
]
)
const persistPricingData = useCallback( const handleSave = useCallback(
(data: ModelRatioData, targetNames: string[] = [data.name]) => { (data: ModelRatioData) => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, { persistPricingData(data)
fallback: {}, setEditData(data)
silent: true, setEditorOpen(true)
}) toast.success(
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, { t('Pricing changes saved to draft. Click "Save model prices" to apply.')
fallback: {}, )
silent: true, },
}) [persistPricingData, t]
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, { )
fallback: {},
silent: true,
})
const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio,
{ fallback: {}, silent: true }
)
const completionMap = safeJsonParse<Record<string, number>>(
completionRatio,
{ fallback: {}, silent: true }
)
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {},
silent: true,
})
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {},
silent: true,
})
const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio,
{ fallback: {}, silent: true }
)
const billingModeMap = safeJsonParse<Record<string, string>>(
billingMode,
{ fallback: {}, silent: true }
)
const billingExprMap = safeJsonParse<Record<string, string>>(
billingExpr,
{ fallback: {}, silent: true }
)
const setIfPresent = ( const handleBatchCopy = useCallback(() => {
target: Record<string, number>, if (!editData) {
name: string, toast.error(t('Open a source model first'))
value: string | undefined return
) => { }
if (!value || value === '') return
const parsed = parseFloat(value)
if (Number.isFinite(parsed)) target[name] = parsed
}
targetNames.forEach((name) => { const targetNames = table
delete priceMap[name] .getFilteredSelectedRowModel()
delete ratioMap[name] .rows.map((row) => row.original.name)
delete cacheMap[name]
delete createCacheMap[name]
delete completionMap[name]
delete imageMap[name]
delete audioMap[name]
delete audioCompletionMap[name]
delete billingModeMap[name]
delete billingExprMap[name]
if (data.billingMode === 'tiered_expr') { if (targetNames.length === 0) {
const combined = combineBillingExpr( toast.error(t('Select at least one target model'))
data.billingExpr || '', return
data.requestRuleExpr || '' }
)
if (combined) {
billingModeMap[name] = 'tiered_expr'
billingExprMap[name] = combined
}
// Always serialize ratio/price values for tiered_expr models so they
// serve as fallback during multi-instance sync delays. The backend's
// ModelPriceHelper checks billing_mode first, so these values are
// only consulted when billing_setting hasn't propagated yet.
setIfPresent(priceMap, name, data.price)
setIfPresent(ratioMap, name, data.ratio)
setIfPresent(cacheMap, name, data.cacheRatio)
setIfPresent(createCacheMap, name, data.createCacheRatio)
setIfPresent(completionMap, name, data.completionRatio)
setIfPresent(imageMap, name, data.imageRatio)
setIfPresent(audioMap, name, data.audioRatio)
setIfPresent(audioCompletionMap, name, data.audioCompletionRatio)
} else if (data.price && data.price !== '') {
setIfPresent(priceMap, name, data.price)
} else {
setIfPresent(ratioMap, name, data.ratio)
setIfPresent(cacheMap, name, data.cacheRatio)
setIfPresent(createCacheMap, name, data.createCacheRatio)
setIfPresent(completionMap, name, data.completionRatio)
setIfPresent(imageMap, name, data.imageRatio)
setIfPresent(audioMap, name, data.audioRatio)
setIfPresent(audioCompletionMap, name, data.audioCompletionRatio)
}
})
onChange('ModelPrice', JSON.stringify(priceMap, null, 2)) persistPricingData(editData, targetNames)
onChange('ModelRatio', JSON.stringify(ratioMap, null, 2)) table.resetRowSelection()
onChange('CacheRatio', JSON.stringify(cacheMap, null, 2)) toast.success(
onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2)) t('Applied {{name}} pricing to {{count}} models', {
onChange('CompletionRatio', JSON.stringify(completionMap, null, 2)) name: editData.name,
onChange('ImageRatio', JSON.stringify(imageMap, null, 2)) count: targetNames.length,
onChange('AudioRatio', JSON.stringify(audioMap, null, 2)) })
onChange(
'AudioCompletionRatio',
JSON.stringify(audioCompletionMap, null, 2)
)
onChange(
'billing_setting.billing_mode',
JSON.stringify(billingModeMap, null, 2)
)
onChange(
'billing_setting.billing_expr',
JSON.stringify(billingExprMap, null, 2)
)
},
[
modelPrice,
modelRatio,
cacheRatio,
createCacheRatio,
completionRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
billingMode,
billingExpr,
onChange,
]
) )
}, [editData, persistPricingData, t, table])
const handleSave = useCallback( useImperativeHandle(
(data: ModelRatioData) => { ref,
() => ({
commitOpenEditor: async () => {
if (!editorOpen || !editorPanelRef.current) return true
const data = await editorPanelRef.current.commitDraft()
if (!data) return false
persistPricingData(data) persistPricingData(data)
setEditData(data) setEditData(data)
setEditorOpen(true) return true
toast.success(
t(
'Pricing changes saved to draft. Click "Save model prices" to apply.'
)
)
}, },
[persistPricingData, t] }),
) [editorOpen, persistPricingData]
)
const handleBatchCopy = useCallback(() => { const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length
if (!editData) {
toast.error(t('Open a source model first'))
return
}
const targetNames = table return (
.getFilteredSelectedRowModel() <div className='flex flex-col gap-4'>
.rows.map((row) => row.original.name) <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'>
if (targetNames.length === 0) { <DataTableToolbar
toast.error(t('Select at least one target model')) table={table}
return searchPlaceholder={t('Search models...')}
} filters={[
{
columnId: 'billingMode',
title: t('Mode'),
options: [
{
label: 'Per-token',
value: 'per-token',
count: modeCounts['per-token'],
},
{
label: 'Per-request',
value: 'per-request',
count: modeCounts['per-request'],
},
{
label: 'Expression',
value: 'tiered_expr',
count: modeCounts.tiered_expr,
},
],
},
]}
preActions={
<Button onClick={handleAdd}>
<Plus data-icon='inline-start' />
{t('Add model')}
</Button>
}
/>
persistPricingData(editData, targetNames) {table.getRowModel().rows.length === 0 ? (
table.resetRowSelection() <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
toast.success( {table.getState().globalFilter
t('Applied {{name}} pricing to {{count}} models', { ? t('No models match your search')
name: editData.name, : t('No models configured. Use Add model to get started.')}
count: targetNames.length, </div>
}) ) : (
) <div className='overflow-hidden rounded-md border'>
}, [editData, persistPricingData, t, table]) <Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() ? 'selected' : undefined}
className={
editData?.name === row.original.name
? 'bg-muted/45'
: undefined
}
onClick={(event) => {
const target = event.target as HTMLElement
if (target.closest('button, [role="checkbox"]')) return
handleEdit(row.original)
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length {table.getRowModel().rows.length > 0 && (
<DataTablePagination table={table} />
)}
</div>
return ( <div className='hidden min-w-0 md:block'>
<div className='flex flex-col gap-4'> {editorOpen ? (
<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)]'> <ModelPricingEditorPanel
<div className='flex min-w-0 flex-col gap-4'> ref={editorPanelRef}
<DataTableToolbar onSave={handleSave}
table={table} onCancel={handleCancel}
searchPlaceholder={t('Search models...')} editData={editData}
filters={[ selectedTargetCount={selectedTargetCount}
{ className='sticky top-4 h-[calc(100vh-8rem)] min-h-[620px]'
columnId: 'billingMode',
title: t('Mode'),
options: [
{
label: 'Per-token',
value: 'per-token',
count: modeCounts['per-token'],
},
{
label: 'Per-request',
value: 'per-request',
count: modeCounts['per-request'],
},
{
label: 'Expression',
value: 'tiered_expr',
count: modeCounts.tiered_expr,
},
],
},
]}
preActions={
<Button onClick={handleAdd}>
<Plus data-icon='inline-start' />
{t('Add model')}
</Button>
}
/> />
) : (
{table.getRowModel().rows.length === 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='text-muted-foreground rounded-lg border border-dashed p-8 text-center'> <div className='text-foreground text-base font-medium'>
{table.getState().globalFilter {t('Select a model to edit pricing')}
? t('No models match your search')
: t('No models configured. Use Add model to get started.')}
</div>
) : (
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() ? 'selected' : undefined
}
className={
editData?.name === row.original.name
? 'bg-muted/45'
: undefined
}
onClick={(event) => {
const target = event.target as HTMLElement
if (target.closest('button, [role="checkbox"]'))
return
handleEdit(row.original)
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{table.getRowModel().rows.length > 0 && (
<DataTablePagination table={table} />
)}
</div>
<div className='hidden min-w-0 md:block'>
{editorOpen ? (
<ModelPricingEditorPanel
onSave={handleSave}
onCancel={handleCancel}
editData={editData}
selectedTargetCount={selectedTargetCount}
className='sticky top-4 h-[calc(100vh-8rem)] min-h-[620px]'
/>
) : (
<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='text-foreground text-base font-medium'>
{t('Select a model to edit pricing')}
</div>
<p className='max-w-sm text-sm'>
{t(
'Use the full-width table to scan prices, then select a row to edit it here.'
)}
</p>
<Button variant='outline' onClick={handleAdd}>
<Plus data-icon='inline-start' />
{t('Add model')}
</Button>
</div> </div>
)} <p className='max-w-sm text-sm'>
</div> {t(
'Use the full-width table to scan prices, then select a row to edit it here.'
)}
</p>
<Button variant='outline' onClick={handleAdd}>
<Plus data-icon='inline-start' />
{t('Add model')}
</Button>
</div>
)}
</div> </div>
</div>
<DataTableBulkActions table={table} entityName={t('model')}> <DataTableBulkActions table={table} entityName={t('model')}>
<Button size='sm' disabled={!editData} onClick={handleBatchCopy}> <Button size='sm' disabled={!editData} onClick={handleBatchCopy}>
<Copy data-icon='inline-start' /> <Copy data-icon='inline-start' />
{editData {editData
? t('Copy {{name}} pricing', { name: editData.name }) ? t('Copy {{name}} pricing', { name: editData.name })
: t('Open a source model first')} : t('Open a source model first')}
</Button> </Button>
</DataTableBulkActions> </DataTableBulkActions>
{isMobile && ( {isMobile && (
<ModelPricingSheet <ModelPricingSheet
open={sheetOpen} ref={editorPanelRef}
onOpenChange={setSheetOpen} open={sheetOpen}
onSave={handleSave} onOpenChange={setSheetOpen}
onCancel={handleCancel} onSave={handleSave}
editData={editData} onCancel={handleCancel}
selectedTargetCount={selectedTargetCount} editData={editData}
/> selectedTargetCount={selectedTargetCount}
)} />
</div> )}
) </div>
}, )
})
export const ModelRatioVisualEditor = memo(
ModelRatioVisualEditorComponent,
// Custom equality check - only re-render if JSON props actually changed // Custom equality check - only re-render if JSON props actually changed
(prevProps, nextProps) => { (prevProps, nextProps) => {
return ( return (
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment