/* Copyright (C) 2023-2026 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useState, useMemo, memo, useCallback, useEffect } from 'react' import { type ColumnDef, type ColumnFiltersState, type OnChangeFn, type PaginationState, type RowSelectionState, type VisibilityState, type SortingState, flexRender, getCoreRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getSortedRowModel, getPaginationRowModel, useReactTable, } from '@tanstack/react-table' import { useMediaQuery } from '@/hooks' import { Copy, Pencil, Plus, Trash2 } from 'lucide-react' 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, DataTableToolbar, DataTablePagination, } from '@/components/data-table' import { StatusBadge } from '@/components/status-badge' import { combineBillingExpr, splitBillingExprAndRequestRules, } from '@/features/pricing/lib/billing-expr' import { safeJsonParse } from '../utils/json-parser' import { ModelPricingEditorPanel, ModelPricingSheet, type ModelRatioData, } from './model-pricing-sheet' import { formatPricingNumber } from './pricing-format' type ModelRatioVisualEditorProps = { modelPrice: string modelRatio: string cacheRatio: string createCacheRatio: string completionRatio: string imageRatio: string audioRatio: string audioCompletionRatio: string billingMode: string billingExpr: string onChange: (field: string, value: string) => void } type ModelRow = { name: string price?: string ratio?: string cacheRatio?: string createCacheRatio?: string completionRatio?: string imageRatio?: string audioRatio?: string audioCompletionRatio?: string billingMode?: string billingExpr?: string requestRuleExpr?: string hasConflict: boolean } const STORAGE_KEY = 'model-ratio-column-visibility' const hasValue = (value?: string) => value !== undefined && value !== '' const toNumberOrNull = (value?: string) => { if (!hasValue(value)) return null const num = Number(value) return Number.isFinite(num) ? num : null } const ratioToPrice = (ratio?: string, denominator?: string) => { const ratioNumber = toNumberOrNull(ratio) const denominatorNumber = denominator ? toNumberOrNull(denominator) : 2 if (ratioNumber === null || denominatorNumber === null) return '' return formatPricingNumber(ratioNumber * denominatorNumber) } const filterBySelectedValues = ( rowValue: unknown, filterValue: unknown ): boolean => { if (!Array.isArray(filterValue) || filterValue.length === 0) return true return filterValue.includes(String(rowValue)) } const getModeLabel = (mode?: string) => { if (mode === 'per-request') return 'Per-request' if (mode === 'tiered_expr') return 'Expression' return 'Per-token' } const getModeVariant = (mode?: string): 'warning' | 'info' | 'success' => { if (mode === 'per-request') return 'warning' if (mode === 'tiered_expr') return 'info' return 'success' } const getExpressionSummary = (row: ModelRow, t: (key: string) => string) => { const tierCount = (row.billingExpr?.match(/tier\(/g) || []).length if (tierCount > 0) { return `${t('Tiered pricing')} · ${tierCount} ${t('tiers')}` } return t('Expression pricing') } const getPriceSummary = (row: ModelRow, t: (key: string) => string) => { if (row.billingMode === 'tiered_expr') { return getExpressionSummary(row, t) } if (row.billingMode === 'per-request') { return row.price ? `$${row.price} / ${t('request')}` : t('Unset price') } const inputPrice = ratioToPrice(row.ratio) if (!inputPrice) return t('Unset price') const extraCount = [ row.completionRatio, row.cacheRatio, row.createCacheRatio, row.imageRatio, row.audioRatio, row.audioCompletionRatio, ].filter(hasValue).length return extraCount > 0 ? `${t('Input')} $${inputPrice} · ${extraCount} ${t('extras')}` : `${t('Input')} $${inputPrice}` } const getPriceDetail = (row: ModelRow, t: (key: string) => string) => { if (row.billingMode === 'tiered_expr') { return row.requestRuleExpr ? t('Includes request rules') : t('Expression based') } if (row.billingMode === 'per-request') { return t('Fixed request price') } const inputPrice = ratioToPrice(row.ratio) if (!inputPrice) return t('No base input price') const details = [ row.completionRatio && `${t('Output')} $${ratioToPrice(row.completionRatio, inputPrice)}`, row.cacheRatio && `${t('Cache')} $${ratioToPrice(row.cacheRatio, inputPrice)}`, row.createCacheRatio && `${t('Cache write')} $${ratioToPrice(row.createCacheRatio, inputPrice)}`, ].filter(Boolean) return details.length > 0 ? details.join(' · ') : t('Base input price only') } export const ModelRatioVisualEditor = memo( function ModelRatioVisualEditor({ modelPrice, modelRatio, cacheRatio, createCacheRatio, completionRatio, imageRatio, audioRatio, audioCompletionRatio, billingMode, billingExpr, onChange, }: ModelRatioVisualEditorProps) { const { t } = useTranslation() const isMobile = useMediaQuery('(max-width: 767px)') const [sheetOpen, setSheetOpen] = useState(false) const [editorOpen, setEditorOpen] = useState(false) const [editData, setEditData] = useState(null) const [sorting, setSorting] = useState([]) const [columnFilters, setColumnFilters] = useState([]) const [globalFilter, setGlobalFilter] = useState('') const [rowSelection, setRowSelection] = useState({}) const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20, }) const [columnVisibility, setColumnVisibility] = useState( () => { const saved = localStorage.getItem(STORAGE_KEY) if (saved) { try { return safeJsonParse(saved, { fallback: { cacheRatio: false, createCacheRatio: false, imageRatio: false, audioRatio: 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>(modelPrice, { fallback: {}, context: 'model prices', }) const ratioMap = safeJsonParse>(modelRatio, { fallback: {}, context: 'model ratios', }) const cacheMap = safeJsonParse>(cacheRatio, { fallback: {}, context: 'cache ratios', }) const createCacheMap = safeJsonParse>( createCacheRatio, { fallback: {}, context: 'create cache ratios' } ) const completionMap = safeJsonParse>( completionRatio, { fallback: {}, context: 'completion ratios' } ) const imageMap = safeJsonParse>(imageRatio, { fallback: {}, context: 'image ratios', }) const audioMap = safeJsonParse>(audioRatio, { fallback: {}, context: 'audio ratios', }) const audioCompletionMap = safeJsonParse>( audioCompletionRatio, { fallback: {}, context: 'audio completion ratios' } ) const billingModeMap = safeJsonParse>( billingMode, { fallback: {}, context: 'billing mode', } ) const billingExprMap = safeJsonParse>( 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 { 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, 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>( (updater) => { setGlobalFilter((previous) => { const next = typeof updater === 'function' ? updater(previous) : updater if (next !== previous) { setEditData(null) setEditorOpen(false) setSheetOpen(false) } return next }) }, [] ) const handleDelete = useCallback( (name: string) => { const priceMap = safeJsonParse>(modelPrice, { fallback: {}, silent: true, }) const ratioMap = safeJsonParse>(modelRatio, { fallback: {}, silent: true, }) const cacheMap = safeJsonParse>(cacheRatio, { fallback: {}, silent: true, }) const createCacheMap = safeJsonParse>( createCacheRatio, { fallback: {}, silent: true } ) const completionMap = safeJsonParse>( completionRatio, { fallback: {}, silent: true } ) const imageMap = safeJsonParse>(imageRatio, { fallback: {}, silent: true, }) const audioMap = safeJsonParse>(audioRatio, { fallback: {}, silent: true, }) const audioCompletionMap = safeJsonParse>( audioCompletionRatio, { fallback: {}, silent: true } ) const billingModeMap = safeJsonParse>( billingMode, { fallback: {}, silent: true } ) const billingExprMap = safeJsonParse>( billingExpr, { fallback: {}, silent: true } ) delete priceMap[name] delete ratioMap[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] onChange('ModelPrice', JSON.stringify(priceMap, null, 2)) onChange('ModelRatio', JSON.stringify(ratioMap, null, 2)) onChange('CacheRatio', JSON.stringify(cacheMap, null, 2)) onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2)) onChange('CompletionRatio', JSON.stringify(completionMap, null, 2)) onChange('ImageRatio', JSON.stringify(imageMap, null, 2)) 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, ] ) const columns = useMemo[]>(() => { return [ { id: 'select', header: ({ table }) => ( table.toggleAllPageRowsSelected(!!value) } aria-label={t('Select all')} className='translate-y-[2px]' /> ), cell: ({ row }) => ( row.toggleSelected(!!value)} aria-label={t('Select row')} className='translate-y-[2px]' /> ), enableSorting: false, enableHiding: false, meta: { label: t('Select') }, }, { accessorKey: 'name', header: ({ column }) => ( ), cell: ({ row }) => ( {row.getValue('name')} {row.original.billingMode === 'tiered_expr' && ( )} {row.original.hasConflict && ( )} ), enableHiding: false, }, { accessorKey: 'billingMode', header: ({ column }) => ( ), cell: ({ row }) => ( ), filterFn: (row, id, value) => filterBySelectedValues(row.getValue(id), value), meta: { label: t('Mode') }, }, { id: 'priceSummary', header: ({ column }) => ( ), cell: ({ row }) => ( {getPriceSummary(row.original, t)} {getPriceDetail(row.original, t)} ), sortingFn: (rowA, rowB) => getPriceSummary(rowA.original, t).localeCompare( getPriceSummary(rowB.original, t) ), meta: { label: t('Price summary') }, }, { id: 'actions', cell: ({ row }) => ( handleEdit(row.original)} > handleDelete(row.original.name)} > ), enableHiding: false, }, ] }, [handleEdit, handleDelete, t]) const table = useReactTable({ data: models, columns, 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 persistPricingData = useCallback( (data: ModelRatioData, targetNames: string[] = [data.name]) => { const priceMap = safeJsonParse>(modelPrice, { fallback: {}, silent: true, }) const ratioMap = safeJsonParse>(modelRatio, { fallback: {}, silent: true, }) const cacheMap = safeJsonParse>(cacheRatio, { fallback: {}, silent: true, }) const createCacheMap = safeJsonParse>( createCacheRatio, { fallback: {}, silent: true } ) const completionMap = safeJsonParse>( completionRatio, { fallback: {}, silent: true } ) const imageMap = safeJsonParse>(imageRatio, { fallback: {}, silent: true, }) const audioMap = safeJsonParse>(audioRatio, { fallback: {}, silent: true, }) const audioCompletionMap = safeJsonParse>( audioCompletionRatio, { fallback: {}, silent: true } ) const billingModeMap = safeJsonParse>( billingMode, { fallback: {}, silent: true } ) const billingExprMap = safeJsonParse>( billingExpr, { fallback: {}, silent: true } ) const setIfPresent = ( target: Record, name: string, value: string | undefined ) => { if (!value || value === '') return const parsed = parseFloat(value) if (Number.isFinite(parsed)) target[name] = parsed } targetNames.forEach((name) => { delete priceMap[name] delete ratioMap[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') { const combined = combineBillingExpr( data.billingExpr || '', 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)) onChange('ModelRatio', JSON.stringify(ratioMap, null, 2)) onChange('CacheRatio', JSON.stringify(cacheMap, null, 2)) onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2)) onChange('CompletionRatio', JSON.stringify(completionMap, null, 2)) onChange('ImageRatio', JSON.stringify(imageMap, null, 2)) 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, ] ) 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')) return } const targetNames = table .getFilteredSelectedRowModel() .rows.map((row) => row.original.name) if (targetNames.length === 0) { toast.error(t('Select at least one target model')) return } persistPricingData(editData, targetNames) table.resetRowSelection() toast.success( t('Applied {{name}} pricing to {{count}} models', { name: editData.name, count: targetNames.length, }) ) }, [editData, persistPricingData, t, table]) const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length return ( {t('Add model')} } /> {table.getRowModel().rows.length === 0 ? ( {table.getState().globalFilter ? t('No models match your search') : t('No models configured. Use Add model to get started.')} ) : ( {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} ))} ))} {table.getRowModel().rows.map((row) => ( { const target = event.target as HTMLElement if (target.closest('button, [role="checkbox"]')) return handleEdit(row.original) }} > {row.getVisibleCells().map((cell) => ( {flexRender( cell.column.columnDef.cell, cell.getContext() )} ))} ))} )} {table.getRowModel().rows.length > 0 && ( )} {editorOpen ? ( ) : ( {t('Select a model to edit pricing')} {t( 'Use the full-width table to scan prices, then select a row to edit it here.' )} {t('Add model')} )} {editData ? t('Copy {{name}} pricing', { name: editData.name }) : t('Open a source model first')} {isMobile && ( )} ) }, // Custom equality check - only re-render if JSON props actually changed (prevProps, nextProps) => { return ( prevProps.modelPrice === nextProps.modelPrice && prevProps.modelRatio === nextProps.modelRatio && prevProps.cacheRatio === nextProps.cacheRatio && prevProps.createCacheRatio === nextProps.createCacheRatio && prevProps.completionRatio === nextProps.completionRatio && prevProps.imageRatio === nextProps.imageRatio && prevProps.audioRatio === nextProps.audioRatio && prevProps.audioCompletionRatio === nextProps.audioCompletionRatio && prevProps.billingMode === nextProps.billingMode && prevProps.billingExpr === nextProps.billingExpr && prevProps.onChange === nextProps.onChange ) } )
{t( 'Use the full-width table to scan prices, then select a row to edit it here.' )}