feat: implement tiered billing expression evaluation and related functionality
- Added support for tiered billing expressions in the billing system. - Introduced new types and functions for handling billing expressions, including caching and execution. - Updated existing billing logic to accommodate tiered billing scenarios. - Enhanced request handling to support incoming billing expression requests. - Added tests for tiered billing functionality to ensure correctness.
This commit is contained in:
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
useModelPricingEditorState,
|
||||
} from '../hooks/useModelPricingEditorState';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import TieredPricingEditor from './TieredPricingEditor';
|
||||
|
||||
const { Text } = Typography;
|
||||
const EMPTY_CANDIDATE_MODEL_NAMES = [];
|
||||
@@ -123,6 +124,8 @@ export default function ModelPricingEditor({
|
||||
handleOptionalFieldToggle,
|
||||
handleNumericFieldChange,
|
||||
handleBillingModeChange,
|
||||
handleBillingExprChange,
|
||||
handleRequestRuleExprChange,
|
||||
handleSubmit,
|
||||
addModel,
|
||||
deleteModel,
|
||||
@@ -135,6 +138,15 @@ export default function ModelPricingEditor({
|
||||
filterMode,
|
||||
});
|
||||
|
||||
const getExprModeLabel = useCallback((model) => {
|
||||
if (model?.billingMode !== 'tiered_expr') {
|
||||
return '';
|
||||
}
|
||||
return (model.billingExpr || '').includes('tier(')
|
||||
? t('阶梯计费')
|
||||
: t('表达式计费');
|
||||
}, [t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -175,10 +187,20 @@ export default function ModelPricingEditor({
|
||||
dataIndex: 'billingMode',
|
||||
key: 'billingMode',
|
||||
render: (_, record) => (
|
||||
<Tag color={record.billingMode === 'per-request' ? 'teal' : 'violet'}>
|
||||
<Tag
|
||||
color={
|
||||
record.billingMode === 'per-request'
|
||||
? 'teal'
|
||||
: record.billingMode === 'tiered_expr'
|
||||
? 'amber'
|
||||
: 'violet'
|
||||
}
|
||||
>
|
||||
{record.billingMode === 'per-request'
|
||||
? t('按次计费')
|
||||
: t('按量计费')}
|
||||
: record.billingMode === 'tiered_expr'
|
||||
? getExprModeLabel(record)
|
||||
: t('按量计费')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
@@ -208,6 +230,7 @@ export default function ModelPricingEditor({
|
||||
[
|
||||
allowDeleteModel,
|
||||
deleteModel,
|
||||
getExprModeLabel,
|
||||
selectedModelName,
|
||||
selectedModelNames,
|
||||
setSelectedModelName,
|
||||
@@ -353,10 +376,20 @@ export default function ModelPricingEditor({
|
||||
title={selectedModel ? selectedModel.name : t('模型计费编辑器')}
|
||||
headerExtraContent={
|
||||
selectedModel ? (
|
||||
<Tag color='blue'>
|
||||
<Tag
|
||||
color={
|
||||
selectedModel.billingMode === 'per-request'
|
||||
? 'teal'
|
||||
: selectedModel.billingMode === 'tiered_expr'
|
||||
? 'amber'
|
||||
: 'blue'
|
||||
}
|
||||
>
|
||||
{selectedModel.billingMode === 'per-request'
|
||||
? t('按次计费')
|
||||
: t('按量计费')}
|
||||
: selectedModel.billingMode === 'tiered_expr'
|
||||
? getExprModeLabel(selectedModel)
|
||||
: t('按量计费')}
|
||||
</Tag>
|
||||
) : null
|
||||
}
|
||||
@@ -381,10 +414,11 @@ export default function ModelPricingEditor({
|
||||
>
|
||||
<Radio value='per-token'>{t('按量计费')}</Radio>
|
||||
<Radio value='per-request'>{t('按次计费')}</Radio>
|
||||
<Radio value='tiered_expr'>{t('表达式/阶梯计费')}</Radio>
|
||||
</RadioGroup>
|
||||
<div className='mt-2 text-xs text-gray-500'>
|
||||
{t(
|
||||
'这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。',
|
||||
'普通按量/按次直接填价格就行;如果价格要跟请求参数或请求头联动,请切到表达式/阶梯计费。',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -415,6 +449,14 @@ export default function ModelPricingEditor({
|
||||
onChange={(value) => handleNumericFieldChange('fixedPrice', value)}
|
||||
extraText={t('适合 MJ / 任务类等按次收费模型。')}
|
||||
/>
|
||||
) : selectedModel.billingMode === 'tiered_expr' ? (
|
||||
<TieredPricingEditor
|
||||
model={selectedModel}
|
||||
onExprChange={handleBillingExprChange}
|
||||
requestRuleExpr={selectedModel.requestRuleExpr}
|
||||
onRequestRuleExprChange={handleRequestRuleExprChange}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Card
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,443 @@
|
||||
export const SOURCE_PARAM = 'param';
|
||||
export const SOURCE_HEADER = 'header';
|
||||
export const SOURCE_TIME = 'time';
|
||||
|
||||
export const MATCH_EQ = 'eq';
|
||||
export const MATCH_CONTAINS = 'contains';
|
||||
export const MATCH_GT = 'gt';
|
||||
export const MATCH_GTE = 'gte';
|
||||
export const MATCH_LT = 'lt';
|
||||
export const MATCH_LTE = 'lte';
|
||||
export const MATCH_EXISTS = 'exists';
|
||||
export const MATCH_RANGE = 'range';
|
||||
|
||||
export const TIME_FUNCS = ['hour', 'minute', 'weekday', 'month', 'day'];
|
||||
|
||||
export const COMMON_TIMEZONES = [
|
||||
{ value: 'Asia/Shanghai', label: 'CST (UTC+8 北京)' },
|
||||
{ value: 'UTC', label: 'UTC' },
|
||||
{ value: 'America/New_York', label: 'EST (UTC-5 纽约)' },
|
||||
{ value: 'America/Los_Angeles', label: 'PST (UTC-8 洛杉矶)' },
|
||||
{ value: 'America/Chicago', label: 'CST (UTC-6 芝加哥)' },
|
||||
{ value: 'Europe/London', label: 'GMT (UTC+0 伦敦)' },
|
||||
{ value: 'Europe/Berlin', label: 'CET (UTC+1 柏林)' },
|
||||
{ value: 'Asia/Tokyo', label: 'JST (UTC+9 东京)' },
|
||||
{ value: 'Asia/Singapore', label: 'SGT (UTC+8 新加坡)' },
|
||||
{ value: 'Asia/Seoul', label: 'KST (UTC+9 首尔)' },
|
||||
{ value: 'Australia/Sydney', label: 'AEST (UTC+10 悉尼)' },
|
||||
];
|
||||
|
||||
export const NUMERIC_LITERAL_REGEX =
|
||||
/^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Condition creators (no multiplier — multiplier lives on the group)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createEmptyCondition() {
|
||||
return { source: SOURCE_PARAM, path: '', mode: MATCH_EQ, value: '' };
|
||||
}
|
||||
|
||||
export function createEmptyTimeCondition() {
|
||||
return {
|
||||
source: SOURCE_TIME,
|
||||
timeFunc: 'hour',
|
||||
timezone: 'Asia/Shanghai',
|
||||
mode: MATCH_GTE,
|
||||
value: '',
|
||||
rangeStart: '',
|
||||
rangeEnd: '',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group creators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createEmptyRuleGroup() {
|
||||
return { conditions: [createEmptyCondition()], multiplier: '' };
|
||||
}
|
||||
|
||||
export function createEmptyTimeRuleGroup() {
|
||||
return { conditions: [createEmptyTimeCondition()], multiplier: '' };
|
||||
}
|
||||
|
||||
// Kept for backward compat with old preset format
|
||||
export function createEmptyRequestRule() {
|
||||
return { source: SOURCE_PARAM, path: '', mode: MATCH_EQ, value: '', multiplier: '' };
|
||||
}
|
||||
|
||||
export function createEmptyTimeRule() {
|
||||
return {
|
||||
source: SOURCE_TIME, timeFunc: 'hour', timezone: 'Asia/Shanghai',
|
||||
mode: MATCH_GTE, value: '', rangeStart: '', rangeEnd: '', multiplier: '',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Match options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getRequestRuleMatchOptions(source, t) {
|
||||
if (source === SOURCE_TIME) {
|
||||
return [
|
||||
{ value: MATCH_EQ, label: t('等于') },
|
||||
{ value: MATCH_GTE, label: t('大于等于') },
|
||||
{ value: MATCH_LT, label: t('小于') },
|
||||
{ value: MATCH_RANGE, label: t('跨夜范围') },
|
||||
];
|
||||
}
|
||||
const base = [
|
||||
{ value: MATCH_EQ, label: t('等于') },
|
||||
{ value: MATCH_CONTAINS, label: t('包含') },
|
||||
{ value: MATCH_EXISTS, label: t('存在') },
|
||||
];
|
||||
if (source === SOURCE_HEADER) {
|
||||
return base;
|
||||
}
|
||||
return [
|
||||
...base,
|
||||
{ value: MATCH_GT, label: t('大于') },
|
||||
{ value: MATCH_GTE, label: t('大于等于') },
|
||||
{ value: MATCH_LT, label: t('小于') },
|
||||
{ value: MATCH_LTE, label: t('小于等于') },
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalize a single condition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function normalizeCondition(cond) {
|
||||
const source = cond?.source === SOURCE_TIME
|
||||
? SOURCE_TIME
|
||||
: cond?.source === SOURCE_HEADER
|
||||
? SOURCE_HEADER
|
||||
: SOURCE_PARAM;
|
||||
|
||||
if (source === SOURCE_TIME) {
|
||||
const timeFunc = TIME_FUNCS.includes(cond?.timeFunc) ? cond.timeFunc : 'hour';
|
||||
const options = getRequestRuleMatchOptions(SOURCE_TIME, (v) => v);
|
||||
const mode = options.some((item) => item.value === cond?.mode) ? cond.mode : MATCH_GTE;
|
||||
return {
|
||||
source: SOURCE_TIME,
|
||||
timeFunc,
|
||||
timezone: cond?.timezone || 'Asia/Shanghai',
|
||||
mode,
|
||||
value: cond?.value == null ? '' : String(cond.value),
|
||||
rangeStart: cond?.rangeStart == null ? '' : String(cond.rangeStart),
|
||||
rangeEnd: cond?.rangeEnd == null ? '' : String(cond.rangeEnd),
|
||||
};
|
||||
}
|
||||
|
||||
const options = getRequestRuleMatchOptions(source, (v) => v);
|
||||
const mode = options.some((item) => item.value === cond?.mode) ? cond.mode : MATCH_EQ;
|
||||
return {
|
||||
source,
|
||||
path: cond?.path || '',
|
||||
mode,
|
||||
value: cond?.value == null ? '' : String(cond.value),
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy compat wrapper
|
||||
export function normalizeRequestRule(rule) {
|
||||
const base = normalizeCondition(rule);
|
||||
return { ...base, multiplier: rule?.multiplier == null ? '' : String(rule.multiplier) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function splitTopLevelMultiply(expr) {
|
||||
const parts = [];
|
||||
let start = 0;
|
||||
let depth = 0;
|
||||
for (let index = 0; index < expr.length; index += 1) {
|
||||
const char = expr[index];
|
||||
if (char === '(') depth += 1;
|
||||
if (char === ')') depth -= 1;
|
||||
if (depth === 0 && expr.slice(index, index + 3) === ' * ') {
|
||||
parts.push(expr.slice(start, index).trim());
|
||||
start = index + 3;
|
||||
index += 2;
|
||||
}
|
||||
}
|
||||
parts.push(expr.slice(start).trim());
|
||||
return parts.filter(Boolean);
|
||||
}
|
||||
|
||||
function splitTopLevelAnd(expr) {
|
||||
const parts = [];
|
||||
let start = 0;
|
||||
let depth = 0;
|
||||
for (let i = 0; i < expr.length; i += 1) {
|
||||
const c = expr[i];
|
||||
if (c === '(') depth += 1;
|
||||
if (c === ')') depth -= 1;
|
||||
if (depth === 0 && expr.slice(i, i + 4) === ' && ') {
|
||||
parts.push(expr.slice(start, i).trim());
|
||||
start = i + 4;
|
||||
i += 3;
|
||||
}
|
||||
}
|
||||
parts.push(expr.slice(start).trim());
|
||||
return parts.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseExprLiteral(raw) {
|
||||
const text = raw.trim();
|
||||
if (text === 'true' || text === 'false') return text;
|
||||
if (NUMERIC_LITERAL_REGEX.test(text)) return text;
|
||||
try { return JSON.parse(text); } catch { return null; }
|
||||
}
|
||||
|
||||
function buildExprLiteral(mode, value) {
|
||||
const text = String(value || '').trim();
|
||||
if (mode === MATCH_CONTAINS) return JSON.stringify(text);
|
||||
if (text === 'true' || text === 'false') return text;
|
||||
if (NUMERIC_LITERAL_REGEX.test(text)) return text;
|
||||
return JSON.stringify(text);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build a single condition expression string (no ? mult : 1 wrapper)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildTimeConditionExpr(cond) {
|
||||
const normalized = normalizeCondition(cond);
|
||||
const { timeFunc, timezone, mode } = normalized;
|
||||
const tz = JSON.stringify(timezone);
|
||||
const fn = `${timeFunc}(${tz})`;
|
||||
|
||||
if (mode === MATCH_RANGE) {
|
||||
const s = normalized.rangeStart.trim();
|
||||
const e = normalized.rangeEnd.trim();
|
||||
if (!NUMERIC_LITERAL_REGEX.test(s) || !NUMERIC_LITERAL_REGEX.test(e)) return '';
|
||||
return `${fn} >= ${s} || ${fn} < ${e}`;
|
||||
}
|
||||
const v = normalized.value.trim();
|
||||
if (!NUMERIC_LITERAL_REGEX.test(v)) return '';
|
||||
const opMap = { [MATCH_EQ]: '==', [MATCH_GTE]: '>=', [MATCH_LT]: '<' };
|
||||
return `${fn} ${opMap[mode] || '=='} ${v}`;
|
||||
}
|
||||
|
||||
function buildRequestConditionExpr(cond) {
|
||||
if (cond?.source === SOURCE_TIME) return buildTimeConditionExpr(cond);
|
||||
const normalized = normalizeCondition(cond);
|
||||
const path = normalized.path.trim();
|
||||
if (!path) return '';
|
||||
|
||||
const sourceExpr = normalized.source === SOURCE_HEADER
|
||||
? `header(${JSON.stringify(path)})`
|
||||
: `param(${JSON.stringify(path)})`;
|
||||
|
||||
switch (normalized.mode) {
|
||||
case MATCH_EXISTS:
|
||||
return normalized.source === SOURCE_HEADER
|
||||
? `${sourceExpr} != ""`
|
||||
: `${sourceExpr} != nil`;
|
||||
case MATCH_CONTAINS:
|
||||
return normalized.source === SOURCE_HEADER
|
||||
? `has(${sourceExpr}, ${buildExprLiteral(normalized.mode, normalized.value)})`
|
||||
: `${sourceExpr} != nil && has(${sourceExpr}, ${buildExprLiteral(normalized.mode, normalized.value)})`;
|
||||
case MATCH_GT: case MATCH_GTE: case MATCH_LT: case MATCH_LTE: {
|
||||
const opMap = { [MATCH_GT]: '>', [MATCH_GTE]: '>=', [MATCH_LT]: '<', [MATCH_LTE]: '<=' };
|
||||
if (!NUMERIC_LITERAL_REGEX.test(String(normalized.value).trim())) return '';
|
||||
return `${sourceExpr} != nil && ${sourceExpr} ${opMap[normalized.mode]} ${String(normalized.value).trim()}`;
|
||||
}
|
||||
case MATCH_EQ:
|
||||
default:
|
||||
return `${sourceExpr} == ${buildExprLiteral(normalized.mode, normalized.value)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build a group factor: (cond1 && cond2 ? mult : 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildRuleGroupFactor(group) {
|
||||
const multiplier = (group.multiplier || '').trim();
|
||||
if (!NUMERIC_LITERAL_REGEX.test(multiplier)) return '';
|
||||
const condExprs = (group.conditions || [])
|
||||
.map(buildRequestConditionExpr)
|
||||
.filter(Boolean);
|
||||
if (condExprs.length === 0) return '';
|
||||
|
||||
const combined = condExprs.length === 1
|
||||
? condExprs[0]
|
||||
: condExprs.map((e) => (e.includes(' || ') ? `(${e})` : e)).join(' && ');
|
||||
return `(${combined} ? ${multiplier} : 1)`;
|
||||
}
|
||||
|
||||
export function buildRequestRuleExpr(groups) {
|
||||
return (groups || []).map(buildRuleGroupFactor).filter(Boolean).join(' * ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse a single condition from an expression fragment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function tryParseTimeCondition(expr) {
|
||||
// Range: hour("tz") >= s || hour("tz") < e
|
||||
let m = expr.match(
|
||||
/^(hour|minute|weekday|month|day)\("([^"]+)"\) >= ([\d.eE+-]+) \|\| \1\("\2"\) < ([\d.eE+-]+)$/,
|
||||
);
|
||||
if (m) {
|
||||
return {
|
||||
source: SOURCE_TIME, timeFunc: m[1], timezone: m[2],
|
||||
mode: MATCH_RANGE, value: '', rangeStart: m[3], rangeEnd: m[4],
|
||||
};
|
||||
}
|
||||
// Wrapped range: (hour("tz") >= s || hour("tz") < e)
|
||||
m = expr.match(
|
||||
/^\((hour|minute|weekday|month|day)\("([^"]+)"\) >= ([\d.eE+-]+) \|\| \1\("\2"\) < ([\d.eE+-]+)\)$/,
|
||||
);
|
||||
if (m) {
|
||||
return {
|
||||
source: SOURCE_TIME, timeFunc: m[1], timezone: m[2],
|
||||
mode: MATCH_RANGE, value: '', rangeStart: m[3], rangeEnd: m[4],
|
||||
};
|
||||
}
|
||||
// Simple: hour("tz") op value
|
||||
m = expr.match(
|
||||
/^(hour|minute|weekday|month|day)\("([^"]+)"\) (==|>=|<) ([\d.eE+-]+)$/,
|
||||
);
|
||||
if (m) {
|
||||
const opMap = { '==': MATCH_EQ, '>=': MATCH_GTE, '<': MATCH_LT };
|
||||
return {
|
||||
source: SOURCE_TIME, timeFunc: m[1], timezone: m[2],
|
||||
mode: opMap[m[3]] || MATCH_EQ, value: m[4], rangeStart: '', rangeEnd: '',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryParseRequestCondition(expr) {
|
||||
const tc = tryParseTimeCondition(expr);
|
||||
if (tc) return tc;
|
||||
|
||||
let m = expr.match(/^header\("([^"]+)"\) != ""$/);
|
||||
if (m) return { source: SOURCE_HEADER, path: m[1], mode: MATCH_EXISTS, value: '' };
|
||||
|
||||
m = expr.match(/^param\("([^"]+)"\) != nil$/);
|
||||
if (m) return { source: SOURCE_PARAM, path: m[1], mode: MATCH_EXISTS, value: '' };
|
||||
|
||||
m = expr.match(/^has\(header\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/);
|
||||
if (m) return { source: SOURCE_HEADER, path: m[1], mode: MATCH_CONTAINS, value: JSON.parse(m[2]) };
|
||||
|
||||
m = expr.match(/^param\("([^"]+)"\) != nil && has\(param\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/);
|
||||
if (m && m[1] === m[2]) return { source: SOURCE_PARAM, path: m[1], mode: MATCH_CONTAINS, value: JSON.parse(m[3]) };
|
||||
|
||||
m = expr.match(/^param\("([^"]+)"\) != nil && param\("([^"]+)"\) (>|>=|<|<=) ([\d.eE+-]+)$/);
|
||||
if (m && m[1] === m[2]) {
|
||||
const opMap = { '>': MATCH_GT, '>=': MATCH_GTE, '<': MATCH_LT, '<=': MATCH_LTE };
|
||||
return { source: SOURCE_PARAM, path: m[1], mode: opMap[m[3]], value: m[4] };
|
||||
}
|
||||
|
||||
m = expr.match(/^(param|header)\("([^"]+)"\) == (.+)$/);
|
||||
if (m) {
|
||||
const parsedValue = parseExprLiteral(m[3]);
|
||||
if (parsedValue === null) return null;
|
||||
return { source: m[1], path: m[2], mode: MATCH_EQ, value: String(parsedValue) };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse a group factor: (cond1 && cond2 ? mult : 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function tryParseRuleGroupFactor(part) {
|
||||
// Must be wrapped in ( ... ? mult : 1)
|
||||
const m = part.match(/^\((.+) \? ([\d.eE+-]+) : 1\)$/s);
|
||||
if (!m) return null;
|
||||
|
||||
const conditionStr = m[1];
|
||||
const multiplier = m[2];
|
||||
|
||||
const andParts = splitTopLevelAnd(conditionStr);
|
||||
const conditions = [];
|
||||
for (const ap of andParts) {
|
||||
const cond = tryParseRequestCondition(ap.trim());
|
||||
if (!cond) return null;
|
||||
conditions.push(normalizeCondition(cond));
|
||||
}
|
||||
if (conditions.length === 0) return null;
|
||||
return { conditions, multiplier };
|
||||
}
|
||||
|
||||
export function tryParseRequestRuleExpr(expr) {
|
||||
const trimmed = (expr || '').trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const parts = splitTopLevelMultiply(trimmed);
|
||||
const groups = [];
|
||||
for (const part of parts) {
|
||||
const group = tryParseRuleGroupFactor(part);
|
||||
if (!group) return null;
|
||||
groups.push(group);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Combine / split billing expr and request rules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hasFullOuterParens(expr) {
|
||||
if (!expr.startsWith('(') || !expr.endsWith(')')) return false;
|
||||
let depth = 0;
|
||||
for (let i = 0; i < expr.length; i += 1) {
|
||||
if (expr[i] === '(') depth += 1;
|
||||
if (expr[i] === ')') depth -= 1;
|
||||
if (depth === 0 && i < expr.length - 1) return false;
|
||||
}
|
||||
return depth === 0;
|
||||
}
|
||||
|
||||
export function unwrapOuterParens(expr) {
|
||||
let current = (expr || '').trim();
|
||||
while (hasFullOuterParens(current)) {
|
||||
current = current.slice(1, -1).trim();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function combineBillingExpr(baseExpr, requestRuleExpr) {
|
||||
const base = (baseExpr || '').trim();
|
||||
const rules = (requestRuleExpr || '').trim();
|
||||
if (!base) return '';
|
||||
if (!rules) return base;
|
||||
return `(${base}) * ${rules}`;
|
||||
}
|
||||
|
||||
export function splitBillingExprAndRequestRules(expr) {
|
||||
const trimmed = (expr || '').trim();
|
||||
if (!trimmed) return { billingExpr: '', requestRuleExpr: '' };
|
||||
|
||||
const parts = splitTopLevelMultiply(trimmed);
|
||||
if (parts.length <= 1) return { billingExpr: trimmed, requestRuleExpr: '' };
|
||||
|
||||
const ruleParts = [];
|
||||
const baseParts = [];
|
||||
|
||||
parts.forEach((part) => {
|
||||
if (tryParseRequestRuleExpr(part) !== null && tryParseRequestRuleExpr(part).length > 0) {
|
||||
ruleParts.push(part);
|
||||
} else {
|
||||
baseParts.push(part);
|
||||
}
|
||||
});
|
||||
|
||||
if (ruleParts.length === 0 || baseParts.length !== 1) {
|
||||
return { billingExpr: trimmed, requestRuleExpr: '' };
|
||||
}
|
||||
|
||||
return {
|
||||
billingExpr: unwrapOuterParens(baseParts[0]),
|
||||
requestRuleExpr: ruleParts.join(' * '),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import {
|
||||
combineBillingExpr,
|
||||
splitBillingExprAndRequestRules,
|
||||
} from '../components/requestRuleExpr';
|
||||
|
||||
export const PAGE_SIZE = 10;
|
||||
export const PRICE_SUFFIX = '$/1M tokens';
|
||||
@@ -18,6 +22,8 @@ const EMPTY_MODEL = {
|
||||
imagePrice: '',
|
||||
audioInputPrice: '',
|
||||
audioOutputPrice: '',
|
||||
billingExpr: '',
|
||||
requestRuleExpr: '',
|
||||
rawRatios: {
|
||||
modelRatio: '',
|
||||
completionRatio: '',
|
||||
@@ -98,6 +104,22 @@ const normalizeCompletionRatioMeta = (rawMeta) => {
|
||||
};
|
||||
|
||||
const buildModelState = (name, sourceMaps) => {
|
||||
const billingMode = sourceMaps.ModelBillingMode?.[name];
|
||||
if (billingMode === 'tiered_expr') {
|
||||
const fullBillingExpr = sourceMaps.ModelBillingExpr?.[name] || '';
|
||||
const { billingExpr, requestRuleExpr } =
|
||||
splitBillingExprAndRequestRules(fullBillingExpr);
|
||||
return {
|
||||
...EMPTY_MODEL,
|
||||
name,
|
||||
billingMode: 'tiered_expr',
|
||||
billingExpr,
|
||||
requestRuleExpr,
|
||||
rawRatios: { ...EMPTY_MODEL.rawRatios },
|
||||
hasConflict: false,
|
||||
};
|
||||
}
|
||||
|
||||
const modelRatio = toNumericString(sourceMaps.ModelRatio[name]);
|
||||
const completionRatio = toNumericString(sourceMaps.CompletionRatio[name]);
|
||||
const completionRatioMeta = normalizeCompletionRatioMeta(
|
||||
@@ -159,6 +181,7 @@ const buildModelState = (name, sourceMaps) => {
|
||||
toNumberOrNull(audioInputPrice) !== null && hasValue(audioCompletionRatio)
|
||||
? formatNumber(Number(audioInputPrice) * Number(audioCompletionRatio))
|
||||
: '',
|
||||
requestRuleExpr: '',
|
||||
rawRatios: {
|
||||
modelRatio,
|
||||
completionRatio,
|
||||
@@ -183,12 +206,16 @@ const buildModelState = (name, sourceMaps) => {
|
||||
};
|
||||
|
||||
export const isBasePricingUnset = (model) =>
|
||||
model.billingMode !== 'tiered_expr' &&
|
||||
!hasValue(model.fixedPrice) && !hasValue(model.inputPrice);
|
||||
|
||||
export const getModelWarnings = (model, t) => {
|
||||
if (!model) {
|
||||
return [];
|
||||
}
|
||||
if (model.billingMode === 'tiered_expr') {
|
||||
return [];
|
||||
}
|
||||
const warnings = [];
|
||||
const hasDerivedPricing = [
|
||||
model.inputPrice,
|
||||
@@ -244,8 +271,22 @@ export const getModelWarnings = (model, t) => {
|
||||
};
|
||||
|
||||
export const buildSummaryText = (model, t) => {
|
||||
const requestRuleSuffix =
|
||||
model.billingMode === 'tiered_expr' && model.requestRuleExpr
|
||||
? `,${t('请求规则')}`
|
||||
: '';
|
||||
if (model.billingMode === 'tiered_expr') {
|
||||
const expr = model.billingExpr;
|
||||
if (!expr) return `${t('表达式计费')}${requestRuleSuffix}`;
|
||||
const tierCount = (expr.match(/tier\(/g) || []).length;
|
||||
if (tierCount === 0) {
|
||||
return `${t('表达式计费')}${requestRuleSuffix}`;
|
||||
}
|
||||
return `${t('阶梯计费')} (${tierCount} ${t('档')})${requestRuleSuffix}`;
|
||||
}
|
||||
|
||||
if (model.billingMode === 'per-request' && hasValue(model.fixedPrice)) {
|
||||
return `${t('按次')} $${model.fixedPrice} / ${t('次')}`;
|
||||
return `${t('按次')} $${model.fixedPrice} / ${t('次')}${requestRuleSuffix}`;
|
||||
}
|
||||
|
||||
if (hasValue(model.inputPrice)) {
|
||||
@@ -259,10 +300,10 @@ export const buildSummaryText = (model, t) => {
|
||||
].filter(hasValue).length;
|
||||
const extraLabel =
|
||||
extraCount > 0 ? `,${t('额外价格项')} ${extraCount}` : '';
|
||||
return `${t('输入')} $${model.inputPrice}${extraLabel}`;
|
||||
return `${t('输入')} $${model.inputPrice}${extraLabel}${requestRuleSuffix}`;
|
||||
}
|
||||
|
||||
return t('未设置价格');
|
||||
return `${t('未设置价格')}${requestRuleSuffix}`;
|
||||
};
|
||||
|
||||
export const buildOptionalFieldToggles = (model) => ({
|
||||
@@ -395,20 +436,53 @@ const serializeModel = (model, t) => {
|
||||
|
||||
export const buildPreviewRows = (model, t) => {
|
||||
if (!model) return [];
|
||||
const finalBillingExpr = combineBillingExpr(
|
||||
model.billingExpr,
|
||||
model.requestRuleExpr,
|
||||
);
|
||||
|
||||
if (model.billingMode === 'tiered_expr') {
|
||||
const rows = [
|
||||
{
|
||||
key: 'BillingMode',
|
||||
label: 'ModelBillingMode',
|
||||
value: 'tiered_expr',
|
||||
},
|
||||
];
|
||||
if (finalBillingExpr) {
|
||||
const tierCount = (model.billingExpr.match(/tier\(/g) || []).length;
|
||||
rows.push({
|
||||
key: 'BillingExpr',
|
||||
label: 'ModelBillingExpr',
|
||||
value:
|
||||
tierCount > 0
|
||||
? `${tierCount} ${t('档')} — ${
|
||||
finalBillingExpr.length > 60
|
||||
? finalBillingExpr.slice(0, 60) + '...'
|
||||
: finalBillingExpr
|
||||
}`
|
||||
: finalBillingExpr.length > 60
|
||||
? finalBillingExpr.slice(0, 60) + '...'
|
||||
: finalBillingExpr,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (model.billingMode === 'per-request') {
|
||||
return [
|
||||
const rows = [
|
||||
{
|
||||
key: 'ModelPrice',
|
||||
label: 'ModelPrice',
|
||||
value: hasValue(model.fixedPrice) ? model.fixedPrice : t('空'),
|
||||
},
|
||||
];
|
||||
return rows;
|
||||
}
|
||||
|
||||
const inputPrice = toNumberOrNull(model.inputPrice);
|
||||
if (inputPrice === null) {
|
||||
return [
|
||||
const rows = [
|
||||
{
|
||||
key: 'ModelRatio',
|
||||
label: 'ModelRatio',
|
||||
@@ -459,6 +533,7 @@ export const buildPreviewRows = (model, t) => {
|
||||
: t('空'),
|
||||
},
|
||||
];
|
||||
return rows;
|
||||
}
|
||||
|
||||
const completionPrice = toNumberOrNull(model.completionPrice);
|
||||
@@ -468,7 +543,7 @@ export const buildPreviewRows = (model, t) => {
|
||||
const audioInputPrice = toNumberOrNull(model.audioInputPrice);
|
||||
const audioOutputPrice = toNumberOrNull(model.audioOutputPrice);
|
||||
|
||||
return [
|
||||
const rows = [
|
||||
{
|
||||
key: 'ModelRatio',
|
||||
label: 'ModelRatio',
|
||||
@@ -522,6 +597,7 @@ export const buildPreviewRows = (model, t) => {
|
||||
: t('空'),
|
||||
},
|
||||
];
|
||||
return rows;
|
||||
};
|
||||
|
||||
export function useModelPricingEditorState({
|
||||
@@ -552,6 +628,8 @@ export function useModelPricingEditorState({
|
||||
ImageRatio: parseOptionJSON(options.ImageRatio),
|
||||
AudioRatio: parseOptionJSON(options.AudioRatio),
|
||||
AudioCompletionRatio: parseOptionJSON(options.AudioCompletionRatio),
|
||||
ModelBillingMode: parseOptionJSON(options.ModelBillingMode),
|
||||
ModelBillingExpr: parseOptionJSON(options.ModelBillingExpr),
|
||||
};
|
||||
|
||||
const names = new Set([
|
||||
@@ -565,6 +643,8 @@ export function useModelPricingEditorState({
|
||||
...Object.keys(sourceMaps.ImageRatio),
|
||||
...Object.keys(sourceMaps.AudioRatio),
|
||||
...Object.keys(sourceMaps.AudioCompletionRatio),
|
||||
...Object.keys(sourceMaps.ModelBillingMode),
|
||||
...Object.keys(sourceMaps.ModelBillingExpr),
|
||||
]);
|
||||
|
||||
const nextModels = Array.from(names)
|
||||
@@ -775,10 +855,29 @@ export function useModelPricingEditorState({
|
||||
};
|
||||
|
||||
const handleBillingModeChange = (value) => {
|
||||
if (!selectedModel) return;
|
||||
upsertModel(selectedModel.name, (model) => {
|
||||
const next = { ...model, billingMode: value };
|
||||
if (value === 'tiered_expr' && !model.billingExpr) {
|
||||
next.billingExpr = 'tier("default", p * 0 + c * 0)';
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBillingExprChange = (newExpr) => {
|
||||
if (!selectedModel) return;
|
||||
upsertModel(selectedModel.name, (model) => ({
|
||||
...model,
|
||||
billingMode: value,
|
||||
billingExpr: newExpr,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRequestRuleExprChange = (newExpr) => {
|
||||
if (!selectedModel) return;
|
||||
upsertModel(selectedModel.name, (model) => ({
|
||||
...model,
|
||||
requestRuleExpr: newExpr,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -854,6 +953,8 @@ export function useModelPricingEditorState({
|
||||
imagePrice: selectedModel.imagePrice,
|
||||
audioInputPrice: selectedModel.audioInputPrice,
|
||||
audioOutputPrice: selectedModel.audioOutputPrice,
|
||||
billingExpr: selectedModel.billingExpr || '',
|
||||
requestRuleExpr: selectedModel.requestRuleExpr || '',
|
||||
};
|
||||
|
||||
if (
|
||||
@@ -915,7 +1016,26 @@ export function useModelPricingEditorState({
|
||||
AudioCompletionRatio: {},
|
||||
};
|
||||
|
||||
const tieredOutput = {
|
||||
ModelBillingMode: {},
|
||||
ModelBillingExpr: {},
|
||||
};
|
||||
|
||||
for (const model of models) {
|
||||
if (model.billingMode === 'tiered_expr') {
|
||||
tieredOutput.ModelBillingMode[model.name] = 'tiered_expr';
|
||||
const finalBillingExpr = combineBillingExpr(
|
||||
model.billingExpr,
|
||||
model.requestRuleExpr,
|
||||
);
|
||||
if (finalBillingExpr) {
|
||||
tieredOutput.ModelBillingExpr[model.name] = finalBillingExpr;
|
||||
}
|
||||
}
|
||||
if (model.billingMode === 'tiered_expr') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const serialized = serializeModel(model, t);
|
||||
Object.entries(serialized).forEach(([key, value]) => {
|
||||
if (value !== null) {
|
||||
@@ -924,12 +1044,20 @@ export function useModelPricingEditorState({
|
||||
});
|
||||
}
|
||||
|
||||
const requestQueue = Object.entries(output).map(([key, value]) =>
|
||||
API.put('/api/option/', {
|
||||
key,
|
||||
value: JSON.stringify(value, null, 2),
|
||||
}),
|
||||
);
|
||||
const requestQueue = [
|
||||
...Object.entries(output).map(([key, value]) =>
|
||||
API.put('/api/option/', {
|
||||
key,
|
||||
value: JSON.stringify(value, null, 2),
|
||||
}),
|
||||
),
|
||||
...Object.entries(tieredOutput).map(([key, value]) =>
|
||||
API.put('/api/option/', {
|
||||
key,
|
||||
value: JSON.stringify(value, null, 2),
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
const results = await Promise.all(requestQueue);
|
||||
for (const res of results) {
|
||||
@@ -970,6 +1098,8 @@ export function useModelPricingEditorState({
|
||||
handleOptionalFieldToggle,
|
||||
handleNumericFieldChange,
|
||||
handleBillingModeChange,
|
||||
handleBillingExprChange,
|
||||
handleRequestRuleExprChange,
|
||||
handleSubmit,
|
||||
addModel,
|
||||
deleteModel,
|
||||
|
||||
Reference in New Issue
Block a user