From 8283df169ebfe84154e7acec6df748f770e03bf2 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Sat, 11 Jul 2026 22:48:02 +0800 Subject: [PATCH] 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 --- .../billing/section-registry.tsx | 2 +- .../models/model-pricing-snapshots.ts | 6 + .../models/model-ratio-form.tsx | 123 ++++++++++-------- .../models/model-ratio-table-columns.tsx | 3 + .../models/model-ratio-visual-editor.tsx | 81 +++++++++--- .../models/ratio-settings-card.tsx | 12 +- web/default/src/i18n/locales/en.json | 2 + web/default/src/i18n/locales/fr.json | 2 + web/default/src/i18n/locales/ja.json | 2 + web/default/src/i18n/locales/ru.json | 2 + web/default/src/i18n/locales/vi.json | 2 + web/default/src/i18n/locales/zh-TW.json | 2 + web/default/src/i18n/locales/zh.json | 2 + 13 files changed, 167 insertions(+), 74 deletions(-) diff --git a/web/default/src/features/system-settings/billing/section-registry.tsx b/web/default/src/features/system-settings/billing/section-registry.tsx index cc609476..eb5e6894 100644 --- a/web/default/src/features/system-settings/billing/section-registry.tsx +++ b/web/default/src/features/system-settings/billing/section-registry.tsx @@ -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']} /> ), }, diff --git a/web/default/src/features/system-settings/models/model-pricing-snapshots.ts b/web/default/src/features/system-settings/models/model-pricing-snapshots.ts index afc53533..553c73b2 100644 --- a/web/default/src/features/system-settings/models/model-pricing-snapshots.ts +++ b/web/default/src/features/system-settings/models/model-pricing-snapshots.ts @@ -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) diff --git a/web/default/src/features/system-settings/models/model-ratio-form.tsx b/web/default/src/features/system-settings/models/model-ratio-form.tsx index 64d38218..640d6090 100644 --- a/web/default/src/features/system-settings/models/model-ratio-form.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-form.tsx @@ -16,6 +16,7 @@ along with this program. If not, see . 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(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 (
-
- - {editMode === 'json' && ( + {!isUnsetVariant && ( +
- )} - )} - -
+ +
+ )}
{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({ }} /> - ( - - - {t('Expose ratio API')} - - {t( - 'Allow clients to query configured ratios via `/api/ratio`.' - )} - - - - - - - )} - /> + {!isUnsetVariant && ( + ( + + + {t('Expose ratio API')} + + {t( + 'Allow clients to query configured ratios via `/api/ratio`.' + )} + + + + + + + )} + /> + )}
) : ( diff --git a/web/default/src/features/system-settings/models/model-ratio-table-columns.tsx b/web/default/src/features/system-settings/models/model-ratio-table-columns.tsx index df4aa9dc..5a47b855 100644 --- a/web/default/src/features/system-settings/models/model-ratio-table-columns.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-table-columns.tsx @@ -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[] { 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, diff --git a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx index 099b2bfc..3606d2aa 100644 --- a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx @@ -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 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 (
@@ -658,18 +697,18 @@ const ModelRatioVisualEditorComponent = forwardRef< }, ]} preActions={ - + filterMode === 'unset' ? undefined : ( + + ) } /> {!hasRows ? (
- {table.getState().globalFilter - ? t('No models match your search') - : t('No models configured. Use Add model to get started.')} + {emptyStateText}
) : ( - + {filterMode !== 'unset' && ( + + )}
)}
@@ -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 diff --git a/web/default/src/features/system-settings/models/ratio-settings-card.tsx b/web/default/src/features/system-settings/models/ratio-settings-card.tsx index 767b8dfe..d705dfae 100644 --- a/web/default/src/features/system-settings/models/ratio-settings-card.tsx +++ b/web/default/src/features/system-settings/models/ratio-settings-card.tsx @@ -136,7 +136,12 @@ const createGroupSchema = (t: Translate) => type ModelFormValues = z.infer> type GroupFormValues = z.infer> -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 = { 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 ( ) } diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 2d0687f7..a9f5cec0 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -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:", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index dbb6b7c1..72157673 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -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 :", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 08354711..9845303e 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -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:": "信頼されていないアップストリームデータ:", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 724896b7..9765f086 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -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:": "Недоверенные вышестоящие данные:", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index ab15a0d0..bcef090c 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -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:", diff --git a/web/default/src/i18n/locales/zh-TW.json b/web/default/src/i18n/locales/zh-TW.json index e317e742..26addcd7 100644 --- a/web/default/src/i18n/locales/zh-TW.json +++ b/web/default/src/i18n/locales/zh-TW.json @@ -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:": "不受信任的上游數據:", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 1a12c09f..3da71a04 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -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:": "不受信任的上游数据:",