diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js
index 6182540..265a94e 100644
--- a/web/src/constants/channel.constants.js
+++ b/web/src/constants/channel.constants.js
@@ -189,6 +189,11 @@ export const CHANNEL_OPTIONS = [
color: 'blue',
label: 'Codex',
},
+ {
+ value: 58,
+ color: 'blue',
+ label: 'DoubaoVideoCompatibleAiping',
+ },
];
export const MODEL_TABLE_PAGE_SIZE = 10;
diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx
index a2aeadc..020f0d7 100644
--- a/web/src/helpers/utils.jsx
+++ b/web/src/helpers/utils.jsx
@@ -609,6 +609,93 @@ export const selectFilter = (input, option) => {
// -------------------------------
// 模型定价计算工具函数
+export const hasMatrixPricing = (record) =>
+ !!(
+ record?.pricing_config &&
+ Array.isArray(record.pricing_config.table) &&
+ record.pricing_config.table.length > 0
+ );
+
+export const getMatrixPricingUnit = (pricingConfig, tokenUnit = 'M') => {
+ if (pricingConfig?.billing_unit === 'per_1m_tokens') {
+ return `1${tokenUnit === 'K' ? 'K' : 'M'} tokens`;
+ }
+ return '次';
+};
+
+const getDisplayCurrencySymbol = (currency) => {
+ if (currency === 'CNY') return '¥';
+ if (currency === 'CUSTOM') {
+ try {
+ const statusStr = localStorage.getItem('status');
+ if (statusStr) {
+ const s = JSON.parse(statusStr);
+ return s?.custom_currency_symbol || '¤';
+ }
+ } catch {
+ // ignore
+ }
+ return '¤';
+ }
+ return '$';
+};
+
+export const formatMatrixPrice = ({
+ price,
+ pricingConfig,
+ tokenUnit,
+ displayPrice,
+ currency,
+ precision = 4,
+}) => {
+ const unitDivisor =
+ pricingConfig?.billing_unit === 'per_1m_tokens' && tokenUnit === 'K'
+ ? 1000
+ : 1;
+ const displayVal = displayPrice(Number(price || 0));
+ const numPrice = parseFloat(displayVal.replace(/[^0-9.]/g, '')) / unitDivisor;
+ return `${getDisplayCurrencySymbol(currency)}${numPrice.toFixed(precision)}`;
+};
+
+export const summarizeMatrixPricing = ({
+ record,
+ usedGroupRatio = 1,
+ tokenUnit,
+ displayPrice,
+ currency,
+ precision = 4,
+}) => {
+ const cfg = record?.pricing_config;
+ const prices = (cfg?.table || [])
+ .map((row) => Number(row.price))
+ .filter((price) => Number.isFinite(price));
+ if (prices.length === 0) {
+ return { price: '-', unit: getMatrixPricingUnit(cfg, tokenUnit) };
+ }
+ const min = Math.min(...prices) * usedGroupRatio;
+ const max = Math.max(...prices) * usedGroupRatio;
+ const minText = formatMatrixPrice({
+ price: min,
+ pricingConfig: cfg,
+ tokenUnit,
+ displayPrice,
+ currency,
+ precision,
+ });
+ const maxText = formatMatrixPrice({
+ price: max,
+ pricingConfig: cfg,
+ tokenUnit,
+ displayPrice,
+ currency,
+ precision,
+ });
+ return {
+ price: min === max ? minText : `${minText} - ${maxText}`,
+ unit: getMatrixPricingUnit(cfg, tokenUnit),
+ };
+};
+
export const calculateModelPrice = ({
record,
selectedGroup,
@@ -646,6 +733,24 @@ export const calculateModelPrice = ({
}
// 2. 根据计费类型计算价格
+ if (hasMatrixPricing(record)) {
+ const summary = summarizeMatrixPricing({
+ record,
+ usedGroupRatio,
+ tokenUnit,
+ displayPrice,
+ currency,
+ precision,
+ });
+ return {
+ ...summary,
+ isMatrix: true,
+ isPerToken: record.pricing_config?.billing_unit === 'per_1m_tokens',
+ usedGroup,
+ usedGroupRatio,
+ };
+ }
+
if (record.quota_type === 0) {
// 按量计费
const inputRatioPriceUSD = record.model_ratio * 2 * usedGroupRatio;
diff --git a/web/src/helpers/utils.test.js b/web/src/helpers/utils.test.js
new file mode 100644
index 0000000..f37f541
--- /dev/null
+++ b/web/src/helpers/utils.test.js
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest';
+
+globalThis.window = {
+ location: { search: '', pathname: '/', hash: '' },
+ navigator: { language: 'zh-CN', languages: ['zh-CN'] },
+ matchMedia: () => ({
+ matches: false,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ }),
+};
+globalThis.localStorage = {
+ getItem: () => null,
+};
+
+const { calculateModelPrice, formatMatrixPrice, hasMatrixPricing } =
+ await import('./utils.jsx');
+
+const displayPrice = (value) => `$${Number(value).toFixed(3)}`;
+
+describe('model pricing helpers', () => {
+ it('detects and summarizes matrix pricing rows', () => {
+ const record = {
+ enable_groups: ['default'],
+ pricing_config: {
+ billing_unit: 'per_1m_tokens',
+ table: [
+ { resolution: '480p', video_input: true, price: 28, source: 'manual' },
+ { resolution: '480p', video_input: false, price: 46, source: 'manual' },
+ ],
+ },
+ };
+
+ expect(hasMatrixPricing(record)).toBe(true);
+ expect(
+ calculateModelPrice({
+ record,
+ selectedGroup: 'default',
+ groupRatio: { default: 0.5 },
+ tokenUnit: 'M',
+ displayPrice,
+ currency: 'USD',
+ }),
+ ).toMatchObject({
+ isMatrix: true,
+ isPerToken: true,
+ price: '$14.0000 - $23.0000',
+ unit: '1M tokens',
+ usedGroupRatio: 0.5,
+ });
+ });
+
+ it('formats per 1K matrix token price from per 1M price', () => {
+ expect(
+ formatMatrixPrice({
+ price: 28,
+ pricingConfig: { billing_unit: 'per_1m_tokens' },
+ tokenUnit: 'K',
+ displayPrice,
+ currency: 'USD',
+ }),
+ ).toBe('$0.0280');
+ });
+});
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 28a8fe5..071a831 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -1709,6 +1709,8 @@
"模型: {{ratio}}": "Model: {{ratio}}",
"模型专用区域": "Model-specific area",
"模型价格": "Model price",
+ "矩阵价格": "Matrix price",
+ "矩阵计费": "Matrix billing",
"模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}": "Model price {{symbol}}{{price}}, {{ratioType}} {{ratio}}",
"模型价格不能为0": "Model price cannot be 0",
"模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}": "Model price: {{symbol}}{{price}} * {{ratioType}}: {{ratio}} = {{symbol}}{{total}}",
@@ -3112,14 +3114,26 @@
",点击更新": ", click Update",
",音频 {{symbol}}{{price}} / 1M tokens": ", Audio {{symbol}}{{price}} / 1M tokens",
"???????????????": "Advanced ratios (leave empty to use global defaults)",
- "??????": "Audio output ratio",
+ "??????": "Apply generated rows",
"??????1.0?": "Global default (1.0)",
"?????????????0.5 = ????? token ???????????????????? 1.0": "Multiplier applied to input pricing when cache is hit. 0.5 means cached tokens are billed at half the input price. Leave empty to use the global default 1.0.",
"???????5 ???": "Cache creation ratio (5 minutes)",
"??????1.25?": "Global default (1.25)",
"????? token ???? ? ????????? 1.25?5 ?? TTL???? 1 ????? 1.6x ????????????": "Tokens written into cache are billed as this ratio ? input price. Default is 1.25 for a 5-minute TTL; cache creation over 1 hour is billed automatically at 1.6x. Leave empty to use the global default.",
- "????": "Image ratio",
- "?????": "Global default"
+ "????": "Final price",
+ "?????": "Preserve manual rows",
+ "??????????": "Failed to save model multidimensional pricing",
+ "?????????": "Model multidimensional pricing saved",
+ "???": "Default value",
+ "Legacy ??": "Legacy price",
+ "??????? Legacy ???{{price}}": "Deleting this rule falls back to Legacy price: {{price}}",
+ "????????": "Add model multidimensional pricing",
+ "??? tokens": "Per 1M tokens",
+ "??": "Refresh",
+ "?? JSON": "Apply JSON",
+ "??? {{count}} ?????{{sample}}": "Will generate {{count}} rows. Sample: {{sample}}",
+ "JSON ????": "JSON format error",
+ "Key": "Key"
},
"邮箱后缀": "Email Suffix",
"邮箱后缀必须以 @ 开头": "Email suffix must start with @",
diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json
index 5938369..1cf211d 100644
--- a/web/src/i18n/locales/zh-CN.json
+++ b/web/src/i18n/locales/zh-CN.json
@@ -1693,6 +1693,8 @@
"模型: {{ratio}}": "模型: {{ratio}}",
"模型专用区域": "模型专用区域",
"模型价格": "模型价格",
+ "矩阵价格": "矩阵价格",
+ "矩阵计费": "矩阵计费",
"模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}": "模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}",
"模型价格不能为0": "模型价格不能为0",
"模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}": "模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}",
@@ -3087,7 +3089,22 @@
",当前无生效订阅,将自动使用钱包": ",当前无生效订阅,将自动使用钱包",
",时间:": ",时间:",
",点击更新": ",点击更新",
- ",音频 {{symbol}}{{price}} / 1M tokens": ",音频 {{symbol}}{{price}} / 1M tokens"
+ ",音频 {{symbol}}{{price}} / 1M tokens": ",音频 {{symbol}}{{price}} / 1M tokens",
+ "??????????": "??????????",
+ "?????????": "?????????",
+ "??????": "??????",
+ "???": "???",
+ "Legacy ??": "Legacy ??",
+ "????": "????",
+ "??????? Legacy ???{{price}}": "??????? Legacy ???{{price}}",
+ "????????": "????????",
+ "??? tokens": "??? tokens",
+ "??": "??",
+ "?????": "?????",
+ "?? JSON": "?? JSON",
+ "??? {{count}} ?????{{sample}}": "??? {{count}} ?????{{sample}}",
+ "JSON ????": "JSON ????",
+ "Key": "Key"
},
"邮箱后缀": "邮箱后缀",
"邮箱后缀必须以 @ 开头": "邮箱后缀必须以 @ 开头",
diff --git a/web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx b/web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx
new file mode 100644
index 0000000..ec45bf9
--- /dev/null
+++ b/web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx
@@ -0,0 +1,160 @@
+import React from 'react';
+import { Button, Checkbox, Input, Select, Space, Table, Tooltip, Typography } from '@douyinfe/semi-ui';
+import { Info, Plus, Trash2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { DIMENSION_SOURCE_TYPES, composeDimensionSource, parseDimensionValue, splitDimensionSource } from './pricingConfig';
+
+export default function DimensionEditor({ dimensions, onChange }) {
+ const { t } = useTranslation();
+
+ const update = (index, patch) => {
+ const next = [...dimensions];
+ next[index] = { ...next[index], ...patch };
+ onChange(next);
+ };
+
+ const remove = (index) => onChange(dimensions.filter((_, itemIndex) => itemIndex !== index));
+ const add = () => onChange([...dimensions, { key: 'duration', source: 'request.duration', type: 'number', default: null }]);
+
+ const columns = [
+ { title: t('Key'), dataIndex: 'key', width: 140, render: (value, _, index) =>
update(index, { key })} data-testid={`dimension-key-${index}`} /> },
+ {
+ title: (
+
+ {t('来源类型')}
+
+
+
+
+ ),
+ dataIndex: 'source_type',
+ width: 140,
+ render: (_, row, index) => {
+ const parsed = splitDimensionSource(row.source);
+ return (
+
+ );
+ },
+ },
+ {
+ title: t('路径'),
+ dataIndex: 'source_path',
+ width: 210,
+ render: (_, row, index) => {
+ const parsed = splitDimensionSource(row.source);
+ return (
+
update(index, { source: composeDimensionSource(parsed.sourceType, path) })}
+ data-testid={`dimension-source-path-${index}`}
+ />
+ );
+ },
+ },
+ {
+ title: t('保存值'),
+ dataIndex: 'source',
+ width: 190,
+ render: (value) => (
+
+ {value}
+
+ ),
+ },
+ {
+ title: t('常用示例'),
+ dataIndex: 'source_examples',
+ width: 160,
+ render: (_, row, index) => (
+
+ ),
+ },
+ {
+ title: t('类型'),
+ dataIndex: 'type',
+ width: 130,
+ render: (value, _, index) => (
+
+ ),
+ },
+ {
+ title: t('可选'),
+ dataIndex: 'optional',
+ width: 80,
+ render: (value, row, index) => (
+
update(index, { optional: event.target.checked, default: event.target.checked ? defaultForType(row.type) : null })}
+ />
+ ),
+ },
+ {
+ title: t('默认值'),
+ dataIndex: 'default',
+ width: 140,
+ render: (value, row, index) => (
+ update(index, { default: text === '' ? null : parseDimensionValue(text, row.type) })}
+ />
+ ),
+ },
+ {
+ title: t('操作'),
+ width: 80,
+ render: (_, __, index) => } type='danger' theme='borderless' onClick={() => remove(index)} />,
+ },
+ ];
+
+ return (
+
+ `${index}`} scroll={{ x: 1280 }} />
+
+ {t('读取不到必填维度时会拒绝计费;读取不到可选维度时使用默认值。高级模式会直接保存路径内容。')}
+
+ } data-testid='dimension-add' onClick={add}>
+ {t('添加维度')}
+
+
+ );
+}
+
+const defaultForType = (type) => {
+ if (type === 'number') return 0;
+ if (type === 'boolean') return false;
+ return '';
+};
+
+const getSourceExamples = (key) => {
+ const examples = [
+ 'request.resolution',
+ 'request.duration',
+ 'request.options.size',
+ 'derived.input_mode',
+ 'derived.video_input',
+ 'derived.megapixels',
+ 'derived.aspect_ratio',
+ ];
+ if (key && !examples.includes(`request.${key}`)) return [`request.${key}`, ...examples];
+ return examples;
+};
diff --git a/web/src/pages/Setting/Operation/ModelPricing/Editor.jsx b/web/src/pages/Setting/Operation/ModelPricing/Editor.jsx
new file mode 100644
index 0000000..44dea9a
--- /dev/null
+++ b/web/src/pages/Setting/Operation/ModelPricing/Editor.jsx
@@ -0,0 +1,176 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { Button, Input, Select, SideSheet, Space, TabPane, Tabs, TextArea, Typography } from '@douyinfe/semi-ui';
+import { Redo2, Save, Undo2, Wand2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { API, showError, showSuccess } from '../../../../helpers';
+import DimensionEditor from './DimensionEditor';
+import GeneratorModal from './GeneratorModal';
+import PreviewPanel from './PreviewPanel';
+import PricingGrid from './PricingGrid';
+import { BILLING_UNITS, FALLBACKS, createDefaultConfig, validatePricingConfig } from './pricingConfig';
+
+export default function Editor({ visible, modelName, config, onClose, onSaved }) {
+ const { t } = useTranslation();
+ const [name, setName] = useState(modelName || '');
+ const [draft, setDraft] = useState(createDefaultConfig());
+ const [jsonText, setJsonText] = useState('');
+ const [history, setHistory] = useState([]);
+ const [future, setFuture] = useState([]);
+ const [generatorVisible, setGeneratorVisible] = useState(false);
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ if (!visible) return;
+ const next = config ? structuredClone(config) : createDefaultConfig();
+ setName(modelName || '');
+ setDraft(next);
+ setJsonText(JSON.stringify(next, null, 2));
+ setHistory([]);
+ setFuture([]);
+ }, [visible, modelName, config]);
+
+ const validation = useMemo(() => validatePricingConfig(draft), [draft]);
+
+ const updateDraft = (next) => {
+ setHistory((items) => [...items, draft]);
+ setFuture([]);
+ setDraft(next);
+ setJsonText(JSON.stringify(next, null, 2));
+ };
+
+ const undo = () => {
+ const previous = history[history.length - 1];
+ if (!previous) return;
+ setHistory((items) => items.slice(0, -1));
+ setFuture((items) => [draft, ...items]);
+ setDraft(previous);
+ setJsonText(JSON.stringify(previous, null, 2));
+ };
+
+ const redo = () => {
+ const next = future[0];
+ if (!next) return;
+ setFuture((items) => items.slice(1));
+ setHistory((items) => [...items, draft]);
+ setDraft(next);
+ setJsonText(JSON.stringify(next, null, 2));
+ };
+
+ const applyJson = () => {
+ try {
+ const next = JSON.parse(jsonText || '{}');
+ updateDraft(next);
+ } catch (error) {
+ showError(`${t('JSON 格式错误')}: ${error.message}`);
+ }
+ };
+
+ const save = async () => {
+ if (!name.trim()) {
+ showError(t('模型名称不能为空'));
+ return;
+ }
+ if (!validation.ok) {
+ showError(validation.errors.join('\n'));
+ return;
+ }
+ setSaving(true);
+ try {
+ const res = await API.put(`/api/option/model_pricing/${encodeURIComponent(name.trim())}`, draft);
+ if (!res.data?.success) {
+ showError(res.data?.message || t('保存模型多维计费失败'));
+ return;
+ }
+ showSuccess(t('模型多维计费已保存'));
+ await onSaved?.();
+ onClose?.();
+ } catch (error) {
+ showError(error.message || t('保存模型多维计费失败'));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const setBillingUnit = (value) => {
+ updateDraft({
+ ...draft,
+ billing_unit: value,
+ preconsume_strategy: value === BILLING_UNITS.per1MTokens ? 'minimum' : 'exact',
+ });
+ };
+
+ return (
+
+ } onClick={undo} disabled={history.length === 0} />
+ } onClick={redo} disabled={future.length === 0} />
+
+ } data-testid='model-pricing-save' onClick={save} loading={saving}>
+ {t('保存')}
+
+
+ }
+ >
+
+
+
+
+
+ } data-testid='model-pricing-open-generator' onClick={() => setGeneratorVisible(true)}>
+ {t('生成价格行')}
+
+
+ {!validation.ok && (
+
+ {validation.errors.join('\n')}
+
+ )}
+
+
+ updateDraft({ ...draft, dimensions })} />
+
+
+ updateDraft({ ...draft, table })} />
+
+
+
+
+
+
+
+
+
+
+ setGeneratorVisible(false)}
+ onGenerate={(table) => {
+ updateDraft({ ...draft, table });
+ setGeneratorVisible(false);
+ }}
+ />
+
+ );
+}
diff --git a/web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx b/web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx
new file mode 100644
index 0000000..6182deb
--- /dev/null
+++ b/web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx
@@ -0,0 +1,203 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { Button, Input, InputNumber, Modal, Radio, Select, Space, Table, Typography } from '@douyinfe/semi-ui';
+import { Plus, Trash2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { getCurrencyConfig } from '../../../../helpers';
+import {
+ BILLING_UNITS,
+ countRatioEntries,
+ displayCurrencyToUSD,
+ estimateGeneratedRowCount,
+ formatDisplayCurrency,
+ generateRowsFromRatios,
+ mergeGeneratedRows,
+ parseDimensionValue,
+} from './pricingConfig';
+
+const DEFAULT_RATIOS = {
+ resolution: { '720P': 1, '1080P': 1.8 },
+ duration: { 5: 1, 10: 1.5 },
+};
+
+export default function GeneratorModal({ visible, dimensions, billingUnit, existingRows, onCancel, onGenerate }) {
+ const { t } = useTranslation();
+ const [basePrice, setBasePrice] = useState(0.1);
+ const [ratioEntries, setRatioEntries] = useState([]);
+ const [strategy, setStrategy] = useState('preserve_manual');
+
+ const currencyConfig = useMemo(() => getCurrencyConfig(), [visible]);
+ const basePriceUSD = useMemo(() => displayCurrencyToUSD(basePrice, currencyConfig), [basePrice, currencyConfig]);
+ const unitLabel = billingUnit === BILLING_UNITS.per1MTokens ? t('每 1M tokens') : t('每次调用');
+
+ useEffect(() => {
+ if (!visible) return;
+ setRatioEntries(createDefaultRatioEntries(dimensions));
+ }, [visible, dimensions]);
+
+ const ratios = useMemo(() => entriesToRatios(ratioEntries, dimensions), [ratioEntries, dimensions]);
+
+ const preview = useMemo(() => {
+ const rows = generateRowsFromRatios({ dimensions, basePrice: basePriceUSD, ratios });
+ return {
+ rows,
+ linearCount: countRatioEntries(ratios),
+ expandedCount: estimateGeneratedRowCount(dimensions, ratios),
+ };
+ }, [dimensions, basePriceUSD, ratios]);
+
+ const apply = () => {
+ const table = mergeGeneratedRows({ existingRows, generatedRows: preview.rows, strategy });
+ onGenerate(table);
+ };
+
+ const updateEntry = (index, patch) => {
+ const next = [...ratioEntries];
+ next[index] = { ...next[index], ...patch };
+ setRatioEntries(next);
+ };
+
+ const addEntry = () => {
+ const firstDimension = dimensions?.[0]?.key || '';
+ setRatioEntries([...ratioEntries, { dimensionKey: firstDimension, value: '', ratio: 1 }]);
+ };
+
+ const removeEntry = (index) => setRatioEntries(ratioEntries.filter((_, itemIndex) => itemIndex !== index));
+
+ const ratioColumns = [
+ {
+ title: t('维度'),
+ dataIndex: 'dimensionKey',
+ width: 160,
+ render: (value, _, index) => (
+
+ ),
+ },
+ {
+ title: t('候选值'),
+ dataIndex: 'value',
+ render: (value, _, index) => updateEntry(index, { value: nextValue })} data-testid={`generator-ratio-value-${index}`} />,
+ },
+ {
+ title: t('倍率'),
+ dataIndex: 'ratio',
+ width: 150,
+ render: (value, _, index) => (
+ updateEntry(index, { ratio: Number(ratio || 0) })} data-testid={`generator-ratio-ratio-${index}`} />
+ ),
+ },
+ {
+ title: t('操作'),
+ width: 80,
+ render: (_, __, index) => } type='danger' theme='borderless' onClick={() => removeEntry(index)} />,
+ },
+ ];
+
+ const previewColumns = [
+ ...(dimensions || []).map((dim) => ({ title: dim.key, dataIndex: dim.key })),
+ {
+ title: t('展示价格'),
+ dataIndex: 'display_price',
+ width: 140,
+ render: (_, row) => formatDisplayCurrency(row.price, currencyConfig, 6),
+ },
+ {
+ title: t('保存价格 USD'),
+ dataIndex: 'price',
+ width: 140,
+ render: (price) => `$${Number(price || 0).toFixed(6)}`,
+ },
+ { title: t('来源'), dataIndex: 'source', width: 120 },
+ ];
+
+ return (
+
+
+
+
+ }
+ >
+
+
+ {t('基础价格是所有倍率计算的起点,当前单位为 {{unitLabel}}。最终价格 = 基础价格 × 各维度倍率。', { unitLabel })}
+
+
+ {t('当前按 {{symbol}} 展示和输入,应用时会按汇率换算为 USD 保存到 table[].price。当前输入 {{symbol}}{{displayPrice}} = ${{usdPrice}}。', {
+ symbol: currencyConfig.symbol,
+ displayPrice: Number(basePrice || 0).toFixed(6),
+ usdPrice: basePriceUSD.toFixed(6),
+ })}
+
+
+ setBasePrice(Number(value || 0))}
+ prefix={`${t('基础价格')}(${currencyConfig.symbol}/${unitLabel})`}
+ data-testid='generator-base-price'
+ />
+ setStrategy(event.target.value)}>
+
+ {t('保留手动行')}
+
+
+ {t('全部替换')}
+
+
+
+ `${index}`} />
+ } data-testid='generator-add-ratio' onClick={addEntry}>
+ {t('添加倍率项')}
+
+
+ {t('线性倍率项 {{linearCount}} 项,最终展开 {{expandedCount}} 行。任一维度没有候选值时不会生成结果。预览最多展示前 50 行。', {
+ linearCount: preview.linearCount,
+ expandedCount: preview.expandedCount,
+ })}
+
+ `${index}`} scroll={{ x: 720 }} />
+
+
+ );
+}
+
+const createDefaultRatioEntries = (dimensions) => {
+ const entries = [];
+ for (const dim of dimensions || []) {
+ const values = DEFAULT_RATIOS[dim.key];
+ if (values) {
+ for (const [value, ratio] of Object.entries(values)) {
+ entries.push({ dimensionKey: dim.key, value, ratio });
+ }
+ continue;
+ }
+ entries.push({ dimensionKey: dim.key, value: '', ratio: 1 });
+ }
+ return entries;
+};
+
+const entriesToRatios = (entries, dimensions) => {
+ const dimByKey = new Map((dimensions || []).map((dim) => [dim.key, dim]));
+ const ratios = {};
+ for (const entry of entries || []) {
+ const dim = dimByKey.get(entry.dimensionKey);
+ if (!dim || entry.value === '') continue;
+ const parsedValue = String(parseDimensionValue(entry.value, dim.type));
+ ratios[dim.key] = { ...(ratios[dim.key] || {}), [parsedValue]: Number(entry.ratio || 0) };
+ }
+ return ratios;
+};
diff --git a/web/src/pages/Setting/Operation/ModelPricing/PreviewPanel.jsx b/web/src/pages/Setting/Operation/ModelPricing/PreviewPanel.jsx
new file mode 100644
index 0000000..a6454f7
--- /dev/null
+++ b/web/src/pages/Setting/Operation/ModelPricing/PreviewPanel.jsx
@@ -0,0 +1,36 @@
+import React, { useMemo, useState } from 'react';
+import { Descriptions, TextArea, Typography } from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
+import { previewLookup } from './pricingConfig';
+
+export default function PreviewPanel({ config }) {
+ const { t } = useTranslation();
+ const [requestText, setRequestText] = useState('{\n "resolution": "720P"\n}');
+
+ const result = useMemo(() => {
+ try {
+ return previewLookup(config, JSON.parse(requestText || '{}'));
+ } catch (error) {
+ return { mode: 'invalid', error: error.message };
+ }
+ }, [config, requestText]);
+
+ return (
+ <>
+
+
+ {result.error && (
+
+ {result.error}
+
+ )}
+ >
+ );
+}
diff --git a/web/src/pages/Setting/Operation/ModelPricing/PricingGrid.jsx b/web/src/pages/Setting/Operation/ModelPricing/PricingGrid.jsx
new file mode 100644
index 0000000..b35fd71
--- /dev/null
+++ b/web/src/pages/Setting/Operation/ModelPricing/PricingGrid.jsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import { Button, Input, InputNumber, Select, Space, Table } from '@douyinfe/semi-ui';
+import { Plus, Trash2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { ROW_SOURCES, parseDimensionValue } from './pricingConfig';
+
+export default function PricingGrid({ dimensions, rows, onChange }) {
+ const { t } = useTranslation();
+
+ const update = (index, patch) => {
+ const next = [...rows];
+ next[index] = { ...next[index], ...patch };
+ onChange(next);
+ };
+
+ const add = () => {
+ const row = { price: 0, source: ROW_SOURCES.manual };
+ for (const dim of dimensions) row[dim.key] = '*';
+ onChange([...(rows || []), row]);
+ };
+
+ const remove = (index) => onChange(rows.filter((_, itemIndex) => itemIndex !== index));
+
+ const columns = [
+ ...dimensions.map((dim) => ({
+ title: dim.key,
+ dataIndex: dim.key,
+ render: (value, _, index) => (
+ update(index, { [dim.key]: text === '*' ? '*' : parseDimensionValue(text, dim.type) })} />
+ ),
+ })),
+ {
+ title: t('价格'),
+ dataIndex: 'price',
+ width: 140,
+ render: (value, _, index) => (
+ update(index, { price: Number(price || 0), source: ROW_SOURCES.manual })} />
+ ),
+ },
+ {
+ title: t('来源'),
+ dataIndex: 'source',
+ width: 140,
+ render: (value, _, index) => (
+
+ ),
+ },
+ {
+ title: t('操作'),
+ width: 80,
+ render: (_, __, index) => } type='danger' theme='borderless' onClick={() => remove(index)} />,
+ },
+ ];
+
+ return (
+
+ `${index}`} />
+ } onClick={add}>
+ {t('添加价格行')}
+
+
+ );
+}
diff --git a/web/src/pages/Setting/Operation/ModelPricing/index.jsx b/web/src/pages/Setting/Operation/ModelPricing/index.jsx
new file mode 100644
index 0000000..3418c62
--- /dev/null
+++ b/web/src/pages/Setting/Operation/ModelPricing/index.jsx
@@ -0,0 +1,138 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { Button, Card, Input, Popconfirm, Space, Table, Tag, Typography } from '@douyinfe/semi-ui';
+import { Edit, Plus, RefreshCw, Trash2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { API, showError, showSuccess } from '../../../../helpers';
+import Editor from './Editor';
+
+export default function ModelPricing() {
+ const { t } = useTranslation();
+ const [rules, setRules] = useState({});
+ const [legacyPrices, setLegacyPrices] = useState({});
+ const [keyword, setKeyword] = useState('');
+ const [editingModel, setEditingModel] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const refresh = async () => {
+ setLoading(true);
+ try {
+ const [rulesRes, optionsRes] = await Promise.all([API.get('/api/option/model_pricing'), API.get('/api/option/')]);
+ if (rulesRes.data?.success) {
+ setRules(rulesRes.data.data || {});
+ } else {
+ showError(rulesRes.data?.message || t('加载模型多维计费失败'));
+ }
+ if (optionsRes.data?.success) {
+ const modelPrice = optionsRes.data.data?.find((item) => item.key === 'ModelPrice')?.value || '{}';
+ setLegacyPrices(parseJsonObject(modelPrice));
+ }
+ } catch (error) {
+ showError(error.message || t('加载模型多维计费失败'));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ refresh();
+ }, []);
+
+ const data = useMemo(() => {
+ const names = new Set([...Object.keys(legacyPrices), ...Object.keys(rules)]);
+ return [...names]
+ .filter((name) => name.toLowerCase().includes(keyword.toLowerCase()))
+ .sort()
+ .map((name) => {
+ const cfg = rules[name];
+ return {
+ model: name,
+ configured: Boolean(cfg),
+ dimensions: cfg?.dimensions?.length || 0,
+ rows: cfg?.table?.length || 0,
+ billingUnit: cfg?.billing_unit || '-',
+ legacyPrice: legacyPrices[name],
+ };
+ });
+ }, [rules, legacyPrices, keyword]);
+
+ const remove = async (model) => {
+ try {
+ const res = await API.delete(`/api/option/model_pricing/${encodeURIComponent(model)}`);
+ if (!res.data?.success) {
+ showError(res.data?.message || t('删除模型多维计费失败'));
+ return;
+ }
+ showSuccess(t('模型多维计费已删除'));
+ await refresh();
+ } catch (error) {
+ showError(error.message || t('删除模型多维计费失败'));
+ }
+ };
+
+ const columns = [
+ { title: t('模型'), dataIndex: 'model' },
+ {
+ title: t('状态'),
+ dataIndex: 'configured',
+ width: 120,
+ render: (configured) => {configured ? t('已配置') : t('Legacy 回退')},
+ },
+ { title: t('维度'), dataIndex: 'dimensions', width: 90 },
+ { title: t('行数'), dataIndex: 'rows', width: 90 },
+ { title: t('计费单位'), dataIndex: 'billingUnit', width: 150 },
+ { title: t('Legacy 价格'), dataIndex: 'legacyPrice', width: 140, render: (value) => value ?? '-' },
+ {
+ title: t('操作'),
+ width: 120,
+ render: (_, row) => (
+
+ } theme='borderless' data-testid={`model-pricing-edit-${row.model}`} onClick={() => setEditingModel(row.model)} />
+ {row.configured && (
+ remove(row.model)}
+ >
+ } type='danger' theme='borderless' />
+
+ )}
+
+ ),
+ },
+ ];
+
+ return (
+
+
+
+ {t('模型多维计费')}
+
+ } onClick={refresh} loading={loading}>
+ {t('刷新')}
+
+ } type='primary' data-testid='model-pricing-add-model' onClick={() => setEditingModel('')}>
+ {t('添加模型')}
+
+
+
+
+
+
+ setEditingModel(null)}
+ onSaved={refresh}
+ />
+
+ );
+}
+
+const parseJsonObject = (text) => {
+ try {
+ const data = JSON.parse(text);
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
+ } catch {
+ return {};
+ }
+};
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..8f1fba1
--- /dev/null
+++ b/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js
@@ -0,0 +1,325 @@
+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',
+};
+
+export const DIMENSION_SOURCE_TYPES = {
+ request: 'request',
+ derived: 'derived',
+ advanced: 'advanced',
+};
+
+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 splitDimensionSource = (source) => {
+ const text = String(source || '').trim();
+ if (text.startsWith('request.')) return { sourceType: DIMENSION_SOURCE_TYPES.request, path: text.slice('request.'.length) };
+ if (text.startsWith('derived.')) return { sourceType: DIMENSION_SOURCE_TYPES.derived, path: text.slice('derived.'.length) };
+ return { sourceType: DIMENSION_SOURCE_TYPES.advanced, path: text };
+};
+
+export const composeDimensionSource = (sourceType, path) => {
+ const text = String(path || '').trim();
+ if (sourceType === DIMENSION_SOURCE_TYPES.request) {
+ return text.startsWith('request.') ? text : `request.${text}`;
+ }
+ if (sourceType === DIMENSION_SOURCE_TYPES.derived) {
+ return text.startsWith('derived.') ? text : `derived.${text}`;
+ }
+ return text;
+};
+
+export const countRatioEntries = (ratios) =>
+ Object.values(ratios || {}).reduce((total, values) => total + Object.keys(values || {}).length, 0);
+
+export const estimateGeneratedRowCount = (dimensions, ratios) => {
+ const dims = dimensions || [];
+ if (dims.length === 0) return 0;
+ return dims.reduce((total, dim) => {
+ const count = Object.keys(ratios?.[dim.key] || {}).length;
+ if (count === 0) return 0;
+ return total * count;
+ }, 1);
+};
+
+export const displayCurrencyToUSD = (amount, currencyConfig) => {
+ const rate = Number(currencyConfig?.rate || 1);
+ const value = Number(amount || 0);
+ if (!Number.isFinite(rate) || rate <= 0) return value;
+ return Number((value / rate).toFixed(6));
+};
+
+export const formatDisplayCurrency = (usdAmount, currencyConfig, digits = 6) => {
+ const symbol = currencyConfig?.symbol || '$';
+ const rate = Number(currencyConfig?.rate || 1);
+ const value = Number(usdAmount || 0) * (Number.isFinite(rate) && rate > 0 ? rate : 1);
+ return `${symbol}${value.toFixed(digits)}`;
+};
+
+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..6d21fc2
--- /dev/null
+++ b/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js
@@ -0,0 +1,142 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ BILLING_UNITS,
+ createDefaultConfig,
+ composeDimensionSource,
+ countRatioEntries,
+ displayCurrencyToUSD,
+ estimateGeneratedRowCount,
+ exportPricingCsv,
+ formatDisplayCurrency,
+ generateRowsFromRatios,
+ importPricingCsv,
+ mergeGeneratedRows,
+ PRECONSUME_STRATEGIES,
+ previewLookup,
+ ROW_SOURCES,
+ splitDimensionSource,
+ 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('accepts derived video input dimension config', () => {
+ const config = {
+ schema_version: 1,
+ scope: 'model',
+ billing_unit: BILLING_UNITS.per1MTokens,
+ preconsume_strategy: PRECONSUME_STRATEGIES.minimum,
+ dimensions: [{ key: 'video_input', source: 'derived.video_input', type: 'boolean', default: null }],
+ table: [
+ { video_input: false, price: 46, source: ROW_SOURCES.manual },
+ { video_input: true, price: 28, source: ROW_SOURCES.manual },
+ ],
+ fallback: { strategy: 'reject' },
+ };
+
+ expect(validatePricingConfig(config)).toEqual({ ok: true, errors: [] });
+ });
+
+ 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('split and compose dimension source', () => {
+ expect(splitDimensionSource('request.duration')).toEqual({ sourceType: 'request', path: 'duration' });
+ expect(splitDimensionSource('derived.aspect_ratio')).toEqual({ sourceType: 'derived', path: 'aspect_ratio' });
+ expect(splitDimensionSource('metadata.custom')).toEqual({ sourceType: 'advanced', path: 'metadata.custom' });
+
+ expect(composeDimensionSource('request', 'duration')).toBe('request.duration');
+ expect(composeDimensionSource('request', 'request.duration')).toBe('request.duration');
+ expect(composeDimensionSource('derived', 'aspect_ratio')).toBe('derived.aspect_ratio');
+ expect(composeDimensionSource('advanced', 'metadata.custom')).toBe('metadata.custom');
+ });
+
+ test('count linear ratio entries and expanded rows', () => {
+ const dimensions = [
+ { key: 'resolution', type: 'string' },
+ { key: 'duration', type: 'number' },
+ ];
+ const ratios = {
+ resolution: { '720P': 1, '1080P': 2 },
+ duration: { 5: 1, 10: 1.5, 15: 2 },
+ };
+ expect(countRatioEntries(ratios)).toBe(5);
+ expect(estimateGeneratedRowCount(dimensions, ratios)).toBe(6);
+ expect(estimateGeneratedRowCount(dimensions, { resolution: { '720P': 1 } })).toBe(0);
+ });
+
+ test('convert display currency amounts to stored USD prices', () => {
+ const cny = { symbol: '¥', rate: 7, type: 'CNY' };
+ const custom = { symbol: '¤', rate: 2, type: 'CUSTOM' };
+ expect(displayCurrencyToUSD(0.7, cny)).toBe(0.1);
+ expect(displayCurrencyToUSD(0.4, custom)).toBe(0.2);
+ expect(displayCurrencyToUSD(0.1, { symbol: '$', rate: 1, type: 'USD' })).toBe(0.1);
+ expect(formatDisplayCurrency(0.1, cny, 4)).toBe('¥0.7000');
+ });
+
+ 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);
+ });
+});