diff --git a/web/src/features/system-settings/models/model-pricing-core.test.ts b/web/src/features/system-settings/models/model-pricing-core.test.ts new file mode 100644 index 000000000000..4d184e03508f --- /dev/null +++ b/web/src/features/system-settings/models/model-pricing-core.test.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { buildModelPricingOptionUpdates } from './model-pricing-core' + +describe('model pricing option updates', () => { + test('uses per-token fields when switching away from stale per-request price', () => { + const updates = buildModelPricingOptionUpdates({ + current: { + modelPrice: JSON.stringify({ 'gpt-test': 0.01 }), + modelRatio: '{}', + cacheRatio: '{}', + createCacheRatio: '{}', + completionRatio: '{}', + imageRatio: '{}', + audioRatio: '{}', + audioCompletionRatio: '{}', + billingMode: JSON.stringify({ 'gpt-test': 'tiered_expr' }), + billingExpr: JSON.stringify({ 'gpt-test': 'tier("old", p)' }), + }, + data: { + name: 'gpt-test', + billingMode: 'per-token', + price: '0.01', + ratio: '1.5', + completionRatio: '2', + }, + }) + + assert.deepEqual(JSON.parse(updates.ModelPrice), {}) + assert.deepEqual(JSON.parse(updates.ModelRatio), { 'gpt-test': 1.5 }) + assert.deepEqual(JSON.parse(updates.CompletionRatio), { 'gpt-test': 2 }) + assert.deepEqual(JSON.parse(updates['billing_setting.billing_mode']), {}) + assert.deepEqual(JSON.parse(updates['billing_setting.billing_expr']), {}) + }) + + test('uses per-request price when switching away from stale token ratios', () => { + const updates = buildModelPricingOptionUpdates({ + current: { + modelPrice: '{}', + modelRatio: JSON.stringify({ 'gpt-test': 1.5 }), + cacheRatio: '{}', + createCacheRatio: '{}', + completionRatio: JSON.stringify({ 'gpt-test': 2 }), + imageRatio: '{}', + audioRatio: '{}', + audioCompletionRatio: '{}', + billingMode: '{}', + billingExpr: '{}', + }, + data: { + name: 'gpt-test', + billingMode: 'per-request', + price: '0.01', + ratio: '1.5', + completionRatio: '2', + }, + }) + + assert.deepEqual(JSON.parse(updates.ModelPrice), { 'gpt-test': 0.01 }) + assert.deepEqual(JSON.parse(updates.ModelRatio), {}) + assert.deepEqual(JSON.parse(updates.CompletionRatio), {}) + }) + + test('keeps fallback price and token ratios when switching into tiered expression mode', () => { + const updates = buildModelPricingOptionUpdates({ + current: { + modelPrice: JSON.stringify({ 'gpt-test': 0.01 }), + modelRatio: JSON.stringify({ 'gpt-test': 1.5 }), + cacheRatio: '{}', + createCacheRatio: '{}', + completionRatio: JSON.stringify({ 'gpt-test': 2 }), + imageRatio: '{}', + audioRatio: '{}', + audioCompletionRatio: '{}', + billingMode: '{}', + billingExpr: '{}', + }, + data: { + name: 'gpt-test', + billingMode: 'tiered_expr', + price: '0.02', + ratio: '2', + completionRatio: '3', + billingExpr: 'tier("base", p * 0 + c * 0)', + }, + }) + + assert.deepEqual(JSON.parse(updates.ModelPrice), { 'gpt-test': 0.02 }) + assert.deepEqual(JSON.parse(updates.ModelRatio), { 'gpt-test': 2 }) + assert.deepEqual(JSON.parse(updates.CompletionRatio), { 'gpt-test': 3 }) + assert.deepEqual(JSON.parse(updates['billing_setting.billing_mode']), { + 'gpt-test': 'tiered_expr', + }) + assert.deepEqual(JSON.parse(updates['billing_setting.billing_expr']), { + 'gpt-test': 'tier("base", p * 0 + c * 0)', + }) + }) +}) diff --git a/web/src/features/system-settings/models/model-pricing-core.ts b/web/src/features/system-settings/models/model-pricing-core.ts index d3d20a3b3ec0..5a76a57edb49 100644 --- a/web/src/features/system-settings/models/model-pricing-core.ts +++ b/web/src/features/system-settings/models/model-pricing-core.ts @@ -20,6 +20,7 @@ import * as z from 'zod' import { combineBillingExpr } from '@/features/pricing/lib/billing-expr' +import { safeJsonParse } from '../utils/json-parser' import { formatPricingNumber } from './pricing-format' export const createModelPricingSchema = (t: (key: string) => string) => @@ -64,6 +65,92 @@ export type ModelRatioData = { requestRuleExpr?: string } +export type ModelPricingOptionInput = { + modelPrice: string + modelRatio: string + cacheRatio: string + createCacheRatio: string + completionRatio: string + imageRatio: string + audioRatio: string + audioCompletionRatio: string + billingMode: string + billingExpr: string +} + +export type ModelPricingOptionUpdates = { + ModelPrice: string + ModelRatio: string + CacheRatio: string + CreateCacheRatio: string + CompletionRatio: string + ImageRatio: string + AudioRatio: string + AudioCompletionRatio: string + 'billing_setting.billing_mode': string + 'billing_setting.billing_expr': string +} + +type NumericOptionCurrentKey = + | 'modelPrice' + | 'modelRatio' + | 'cacheRatio' + | 'createCacheRatio' + | 'completionRatio' + | 'imageRatio' + | 'audioRatio' + | 'audioCompletionRatio' + +type NumericOptionOutputKey = + | 'ModelPrice' + | 'ModelRatio' + | 'CacheRatio' + | 'CreateCacheRatio' + | 'CompletionRatio' + | 'ImageRatio' + | 'AudioRatio' + | 'AudioCompletionRatio' + +type NumericOptionDataKey = + | 'price' + | 'ratio' + | 'cacheRatio' + | 'createCacheRatio' + | 'completionRatio' + | 'imageRatio' + | 'audioRatio' + | 'audioCompletionRatio' + +const pricingMapFields: Array<{ + currentKey: NumericOptionCurrentKey + outputKey: NumericOptionOutputKey + dataKey: NumericOptionDataKey +}> = [ + { currentKey: 'modelPrice', outputKey: 'ModelPrice', dataKey: 'price' }, + { currentKey: 'modelRatio', outputKey: 'ModelRatio', dataKey: 'ratio' }, + { currentKey: 'cacheRatio', outputKey: 'CacheRatio', dataKey: 'cacheRatio' }, + { + currentKey: 'createCacheRatio', + outputKey: 'CreateCacheRatio', + dataKey: 'createCacheRatio', + }, + { + currentKey: 'completionRatio', + outputKey: 'CompletionRatio', + dataKey: 'completionRatio', + }, + { currentKey: 'imageRatio', outputKey: 'ImageRatio', dataKey: 'imageRatio' }, + { currentKey: 'audioRatio', outputKey: 'AudioRatio', dataKey: 'audioRatio' }, + { + currentKey: 'audioCompletionRatio', + outputKey: 'AudioCompletionRatio', + dataKey: 'audioCompletionRatio', + }, +] + +const priceMapField = pricingMapFields[0] +const ratioMapFields = pricingMapFields.slice(1) + export type PreviewRow = { key: string label: string @@ -156,6 +243,110 @@ export function toNumberOrNull(value: unknown): number | null { return Number.isFinite(num) ? num : null } +export function buildModelPricingOptionUpdates({ + current, + data, + targetNames = [data.name], +}: { + current: ModelPricingOptionInput + data: ModelRatioData + targetNames?: string[] +}): ModelPricingOptionUpdates { + const pricingMaps = Object.fromEntries( + pricingMapFields.map((field) => [ + field.outputKey, + safeJsonParse>(current[field.currentKey], { + fallback: {}, + silent: true, + }), + ]) + ) as Record> + + const priceMap = pricingMaps.ModelPrice + + const setFieldIfPresent = ( + field: (typeof pricingMapFields)[number], + name: string + ) => { + setIfPresent(pricingMaps[field.outputKey], name, data[field.dataKey]) + } + + const setFieldsIfPresent = ( + fields: typeof pricingMapFields, + name: string + ) => { + fields.forEach((field) => setFieldIfPresent(field, name)) + } + + const billingModeMap = safeJsonParse>( + current.billingMode, + { fallback: {}, silent: true } + ) + const billingExprMap = safeJsonParse>( + current.billingExpr, + { fallback: {}, silent: true } + ) + + const setIfPresent = ( + target: Record, + name: string, + value: string | undefined + ) => { + if (!value || value === '') return + const parsed = Number.parseFloat(value) + if (Number.isFinite(parsed)) target[name] = parsed + } + + targetNames.forEach((name) => { + pricingMapFields.forEach((field) => { + delete pricingMaps[field.outputKey][name] + }) + delete billingModeMap[name] + delete billingExprMap[name] + + const mode = + data.billingMode || + (data.price && data.price !== '' ? 'per-request' : 'per-token') + + if (mode === 'tiered_expr') { + const combined = combineBillingExpr( + data.billingExpr || '', + data.requestRuleExpr || '' + ) + if (combined) { + billingModeMap[name] = 'tiered_expr' + billingExprMap[name] = combined + } + setFieldsIfPresent(pricingMapFields, name) + return + } + + if (mode === 'per-request') { + setFieldIfPresent(priceMapField, name) + return + } + + setFieldsIfPresent(ratioMapFields, name) + }) + + return { + ModelPrice: JSON.stringify(priceMap, null, 2), + ModelRatio: JSON.stringify(pricingMaps.ModelRatio, null, 2), + CacheRatio: JSON.stringify(pricingMaps.CacheRatio, null, 2), + CreateCacheRatio: JSON.stringify(pricingMaps.CreateCacheRatio, null, 2), + CompletionRatio: JSON.stringify(pricingMaps.CompletionRatio, null, 2), + ImageRatio: JSON.stringify(pricingMaps.ImageRatio, null, 2), + AudioRatio: JSON.stringify(pricingMaps.AudioRatio, null, 2), + AudioCompletionRatio: JSON.stringify( + pricingMaps.AudioCompletionRatio, + null, + 2 + ), + 'billing_setting.billing_mode': JSON.stringify(billingModeMap, null, 2), + 'billing_setting.billing_expr': JSON.stringify(billingExprMap, null, 2), + } +} + function ratioToBasePrice(ratio: unknown): string { const num = toNumberOrNull(ratio) if (num === null) return '' diff --git a/web/src/features/system-settings/models/model-ratio-visual-editor.tsx b/web/src/features/system-settings/models/model-ratio-visual-editor.tsx index 5013455c9bba..8a8065cdf1d4 100644 --- a/web/src/features/system-settings/models/model-ratio-visual-editor.tsx +++ b/web/src/features/system-settings/models/model-ratio-visual-editor.tsx @@ -21,8 +21,8 @@ import type { OnChangeFn, PaginationState, RowSelectionState, - VisibilityState, SortingState, + VisibilityState, } from '@tanstack/react-table' import { Copy, Plus } from 'lucide-react' import { @@ -47,11 +47,10 @@ import { useDataTable, } from '@/components/data-table' import { Button } from '@/components/ui/button' -import { combineBillingExpr } from '@/features/pricing/lib/billing-expr' import { useMediaQuery } from '@/hooks' import { safeJsonParse } from '../utils/json-parser' -import type { PricingMode } from './model-pricing-core' +import { buildModelPricingOptionUpdates } from './model-pricing-core' import { ModelPricingEditorPanel, type ModelPricingEditorPanelHandle, @@ -227,6 +226,7 @@ const ModelRatioVisualEditorComponent = forwardRef< const draft = draftByName.get(name) const displayed = saved ?? draft ?? { name, billingMode: 'per-token', hasConflict: false } + const savedSignature = getSnapshotSignature(saved) const draftSignature = getSnapshotSignature(draft) @@ -291,12 +291,13 @@ const ModelRatioVisualEditorComponent = forwardRef< const handleEdit = useCallback( (model: ModelRow) => { const editableModel = model.draft ?? model.saved ?? model - let editBillingMode: PricingMode = 'per-token' + let nextBillingMode: ModelRatioData['billingMode'] = 'per-token' if (editableModel.billingMode === 'tiered_expr') { - editBillingMode = 'tiered_expr' + nextBillingMode = 'tiered_expr' } else if (editableModel.price && editableModel.price !== '') { - editBillingMode = 'per-request' + nextBillingMode = 'per-request' } + setEditData({ name: editableModel.name, price: editableModel.price, @@ -307,7 +308,7 @@ const ModelRatioVisualEditorComponent = forwardRef< imageRatio: editableModel.imageRatio, audioRatio: editableModel.audioRatio, audioCompletionRatio: editableModel.audioCompletionRatio, - billingMode: editBillingMode, + billingMode: nextBillingMode, billingExpr: editableModel.billingExpr, requestRuleExpr: editableModel.requestRuleExpr, }) @@ -340,77 +341,26 @@ const ModelRatioVisualEditorComponent = forwardRef< 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 updates = buildModelPricingOptionUpdates({ + current: { + modelPrice, + modelRatio, + cacheRatio, + createCacheRatio, + completionRatio, + imageRatio, + audioRatio, + audioCompletionRatio, + billingMode, + billingExpr, + }, + data: { name }, + targetNames: [name], }) - const audioMap = safeJsonParse>(audioRatio, { - fallback: {}, - silent: true, + + Object.entries(updates).forEach(([field, value]) => { + onChange(field, value) }) - 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) - ) if (editData?.name === name) { setEditData(null) @@ -439,25 +389,14 @@ const ModelRatioVisualEditorComponent = forwardRef< buildModelRatioColumns({ onDelete: handleDelete, onEdit: handleEdit, - deleteDisabled: filterMode === 'unset', t, }), - [handleEdit, handleDelete, filterMode, t] + [handleEdit, handleDelete, 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, @@ -480,122 +419,26 @@ const ModelRatioVisualEditorComponent = forwardRef< 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 updates = buildModelPricingOptionUpdates({ + current: { + modelPrice, + modelRatio, + cacheRatio, + createCacheRatio, + completionRatio, + imageRatio, + audioRatio, + audioCompletionRatio, + billingMode, + billingExpr, + }, + data, + targetNames, }) - 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) - } + Object.entries(updates).forEach(([field, value]) => { + onChange(field, value) }) - - 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, @@ -612,20 +455,12 @@ const ModelRatioVisualEditorComponent = forwardRef< ] ) - const handleBatchCopy = useCallback(async () => { + const handleBatchCopy = useCallback(() => { 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) @@ -635,19 +470,15 @@ const ModelRatioVisualEditorComponent = forwardRef< return } - // Persist to the source model too, so targets never carry pricing the - // source itself would lose if the editor draft were abandoned. - persistPricingData(sourceData, [ - ...new Set([sourceData.name, ...targetNames]), - ]) + persistPricingData(editData, targetNames) table.resetRowSelection() toast.success( t('Applied {{name}} pricing to {{count}} models', { - name: sourceData.name, + name: editData.name, count: targetNames.length, }) ) - }, [editData, editorOpen, persistPricingData, t, table]) + }, [editData, persistPricingData, t, table]) useImperativeHandle( ref, @@ -682,7 +513,6 @@ const ModelRatioVisualEditorComponent = forwardRef< - {filterMode !== 'unset' && ( - - )} + )} @@ -831,17 +659,6 @@ export const ModelRatioVisualEditor = memo( // Custom equality check - only re-render if JSON props actually changed (prevProps, nextProps) => { return ( - prevProps.savedModelPrice === nextProps.savedModelPrice && - prevProps.savedModelRatio === nextProps.savedModelRatio && - prevProps.savedCacheRatio === nextProps.savedCacheRatio && - prevProps.savedCreateCacheRatio === nextProps.savedCreateCacheRatio && - prevProps.savedCompletionRatio === nextProps.savedCompletionRatio && - prevProps.savedImageRatio === nextProps.savedImageRatio && - prevProps.savedAudioRatio === nextProps.savedAudioRatio && - prevProps.savedAudioCompletionRatio === - nextProps.savedAudioCompletionRatio && - prevProps.savedBillingMode === nextProps.savedBillingMode && - prevProps.savedBillingExpr === nextProps.savedBillingExpr && prevProps.modelPrice === nextProps.modelPrice && prevProps.modelRatio === nextProps.modelRatio && prevProps.cacheRatio === nextProps.cacheRatio &&