feat: add unset price models tab to model pricing settings (#6124)

* feat: add unset price models tab to model pricing settings

* feat: add unset price models tab translations
This commit is contained in:
feitianbubu
2026-07-11 22:48:02 +08:00
committed by GitHub
parent 7a2b9d86e8
commit 8283df169e
13 changed files with 167 additions and 74 deletions
@@ -110,7 +110,7 @@ const BILLING_SECTIONS = [
modelDefaults={getModelDefaults(settings)}
groupDefaults={getGroupDefaults(settings)}
toolPricesDefault={settings['tool_price_setting.prices']}
visibleTabs={['models', 'tool-prices', 'upstream-sync']}
visibleTabs={['models', 'unset-models', 'tool-prices', 'upstream-sync']}
/>
),
},
@@ -61,6 +61,12 @@ export type ModelRow = ModelPricingSnapshot & {
export const hasPricingValue = (value?: string) =>
value !== undefined && value !== ''
export const isBasePricingUnset = (snapshot?: ModelPricingSnapshot) =>
!snapshot ||
(snapshot.billingMode !== 'tiered_expr' &&
!hasPricingValue(snapshot.price) &&
!hasPricingValue(snapshot.ratio))
const toNumberOrNull = (value?: string) => {
if (!hasPricingValue(value)) return null
const num = Number(value)
@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { Code2, Eye, RotateCcw, Save } from 'lucide-react'
import { memo, useCallback, useRef, useState } from 'react'
import { type UseFormReturn } from 'react-hook-form'
@@ -33,6 +34,7 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import { getEnabledModels } from '@/features/channels/api'
import {
SettingsForm,
@@ -65,6 +67,7 @@ type ModelRatioFormProps = {
onReset: () => void
isSaving: boolean
isResetting: boolean
variant?: 'default' | 'unset'
}
type ModelJsonFieldName =
@@ -164,11 +167,19 @@ export const ModelRatioForm = memo(function ModelRatioForm({
onReset,
isSaving,
isResetting,
variant = 'default',
}: ModelRatioFormProps) {
const { t } = useTranslation()
const isUnsetVariant = variant === 'unset'
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
const visualEditorRef = useRef<ModelRatioVisualEditorHandle>(null)
const enabledModelsQuery = useQuery({
queryKey: ['enabled-models'],
queryFn: getEnabledModels,
enabled: isUnsetVariant,
})
const handleFieldChange = useCallback(
(field: keyof ModelFormValues, value: string) => {
form.setValue(field, value, {
@@ -194,42 +205,44 @@ export const ModelRatioForm = memo(function ModelRatioForm({
return (
<div className='space-y-6'>
<div className='flex flex-wrap justify-end gap-2'>
<Button
type='button'
variant='destructive'
size='sm'
onClick={onReset}
disabled={isResetting}
>
<RotateCcw data-icon='inline-start' />
{t('Reset prices')}
</Button>
{editMode === 'json' && (
{!isUnsetVariant && (
<div className='flex flex-wrap justify-end gap-2'>
<Button
type='button'
variant='destructive'
size='sm'
onClick={handleSave}
disabled={isSaving}
onClick={onReset}
disabled={isResetting}
>
<Save data-icon='inline-start' />
{isSaving ? t('Saving...') : t('Save model prices')}
<RotateCcw data-icon='inline-start' />
{t('Reset prices')}
</Button>
)}
<Button variant='outline' size='sm' onClick={toggleEditMode}>
{editMode === 'visual' ? (
<>
<Code2 className='mr-2 h-4 w-4' />
{t('Switch to JSON')}
</>
) : (
<>
<Eye className='mr-2 h-4 w-4' />
{t('Switch to Visual')}
</>
{editMode === 'json' && (
<Button
type='button'
size='sm'
onClick={handleSave}
disabled={isSaving}
>
<Save data-icon='inline-start' />
{isSaving ? t('Saving...') : t('Save model prices')}
</Button>
)}
</Button>
</div>
<Button variant='outline' size='sm' onClick={toggleEditMode}>
{editMode === 'visual' ? (
<>
<Code2 className='mr-2 h-4 w-4' />
{t('Switch to JSON')}
</>
) : (
<>
<Eye className='mr-2 h-4 w-4' />
{t('Switch to Visual')}
</>
)}
</Button>
</div>
)}
<Form {...form}>
{editMode === 'visual' ? (
@@ -256,6 +269,10 @@ export const ModelRatioForm = memo(function ModelRatioForm({
audioCompletionRatio={form.watch('AudioCompletionRatio')}
billingMode={form.watch('BillingMode')}
billingExpr={form.watch('BillingExpr')}
candidateModelNames={
isUnsetVariant ? enabledModelsQuery.data?.data : undefined
}
filterMode={isUnsetVariant ? 'unset' : 'all'}
onSave={handleSave}
isSaving={isSaving}
onChange={(field, value) => {
@@ -269,28 +286,30 @@ export const ModelRatioForm = memo(function ModelRatioForm({
}}
/>
<FormField
control={form.control}
name='ExposeRatioEnabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Expose ratio API')}</FormLabel>
<FormDescription>
{t(
'Allow clients to query configured ratios via `/api/ratio`.'
)}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
{!isUnsetVariant && (
<FormField
control={form.control}
name='ExposeRatioEnabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Expose ratio API')}</FormLabel>
<FormDescription>
{t(
'Allow clients to query configured ratios via `/api/ratio`.'
)}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
)}
</div>
) : (
<SettingsForm onSubmit={form.handleSubmit(onSave)}>
@@ -42,12 +42,14 @@ const filterBySelectedValues = (
type BuildModelRatioColumnsOptions = {
onDelete: (name: string) => void
onEdit: (model: ModelRow) => void
deleteDisabled?: boolean
t: (key: string) => string
}
export function buildModelRatioColumns({
onDelete,
onEdit,
deleteDisabled,
t,
}: BuildModelRatioColumnsOptions): ColumnDef<ModelRow>[] {
return [
@@ -151,6 +153,7 @@ export function buildModelRatioColumns({
menuLabel={t('Open menu')}
onEdit={() => onEdit(row.original)}
onDelete={() => onDelete(row.original.name)}
deleteDisabled={deleteDisabled}
/>
),
enableHiding: false,
@@ -60,6 +60,7 @@ import {
import {
buildModelSnapshots,
getSnapshotSignature,
isBasePricingUnset,
type ModelRow,
} from './model-pricing-snapshots'
import { buildModelRatioColumns } from './model-ratio-table-columns'
@@ -85,6 +86,8 @@ type ModelRatioVisualEditorProps = {
audioCompletionRatio: string
billingMode: string
billingExpr: string
candidateModelNames?: string[]
filterMode?: 'all' | 'unset'
onChange: (field: string, value: string) => void
onSave: () => void | Promise<void>
isSaving: boolean
@@ -121,6 +124,8 @@ const ModelRatioVisualEditorComponent = forwardRef<
audioCompletionRatio,
billingMode,
billingExpr,
candidateModelNames,
filterMode = 'all',
onChange,
onSave,
isSaving,
@@ -208,18 +213,23 @@ const ModelRatioVisualEditorComponent = forwardRef<
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 modelNames = new Set([
...(candidateModelNames ?? []),
...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 displayed = saved ??
draft ?? { name, billingMode: 'per-token', hasConflict: false }
const savedSignature = getSnapshotSignature(saved)
const draftSignature = getSnapshotSignature(draft)
return {
...displayed!,
...displayed,
saved,
draft,
isDraftChanged: savedSignature !== draftSignature,
@@ -228,8 +238,11 @@ const ModelRatioVisualEditorComponent = forwardRef<
}
})
.filter((row) => !row.isDraftDeleted)
.filter((row) => filterMode !== 'unset' || isBasePricingUnset(row.saved))
.sort((a, b) => a.name.localeCompare(b.name))
}, [
candidateModelNames,
filterMode,
savedModelPrice,
savedModelRatio,
savedCacheRatio,
@@ -423,14 +436,25 @@ const ModelRatioVisualEditorComponent = forwardRef<
buildModelRatioColumns({
onDelete: handleDelete,
onEdit: handleEdit,
deleteDisabled: filterMode === 'unset',
t,
}),
[handleEdit, handleDelete, t]
[handleEdit, handleDelete, filterMode, t]
)
const ensurePageInRange = useCallback((pageCount: number) => {
setPagination((prev) =>
pageCount > 0 && prev.pageIndex >= pageCount
? { ...prev, pageIndex: pageCount - 1 }
: prev
)
}, [])
const { table } = useDataTable({
data: models,
columns,
getRowId: (row) => row.name,
ensurePageInRange,
sorting,
columnFilters,
globalFilter,
@@ -585,12 +609,20 @@ const ModelRatioVisualEditorComponent = forwardRef<
]
)
const handleBatchCopy = useCallback(() => {
const handleBatchCopy = useCallback(async () => {
if (!editData) {
toast.error(t('Open a source model first'))
return
}
let sourceData = editData
if (editorOpen && editorPanelRef.current) {
const committed = await editorPanelRef.current.commitDraft()
if (!committed) return
sourceData = committed
setEditData(committed)
}
const targetNames = table
.getFilteredSelectedRowModel()
.rows.map((row) => row.original.name)
@@ -600,15 +632,15 @@ const ModelRatioVisualEditorComponent = forwardRef<
return
}
persistPricingData(editData, targetNames)
persistPricingData(sourceData, targetNames)
table.resetRowSelection()
toast.success(
t('Applied {{name}} pricing to {{count}} models', {
name: editData.name,
name: sourceData.name,
count: targetNames.length,
})
)
}, [editData, persistPricingData, t, table])
}, [editData, editorOpen, persistPricingData, t, table])
useImperativeHandle(
ref,
@@ -627,6 +659,13 @@ const ModelRatioVisualEditorComponent = forwardRef<
const hasRows = table.getRowModel().rows.length > 0
let emptyStateText = t('No models configured. Use Add model to get started.')
if (table.getState().globalFilter) {
emptyStateText = t('No models match your search')
} else if (filterMode === 'unset') {
emptyStateText = t('No models with unset prices')
}
return (
<div className='flex 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)]'>
@@ -658,18 +697,18 @@ const ModelRatioVisualEditorComponent = forwardRef<
},
]}
preActions={
<Button onClick={handleAdd}>
<Plus data-icon='inline-start' />
{t('Add model')}
</Button>
filterMode === 'unset' ? undefined : (
<Button onClick={handleAdd}>
<Plus data-icon='inline-start' />
{t('Add model')}
</Button>
)
}
/>
{!hasRows ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
{table.getState().globalFilter
? t('No models match your search')
: t('No models configured. Use Add model to get started.')}
{emptyStateText}
</div>
) : (
<DataTableView
@@ -743,10 +782,12 @@ const ModelRatioVisualEditorComponent = forwardRef<
'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>
{filterMode !== 'unset' && (
<Button variant='outline' onClick={handleAdd}>
<Plus data-icon='inline-start' />
{t('Add model')}
</Button>
)}
</div>
)}
</div>
@@ -790,6 +831,8 @@ export const ModelRatioVisualEditor = memo(
prevProps.audioCompletionRatio === nextProps.audioCompletionRatio &&
prevProps.billingMode === nextProps.billingMode &&
prevProps.billingExpr === nextProps.billingExpr &&
prevProps.candidateModelNames === nextProps.candidateModelNames &&
prevProps.filterMode === nextProps.filterMode &&
prevProps.onChange === nextProps.onChange &&
prevProps.onSave === nextProps.onSave &&
prevProps.isSaving === nextProps.isSaving
@@ -136,7 +136,12 @@ const createGroupSchema = (t: Translate) =>
type ModelFormValues = z.infer<ReturnType<typeof createModelSchema>>
type GroupFormValues = z.infer<ReturnType<typeof createGroupSchema>>
type RatioTabId = 'models' | 'groups' | 'tool-prices' | 'upstream-sync'
type RatioTabId =
| 'models'
| 'unset-models'
| 'groups'
| 'tool-prices'
| 'upstream-sync'
type RatioSettingsCardProps = {
modelDefaults: ModelFormValues
@@ -392,6 +397,7 @@ export function RatioSettingsCard({
const tabLabels: Record<RatioTabId, string> = {
models: 'Model prices',
'unset-models': 'Unset price models',
groups: 'Group ratios',
'tool-prices': 'Tool prices',
'upstream-sync': 'Upstream price sync',
@@ -402,11 +408,12 @@ export function RatioSettingsCard({
2: 'grid-cols-2',
3: 'grid-cols-3',
4: 'grid-cols-4',
5: 'grid-cols-5',
}[visibleTabs.length] ?? 'grid-cols-4'
const defaultTab = visibleTabs[0] ?? 'models'
const renderTabContent = (tab: RatioTabId) => {
if (tab === 'models') {
if (tab === 'models' || tab === 'unset-models') {
return (
<ModelRatioForm
form={modelForm}
@@ -415,6 +422,7 @@ export function RatioSettingsCard({
onReset={handleResetRatios}
isSaving={updateOption.isPending}
isResetting={resetMutation.isPending}
variant={tab === 'unset-models' ? 'unset' : 'default'}
/>
)
}
+2
View File
@@ -2925,6 +2925,7 @@
"No models to add": "No models to add",
"No models to copy": "No models to copy",
"No models to remove": "No models to remove",
"No models with unset prices": "No models with unset prices",
"No new models to add": "No new models to add",
"No new models yet": "No new models yet",
"No nodes": "No nodes",
@@ -4764,6 +4765,7 @@
"Unlimited Quota": "Unlimited Quota",
"Unsaved changes": "Unsaved changes",
"Unset price": "Unset price",
"Unset price models": "Unset price models",
"Until": "Until",
"Untitled": "Untitled",
"Untrusted upstream data:": "Untrusted upstream data:",
+2
View File
@@ -2925,6 +2925,7 @@
"No models to add": "Aucun modèle à ajouter",
"No models to copy": "Aucun modèle à copier",
"No models to remove": "Aucun modèle à supprimer",
"No models with unset prices": "Aucun modèle sans prix",
"No new models to add": "Aucun nouveau modèle à ajouter",
"No new models yet": "Pas encore de nouveaux modèles",
"No nodes": "Aucun nœud",
@@ -4764,6 +4765,7 @@
"Unlimited Quota": "Quota illimité",
"Unsaved changes": "Modifications non enregistrées",
"Unset price": "Prix non défini",
"Unset price models": "Modèles sans prix",
"Until": "Jusqu'au",
"Untitled": "Sans titre",
"Untrusted upstream data:": "Données amont non fiables :",
+2
View File
@@ -2925,6 +2925,7 @@
"No models to add": "追加するモデルがありません",
"No models to copy": "コピーするモデルがありません",
"No models to remove": "削除するモデルがありません",
"No models with unset prices": "価格未設定のモデルはありません",
"No new models to add": "追加する新しいモデルはありません",
"No new models yet": "新しいモデルはまだありません",
"No nodes": "ノードなし",
@@ -4764,6 +4765,7 @@
"Unlimited Quota": "無制限のクォータ",
"Unsaved changes": "未保存の変更",
"Unset price": "価格未設定",
"Unset price models": "価格が未設定のモデル",
"Until": "まで",
"Untitled": "無題",
"Untrusted upstream data:": "信頼されていないアップストリームデータ:",
+2
View File
@@ -2925,6 +2925,7 @@
"No models to add": "Нет моделей для добавления",
"No models to copy": "Нет моделей для копирования",
"No models to remove": "Нет моделей для удаления",
"No models with unset prices": "Нет моделей без цены",
"No new models to add": "Нет новых моделей для добавления",
"No new models yet": "Новых моделей пока нет",
"No nodes": "Нет узлов",
@@ -4764,6 +4765,7 @@
"Unlimited Quota": "Неограниченная квота",
"Unsaved changes": "Несохранённые изменения",
"Unset price": "Цена не задана",
"Unset price models": "Модели с неустановленной ценой",
"Until": "До",
"Untitled": "Без названия",
"Untrusted upstream data:": "Недоверенные вышестоящие данные:",
+2
View File
@@ -2925,6 +2925,7 @@
"No models to add": "Không có mô hình để thêm",
"No models to copy": "Không có mô hình nào để sao chép",
"No models to remove": "Không có mô hình để xóa",
"No models with unset prices": "Không có mô hình chưa thiết lập giá",
"No new models to add": "Không có mô hình mới để thêm",
"No new models yet": "Chưa có mô hình mới",
"No nodes": "Không có nút",
@@ -4764,6 +4765,7 @@
"Unlimited Quota": "Hạn mức không giới hạn",
"Unsaved changes": "Thay đổi chưa được lưu",
"Unset price": "Chưa đặt giá",
"Unset price models": "Mô hình chưa thiết lập giá",
"Until": "Cho đến",
"Untitled": "Không có tiêu đề",
"Untrusted upstream data:": "Dữ liệu nguồn không đáng tin cậy:",
+2
View File
@@ -2925,6 +2925,7 @@
"No models to add": "無待新增模型",
"No models to copy": "沒有模型可複製",
"No models to remove": "無待刪除模型",
"No models with unset prices": "沒有未設定定價的模型",
"No new models to add": "沒有新模型可新增",
"No new models yet": "暫無新模型",
"No nodes": "無節點",
@@ -4764,6 +4765,7 @@
"Unlimited Quota": "無限配額",
"Unsaved changes": "未儲存的變更",
"Unset price": "未設定價格",
"Unset price models": "未設定價格模型",
"Until": "至",
"Untitled": "未命名",
"Untrusted upstream data:": "不受信任的上游數據:",
+2
View File
@@ -2925,6 +2925,7 @@
"No models to add": "无待新增模型",
"No models to copy": "没有模型可复制",
"No models to remove": "无待删除模型",
"No models with unset prices": "没有未设置定价的模型",
"No new models to add": "没有新模型可添加",
"No new models yet": "暂无新模型",
"No nodes": "无节点",
@@ -4764,6 +4765,7 @@
"Unlimited Quota": "无限配额",
"Unsaved changes": "未保存的更改",
"Unset price": "未设置价格",
"Unset price models": "未设置价格模型",
"Until": "至",
"Untitled": "未命名",
"Untrusted upstream data:": "不受信任的上游数据:",