/* 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, forwardRef, useImperativeHandle, useRef, } from 'react' import { 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, Plus } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { DataTableBulkActions, DataTableToolbar, DataTablePagination, } from '@/components/data-table' import { combineBillingExpr } from '@/features/pricing/lib/billing-expr' import { safeJsonParse } from '../utils/json-parser' import { ModelPricingEditorPanel, type ModelPricingEditorPanelHandle, ModelPricingSheet, type ModelRatioData, } from './model-pricing-sheet' import { buildModelSnapshots, getSnapshotSignature, type ModelRow, } from './model-pricing-snapshots' import { buildModelRatioColumns } from './model-ratio-table-columns' 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 createCacheRatio: string completionRatio: string imageRatio: string audioRatio: string audioCompletionRatio: string billingMode: string billingExpr: string onChange: (field: string, value: string) => void } export type ModelRatioVisualEditorHandle = { commitOpenEditor: () => Promise } const STORAGE_KEY = 'model-ratio-column-visibility' const ModelRatioVisualEditorComponent = forwardRef< ModelRatioVisualEditorHandle, ModelRatioVisualEditorProps >(function ModelRatioVisualEditor( { savedModelPrice, savedModelRatio, savedCacheRatio, savedCreateCacheRatio, savedCompletionRatio, savedImageRatio, savedAudioRatio, savedAudioCompletionRatio, savedBillingMode, savedBillingExpr, modelPrice, modelRatio, cacheRatio, createCacheRatio, completionRatio, imageRatio, audioRatio, audioCompletionRatio, billingMode, billingExpr, onChange, }, ref ) { 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 editorPanelRef = useRef(null) 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 savedRows = buildModelSnapshots({ modelPrice: savedModelPrice, modelRatio: savedModelRatio, cacheRatio: savedCacheRatio, createCacheRatio: savedCreateCacheRatio, completionRatio: savedCompletionRatio, imageRatio: savedImageRatio, audioRatio: savedAudioRatio, audioCompletionRatio: savedAudioCompletionRatio, billingMode: savedBillingMode, billingExpr: savedBillingExpr, }) const draftRows = buildModelSnapshots({ modelPrice, modelRatio, cacheRatio, createCacheRatio, completionRatio, imageRatio, audioRatio, audioCompletionRatio, billingMode, billingExpr, }) 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()]) 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) return { ...displayed!, saved, draft, isDraftChanged: savedSignature !== draftSignature, isDraftDeleted: Boolean(saved && !draft), isDraftNew: Boolean(!saved && draft), } }) .sort((a, b) => a.name.localeCompare(b.name)) }, [ savedModelPrice, savedModelRatio, savedCacheRatio, savedCreateCacheRatio, savedCompletionRatio, savedImageRatio, savedAudioRatio, savedAudioCompletionRatio, savedBillingMode, savedBillingExpr, 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) => { const editableModel = model.draft ?? model.saved ?? model setEditData({ 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: editableModel.billingMode === 'tiered_expr' ? 'tiered_expr' : editableModel.price && editableModel.price !== '' ? 'per-request' : 'per-token', billingExpr: editableModel.billingExpr, requestRuleExpr: editableModel.requestRuleExpr, }) setEditorOpen(true) if (isMobile) setSheetOpen(true) }, [isMobile] ) const handleAdd = useCallback(() => { setEditData(null) setEditorOpen(true) if (isMobile) setSheetOpen(true) }, [isMobile]) 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( () => buildModelRatioColumns({ onDelete: handleDelete, onEdit: handleEdit, t, }), [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 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]) useImperativeHandle( ref, () => ({ commitOpenEditor: async () => { if (!editorOpen || !editorPanelRef.current) return true const data = await editorPanelRef.current.commitDraft() if (!data) return false persistPricingData(data) setEditData(data) return true }, }), [editorOpen, persistPricingData] ) 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) => ( ))} ))} {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) => ( ))} ))}
{header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )}
{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.' )}

)}
{isMobile && ( )}
) }) export const ModelRatioVisualEditor = memo( ModelRatioVisualEditorComponent, // 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 ) } )