Просмотр исходного кода

feat(ui): add model pricing config utilities

master
fengsilin 3 недель назад
Родитель
Сommit
aff3284d02
2 измененных файлов: 356 добавлений и 0 удалений
  1. +274
    -0
      web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js
  2. +82
    -0
      web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js

+ 274
- 0
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;
};

+ 82
- 0
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);
});
});

Загрузка…
Отмена
Сохранить