diff --git a/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js b/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js new file mode 100644 index 0000000..c4965da --- /dev/null +++ b/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js @@ -0,0 +1,274 @@ +export const BILLING_UNITS = { + perCall: 'per_call', + per1MTokens: 'per_1m_tokens', +}; + +export const PRECONSUME_STRATEGIES = { + exact: 'exact', + minimum: 'minimum', +}; + +export const FALLBACKS = { + reject: 'reject', + max: 'max', + default: 'default', +}; + +export const ROW_SOURCES = { + manual: 'manual', + generated: 'generated', +}; + +const RESERVED_KEYS = new Set([ + 'price', + 'source', + '__fallback__', + 'price_formula', + 'pricing_mode', + 'fallback_strategy', + 'schema_version', + 'scope', + 'billing_unit', + 'preconsume_strategy', +]); + +export const createDefaultConfig = () => ({ + schema_version: 1, + scope: 'model', + billing_unit: BILLING_UNITS.perCall, + preconsume_strategy: PRECONSUME_STRATEGIES.exact, + dimensions: [ + { + key: 'resolution', + source: 'request.resolution', + type: 'string', + default: null, + }, + ], + table: [ + { + resolution: '*', + price: 0.1, + source: ROW_SOURCES.manual, + }, + ], + fallback: { + strategy: FALLBACKS.reject, + }, +}); + +export const validatePricingConfig = (config) => { + const errors = []; + if (!config || typeof config !== 'object') { + return { ok: false, errors: ['config must be an object'] }; + } + + if (config.schema_version !== 1) errors.push('schema_version must be 1'); + if (config.scope !== 'model') errors.push('scope must be model'); + + const billingUnit = config.billing_unit || BILLING_UNITS.perCall; + const preconsume = config.preconsume_strategy || PRECONSUME_STRATEGIES.exact; + if (!Object.values(BILLING_UNITS).includes(billingUnit)) errors.push('billing_unit is invalid'); + if (!Object.values(PRECONSUME_STRATEGIES).includes(preconsume)) errors.push('preconsume_strategy is invalid'); + if (billingUnit === BILLING_UNITS.perCall && preconsume !== PRECONSUME_STRATEGIES.exact) { + errors.push('per_call requires exact preconsume_strategy'); + } + if (billingUnit === BILLING_UNITS.per1MTokens && preconsume !== PRECONSUME_STRATEGIES.minimum) { + errors.push('per_1m_tokens requires minimum preconsume_strategy'); + } + + if (!Array.isArray(config.dimensions) || config.dimensions.length === 0) errors.push('dimensions must not be empty'); + if (!Array.isArray(config.table) || config.table.length === 0) errors.push('table must not be empty'); + + const dimensionKeys = new Set(); + for (const dim of config.dimensions || []) { + if (!/^[a-z][a-z0-9_]{0,31}$/.test(dim.key || '')) errors.push(`dimension key "${dim.key || ''}" is invalid`); + if (RESERVED_KEYS.has(dim.key)) errors.push(`dimension key "${dim.key}" is reserved`); + if (dimensionKeys.has(dim.key)) errors.push(`dimension key "${dim.key}" is duplicated`); + dimensionKeys.add(dim.key); + if (!['string', 'number', 'boolean'].includes(dim.type)) errors.push(`dimension "${dim.key}" type is invalid`); + if (dim.optional && (dim.default === null || dim.default === undefined)) { + errors.push(`optional dimension "${dim.key}" requires a default value`); + } + if (!dim.optional && dim.default !== null && dim.default !== undefined) { + errors.push(`required dimension "${dim.key}" must use null default`); + } + if (dim.optional && dim.default !== null && dim.default !== undefined && !valueMatchesType(dim.default, dim.type)) { + errors.push(`dimension "${dim.key}" default does not match type`); + } + } + + const rowSignatures = new Set(); + const rowPatterns = []; + for (const [index, row] of (config.table || []).entries()) { + for (const key of Object.keys(row || {})) { + if (!dimensionKeys.has(key) && key !== 'price' && key !== 'source') errors.push(`row ${index + 1} contains extra field "${key}"`); + } + for (const dim of config.dimensions || []) { + if (!(dim.key in row)) { + errors.push(`row ${index + 1} missing "${dim.key}"`); + continue; + } + if (row[dim.key] !== '*' && !valueMatchesType(row[dim.key], dim.type)) { + errors.push(`row ${index + 1} value for "${dim.key}" does not match type`); + } + } + const price = Number(row.price); + if (Number.isNaN(price) || price < 0) errors.push(`row ${index + 1} price must be >= 0`); + if (!Object.values(ROW_SOURCES).includes(row.source)) errors.push(`row ${index + 1} source must be manual or generated`); + + const signature = (config.dimensions || []).map((dim) => String(row[dim.key])).join('\u0001'); + if (rowSignatures.has(signature)) errors.push(`row ${index + 1} duplicates another row`); + rowSignatures.add(signature); + rowPatterns.push({ + index, + values: (config.dimensions || []).map((dim) => row[dim.key]), + wildcards: (config.dimensions || []).filter((dim) => row[dim.key] === '*').length, + }); + } + + for (let i = 0; i < rowPatterns.length; i += 1) { + for (let j = i + 1; j < rowPatterns.length; j += 1) { + if (rowPatterns[i].wildcards !== rowPatterns[j].wildcards) continue; + if (patternsOverlap(rowPatterns[i].values, rowPatterns[j].values)) { + errors.push(`rows ${rowPatterns[i].index + 1} and ${rowPatterns[j].index + 1} are ambiguous`); + } + } + } + + const strategy = config.fallback?.strategy; + if (!Object.values(FALLBACKS).includes(strategy)) errors.push('fallback.strategy is invalid'); + if (strategy === FALLBACKS.default && (config.fallback.default_price === undefined || Number(config.fallback.default_price) < 0)) { + errors.push('fallback.default_price must be >= 0'); + } + + return { ok: errors.length === 0, errors }; +}; + +export const generateRowsFromRatios = ({ dimensions, basePrice, ratios }) => { + const dims = dimensions || []; + if (dims.length === 0) return []; + + const build = (index, prefix, price) => { + if (index >= dims.length) return [{ ...prefix, price: Number(Number(price).toFixed(6)), source: ROW_SOURCES.generated }]; + const dim = dims[index]; + const dimRatios = ratios?.[dim.key] || {}; + return Object.entries(dimRatios).flatMap(([value, ratio]) => + build(index + 1, { ...prefix, [dim.key]: parseDimensionValue(value, dim.type) }, price * Number(ratio || 1)), + ); + }; + + return build(0, {}, Number(basePrice || 0)); +}; + +export const mergeGeneratedRows = ({ existingRows, generatedRows, strategy }) => { + if (strategy === 'replace_all') return [...(generatedRows || [])]; + const manualRows = (existingRows || []).filter((row) => row.source === ROW_SOURCES.manual); + return [...manualRows, ...(generatedRows || [])]; +}; + +export const importPricingCsv = (text, dimensions) => { + const lines = String(text || '').trim().split(/\r?\n/).filter(Boolean); + if (lines.length === 0) return { rows: [], errors: [] }; + + const headers = parseCsvLine(lines[0]); + const errors = []; + const rows = lines.slice(1).map((line, index) => { + const cells = parseCsvLine(line); + const row = {}; + headers.forEach((header, i) => { + row[header] = cells[i] ?? ''; + }); + for (const dim of dimensions || []) row[dim.key] = row[dim.key] === '*' ? '*' : parseDimensionValue(row[dim.key], dim.type); + row.price = Number(row.price); + row.source = row.source || ROW_SOURCES.manual; + if (Number.isNaN(row.price)) errors.push(`line ${index + 2} has invalid price`); + return row; + }); + + return { rows, errors }; +}; + +export const exportPricingCsv = (rows, dimensions) => { + const headers = [...(dimensions || []).map((dim) => dim.key), 'price', 'source']; + const body = (rows || []).map((row) => headers.map((header) => escapeCsvValue(row[header] ?? '')).join(',')); + return [headers.join(','), ...body].join('\n'); +}; + +export const previewLookup = (config, requestValues) => { + const validation = validatePricingConfig(config); + if (!validation.ok) return { mode: 'invalid', error: validation.errors.join('\n') }; + + const matches = []; + for (const row of config.table || []) { + let wildcards = 0; + let matched = true; + for (const dim of config.dimensions || []) { + const expected = row[dim.key]; + if (expected === '*') { + wildcards += 1; + continue; + } + if (String(expected) !== String(requestValues?.[dim.key])) { + matched = false; + break; + } + } + if (matched) matches.push({ row, wildcards }); + } + + matches.sort((a, b) => a.wildcards - b.wildcards); + if (matches.length > 1 && matches[0].wildcards === matches[1].wildcards) return { mode: 'reject', error: 'ambiguous matching rows' }; + if (matches.length > 0) { + const best = matches[0]; + return { + mode: 'matched', + row: best.row, + price: Number(best.row.price), + snapshot: buildSnapshot(config, requestValues, Number(best.row.price), 'matched'), + }; + } + + if (config.fallback.strategy === FALLBACKS.reject) return { mode: 'reject', error: 'no pricing row matched' }; + if (config.fallback.strategy === FALLBACKS.max) { + const price = Math.max(...config.table.map((row) => Number(row.price || 0))); + return { mode: 'fallback_max', price, snapshot: buildSnapshot(config, requestValues, price, 'fallback_max', FALLBACKS.max) }; + } + + const price = Number(config.fallback.default_price || 0); + return { mode: 'fallback_default', price, snapshot: buildSnapshot(config, requestValues, price, 'fallback_default', FALLBACKS.default) }; +}; + +export const parseDimensionValue = (value, type) => { + if (value === '*') return value; + if (type === 'number') return Number(value); + if (type === 'boolean') return value === true || value === 'true'; + return value; +}; + +const valueMatchesType = (value, type) => { + if (type === 'number') return typeof value === 'number' && !Number.isNaN(value); + if (type === 'boolean') return typeof value === 'boolean'; + return typeof value === 'string'; +}; + +const patternsOverlap = (left, right) => + left.every((leftValue, index) => leftValue === '*' || right[index] === '*' || String(leftValue) === String(right[index])); + +const parseCsvLine = (line) => line.split(',').map((item) => item.trim()); + +const escapeCsvValue = (value) => { + const text = String(value); + if (!/[",\n]/.test(text)) return text; + return `"${text.replaceAll('"', '""')}"`; +}; + +const buildSnapshot = (config, values, price, mode, fallbackStrategy) => { + const snapshot = {}; + for (const dim of config.dimensions || []) snapshot[dim.key] = values?.[dim.key] ?? null; + snapshot.price = price; + snapshot.billing_unit = config.billing_unit || BILLING_UNITS.perCall; + snapshot.pricing_mode = mode; + if (fallbackStrategy) snapshot.fallback_strategy = fallbackStrategy; + return snapshot; +}; diff --git a/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js b/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js new file mode 100644 index 0000000..031e039 --- /dev/null +++ b/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'bun:test'; +import { + createDefaultConfig, + exportPricingCsv, + generateRowsFromRatios, + importPricingCsv, + mergeGeneratedRows, + previewLookup, + validatePricingConfig, +} from './pricingConfig'; + +describe('pricingConfig utilities', () => { + test('create and validate default config', () => { + const config = createDefaultConfig('hailuo-video'); + expect(config.scope).toBe('model'); + expect(validatePricingConfig(config).ok).toBe(true); + }); + + test('validate dimensions and rows', () => { + const config = createDefaultConfig(); + config.dimensions[0].key = 'price'; + expect(validatePricingConfig(config).ok).toBe(false); + + config.dimensions[0].key = 'resolution'; + config.table = [{ resolution: 720, price: -1, source: 'manual' }]; + const result = validatePricingConfig(config); + expect(result.ok).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + test('generate rows and preserve manual rows', () => { + const dimensions = [ + { key: 'resolution', type: 'string' }, + { key: 'duration', type: 'number' }, + ]; + const generatedRows = generateRowsFromRatios({ + dimensions, + basePrice: 0.1, + ratios: { + resolution: { '720P': 1, '1080P': 2 }, + duration: { 5: 1, 10: 1.5 }, + }, + }); + expect(generatedRows).toHaveLength(4); + + const merged = mergeGeneratedRows({ + existingRows: [{ resolution: '480P', duration: 5, price: 0.05, source: 'manual' }], + generatedRows, + strategy: 'preserve_manual', + }); + expect(merged).toHaveLength(5); + expect(merged[0].source).toBe('manual'); + }); + + test('csv import and export', () => { + const dimensions = [{ key: 'resolution', type: 'string' }]; + const parsed = importPricingCsv('resolution,price,source\n720P,0.1,manual', dimensions); + expect(parsed.errors).toHaveLength(0); + expect(parsed.rows[0].price).toBe(0.1); + expect(exportPricingCsv(parsed.rows, dimensions)).toContain('720P,0.1,manual'); + }); + + test('preview exact row, wildcard row, and fallbacks', () => { + const config = createDefaultConfig(); + config.table = [ + { resolution: '720P', price: 0.1, source: 'manual' }, + { resolution: '*', price: 0.2, source: 'manual' }, + ]; + expect(previewLookup(config, { resolution: '720P' }).price).toBe(0.1); + expect(previewLookup(config, { resolution: '1080P' }).price).toBe(0.2); + + config.table = [{ resolution: '720P', price: 0.1, source: 'manual' }]; + config.fallback = { strategy: 'reject' }; + expect(previewLookup(config, { resolution: '1080P' }).mode).toBe('reject'); + + config.fallback = { strategy: 'max' }; + expect(previewLookup(config, { resolution: '1080P' }).price).toBe(0.1); + + config.fallback = { strategy: 'default', default_price: 0.3 }; + expect(previewLookup(config, { resolution: '1080P' }).price).toBe(0.3); + }); +});