2026-03-06 21:36:51 +08:00
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
|
|
|
|
import { API, showError, showSuccess } from '../../../../helpers';
|
2026-03-16 16:00:22 +08:00
|
|
|
|
import {
|
|
|
|
|
|
combineBillingExpr,
|
|
|
|
|
|
splitBillingExprAndRequestRules,
|
|
|
|
|
|
} from '../components/requestRuleExpr';
|
2026-03-06 21:36:51 +08:00
|
|
|
|
|
|
|
|
|
|
export const PAGE_SIZE = 10;
|
|
|
|
|
|
export const PRICE_SUFFIX = '$/1M tokens';
|
|
|
|
|
|
const EMPTY_CANDIDATE_MODEL_NAMES = [];
|
|
|
|
|
|
|
|
|
|
|
|
const EMPTY_MODEL = {
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
billingMode: 'per-token',
|
|
|
|
|
|
fixedPrice: '',
|
|
|
|
|
|
inputPrice: '',
|
|
|
|
|
|
completionPrice: '',
|
|
|
|
|
|
lockedCompletionRatio: '',
|
|
|
|
|
|
completionRatioLocked: false,
|
|
|
|
|
|
cachePrice: '',
|
|
|
|
|
|
createCachePrice: '',
|
|
|
|
|
|
imagePrice: '',
|
|
|
|
|
|
audioInputPrice: '',
|
|
|
|
|
|
audioOutputPrice: '',
|
2026-03-16 16:00:22 +08:00
|
|
|
|
billingExpr: '',
|
|
|
|
|
|
requestRuleExpr: '',
|
2026-03-06 21:36:51 +08:00
|
|
|
|
rawRatios: {
|
|
|
|
|
|
modelRatio: '',
|
|
|
|
|
|
completionRatio: '',
|
|
|
|
|
|
cacheRatio: '',
|
|
|
|
|
|
createCacheRatio: '',
|
|
|
|
|
|
imageRatio: '',
|
|
|
|
|
|
audioRatio: '',
|
|
|
|
|
|
audioCompletionRatio: '',
|
|
|
|
|
|
},
|
|
|
|
|
|
hasConflict: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const NUMERIC_INPUT_REGEX = /^(\d+(\.\d*)?|\.\d*)?$/;
|
|
|
|
|
|
|
|
|
|
|
|
export const hasValue = (value) =>
|
|
|
|
|
|
value !== '' && value !== null && value !== undefined && value !== false;
|
|
|
|
|
|
|
|
|
|
|
|
const toNumericString = (value) => {
|
|
|
|
|
|
if (!hasValue(value) && value !== 0) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
const num = Number(value);
|
|
|
|
|
|
return Number.isFinite(num) ? String(num) : '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const toNumberOrNull = (value) => {
|
|
|
|
|
|
if (!hasValue(value) && value !== 0) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const num = Number(value);
|
|
|
|
|
|
return Number.isFinite(num) ? num : null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const formatNumber = (value) => {
|
|
|
|
|
|
const num = toNumberOrNull(value);
|
|
|
|
|
|
if (num === null) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
return parseFloat(num.toFixed(12)).toString();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-14 15:29:47 +08:00
|
|
|
|
const toNormalizedNumber = (value) => {
|
|
|
|
|
|
const formatted = formatNumber(value);
|
|
|
|
|
|
return formatted === '' ? null : Number(formatted);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-06 21:36:51 +08:00
|
|
|
|
const parseOptionJSON = (rawValue) => {
|
|
|
|
|
|
if (!rawValue || rawValue.trim() === '') {
|
|
|
|
|
|
return {};
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = JSON.parse(rawValue);
|
|
|
|
|
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('JSON解析错误:', error);
|
|
|
|
|
|
return {};
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const ratioToBasePrice = (ratio) => {
|
|
|
|
|
|
const num = toNumberOrNull(ratio);
|
|
|
|
|
|
if (num === null) return '';
|
|
|
|
|
|
return formatNumber(num * 2);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const normalizeCompletionRatioMeta = (rawMeta) => {
|
|
|
|
|
|
if (!rawMeta || typeof rawMeta !== 'object' || Array.isArray(rawMeta)) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
locked: false,
|
|
|
|
|
|
ratio: '',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
locked: Boolean(rawMeta.locked),
|
|
|
|
|
|
ratio: toNumericString(rawMeta.ratio),
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const buildModelState = (name, sourceMaps) => {
|
2026-03-16 16:00:22 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 21:36:51 +08:00
|
|
|
|
const modelRatio = toNumericString(sourceMaps.ModelRatio[name]);
|
|
|
|
|
|
const completionRatio = toNumericString(sourceMaps.CompletionRatio[name]);
|
|
|
|
|
|
const completionRatioMeta = normalizeCompletionRatioMeta(
|
|
|
|
|
|
sourceMaps.CompletionRatioMeta?.[name],
|
|
|
|
|
|
);
|
|
|
|
|
|
const cacheRatio = toNumericString(sourceMaps.CacheRatio[name]);
|
|
|
|
|
|
const createCacheRatio = toNumericString(sourceMaps.CreateCacheRatio[name]);
|
|
|
|
|
|
const imageRatio = toNumericString(sourceMaps.ImageRatio[name]);
|
|
|
|
|
|
const audioRatio = toNumericString(sourceMaps.AudioRatio[name]);
|
|
|
|
|
|
const audioCompletionRatio = toNumericString(
|
|
|
|
|
|
sourceMaps.AudioCompletionRatio[name],
|
|
|
|
|
|
);
|
|
|
|
|
|
const fixedPrice = toNumericString(sourceMaps.ModelPrice[name]);
|
|
|
|
|
|
const inputPrice = ratioToBasePrice(modelRatio);
|
|
|
|
|
|
const inputPriceNumber = toNumberOrNull(inputPrice);
|
|
|
|
|
|
const audioInputPrice =
|
|
|
|
|
|
inputPriceNumber !== null && hasValue(audioRatio)
|
|
|
|
|
|
? formatNumber(inputPriceNumber * Number(audioRatio))
|
|
|
|
|
|
: '';
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
...EMPTY_MODEL,
|
|
|
|
|
|
name,
|
|
|
|
|
|
billingMode: hasValue(fixedPrice) ? 'per-request' : 'per-token',
|
|
|
|
|
|
fixedPrice,
|
|
|
|
|
|
inputPrice,
|
|
|
|
|
|
completionRatioLocked: completionRatioMeta.locked,
|
|
|
|
|
|
lockedCompletionRatio: completionRatioMeta.ratio,
|
|
|
|
|
|
completionPrice:
|
|
|
|
|
|
inputPriceNumber !== null &&
|
2026-03-14 15:29:47 +08:00
|
|
|
|
hasValue(
|
|
|
|
|
|
completionRatioMeta.locked
|
|
|
|
|
|
? completionRatioMeta.ratio
|
|
|
|
|
|
: completionRatio,
|
|
|
|
|
|
)
|
2026-03-06 21:36:51 +08:00
|
|
|
|
? formatNumber(
|
|
|
|
|
|
inputPriceNumber *
|
|
|
|
|
|
Number(
|
|
|
|
|
|
completionRatioMeta.locked
|
|
|
|
|
|
? completionRatioMeta.ratio
|
|
|
|
|
|
: completionRatio,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
: '',
|
|
|
|
|
|
cachePrice:
|
|
|
|
|
|
inputPriceNumber !== null && hasValue(cacheRatio)
|
|
|
|
|
|
? formatNumber(inputPriceNumber * Number(cacheRatio))
|
|
|
|
|
|
: '',
|
|
|
|
|
|
createCachePrice:
|
|
|
|
|
|
inputPriceNumber !== null && hasValue(createCacheRatio)
|
|
|
|
|
|
? formatNumber(inputPriceNumber * Number(createCacheRatio))
|
|
|
|
|
|
: '',
|
|
|
|
|
|
imagePrice:
|
|
|
|
|
|
inputPriceNumber !== null && hasValue(imageRatio)
|
|
|
|
|
|
? formatNumber(inputPriceNumber * Number(imageRatio))
|
|
|
|
|
|
: '',
|
|
|
|
|
|
audioInputPrice,
|
|
|
|
|
|
audioOutputPrice:
|
|
|
|
|
|
toNumberOrNull(audioInputPrice) !== null && hasValue(audioCompletionRatio)
|
|
|
|
|
|
? formatNumber(Number(audioInputPrice) * Number(audioCompletionRatio))
|
|
|
|
|
|
: '',
|
2026-03-16 16:00:22 +08:00
|
|
|
|
requestRuleExpr: '',
|
2026-03-06 21:36:51 +08:00
|
|
|
|
rawRatios: {
|
|
|
|
|
|
modelRatio,
|
|
|
|
|
|
completionRatio,
|
|
|
|
|
|
cacheRatio,
|
|
|
|
|
|
createCacheRatio,
|
|
|
|
|
|
imageRatio,
|
|
|
|
|
|
audioRatio,
|
|
|
|
|
|
audioCompletionRatio,
|
|
|
|
|
|
},
|
|
|
|
|
|
hasConflict:
|
|
|
|
|
|
hasValue(fixedPrice) &&
|
|
|
|
|
|
[
|
|
|
|
|
|
modelRatio,
|
|
|
|
|
|
completionRatio,
|
|
|
|
|
|
cacheRatio,
|
|
|
|
|
|
createCacheRatio,
|
|
|
|
|
|
imageRatio,
|
|
|
|
|
|
audioRatio,
|
|
|
|
|
|
audioCompletionRatio,
|
|
|
|
|
|
].some(hasValue),
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const isBasePricingUnset = (model) =>
|
2026-03-16 16:00:22 +08:00
|
|
|
|
model.billingMode !== 'tiered_expr' &&
|
2026-03-06 21:36:51 +08:00
|
|
|
|
!hasValue(model.fixedPrice) && !hasValue(model.inputPrice);
|
|
|
|
|
|
|
|
|
|
|
|
export const getModelWarnings = (model, t) => {
|
|
|
|
|
|
if (!model) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
2026-03-16 16:00:22 +08:00
|
|
|
|
if (model.billingMode === 'tiered_expr') {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
2026-03-06 21:36:51 +08:00
|
|
|
|
const warnings = [];
|
|
|
|
|
|
const hasDerivedPricing = [
|
|
|
|
|
|
model.inputPrice,
|
|
|
|
|
|
model.completionPrice,
|
|
|
|
|
|
model.cachePrice,
|
|
|
|
|
|
model.createCachePrice,
|
|
|
|
|
|
model.imagePrice,
|
|
|
|
|
|
model.audioInputPrice,
|
|
|
|
|
|
model.audioOutputPrice,
|
|
|
|
|
|
].some(hasValue);
|
|
|
|
|
|
|
|
|
|
|
|
if (model.hasConflict) {
|
2026-03-14 15:29:47 +08:00
|
|
|
|
warnings.push(
|
|
|
|
|
|
t('当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。'),
|
|
|
|
|
|
);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
!hasValue(model.inputPrice) &&
|
|
|
|
|
|
[
|
|
|
|
|
|
model.rawRatios.completionRatio,
|
|
|
|
|
|
model.rawRatios.cacheRatio,
|
|
|
|
|
|
model.rawRatios.createCacheRatio,
|
|
|
|
|
|
model.rawRatios.imageRatio,
|
|
|
|
|
|
model.rawRatios.audioRatio,
|
|
|
|
|
|
model.rawRatios.audioCompletionRatio,
|
|
|
|
|
|
].some(hasValue)
|
|
|
|
|
|
) {
|
|
|
|
|
|
warnings.push(
|
2026-03-14 15:29:47 +08:00
|
|
|
|
t(
|
|
|
|
|
|
'当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。',
|
|
|
|
|
|
),
|
2026-03-06 21:36:51 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 15:29:47 +08:00
|
|
|
|
if (
|
|
|
|
|
|
model.billingMode === 'per-token' &&
|
|
|
|
|
|
hasDerivedPricing &&
|
|
|
|
|
|
!hasValue(model.inputPrice)
|
|
|
|
|
|
) {
|
2026-03-06 21:36:51 +08:00
|
|
|
|
warnings.push(t('按量计费下需要先填写输入价格,才能保存其它价格项。'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
model.billingMode === 'per-token' &&
|
|
|
|
|
|
hasValue(model.audioOutputPrice) &&
|
|
|
|
|
|
!hasValue(model.audioInputPrice)
|
|
|
|
|
|
) {
|
|
|
|
|
|
warnings.push(t('填写音频补全价格前,需要先填写音频输入价格。'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return warnings;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const buildSummaryText = (model, t) => {
|
2026-03-16 16:00:22 +08:00
|
|
|
|
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}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 21:36:51 +08:00
|
|
|
|
if (model.billingMode === 'per-request' && hasValue(model.fixedPrice)) {
|
2026-03-16 16:00:22 +08:00
|
|
|
|
return `${t('按次')} $${model.fixedPrice} / ${t('次')}${requestRuleSuffix}`;
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (hasValue(model.inputPrice)) {
|
|
|
|
|
|
const extraCount = [
|
|
|
|
|
|
model.completionPrice,
|
|
|
|
|
|
model.cachePrice,
|
|
|
|
|
|
model.createCachePrice,
|
|
|
|
|
|
model.imagePrice,
|
|
|
|
|
|
model.audioInputPrice,
|
|
|
|
|
|
model.audioOutputPrice,
|
|
|
|
|
|
].filter(hasValue).length;
|
|
|
|
|
|
const extraLabel =
|
|
|
|
|
|
extraCount > 0 ? `,${t('额外价格项')} ${extraCount}` : '';
|
2026-03-16 16:00:22 +08:00
|
|
|
|
return `${t('输入')} $${model.inputPrice}${extraLabel}${requestRuleSuffix}`;
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 16:00:22 +08:00
|
|
|
|
return `${t('未设置价格')}${requestRuleSuffix}`;
|
2026-03-06 21:36:51 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const buildOptionalFieldToggles = (model) => ({
|
2026-03-14 15:29:47 +08:00
|
|
|
|
completionPrice:
|
|
|
|
|
|
model.completionRatioLocked || hasValue(model.completionPrice),
|
2026-03-06 21:36:51 +08:00
|
|
|
|
cachePrice: hasValue(model.cachePrice),
|
|
|
|
|
|
createCachePrice: hasValue(model.createCachePrice),
|
|
|
|
|
|
imagePrice: hasValue(model.imagePrice),
|
|
|
|
|
|
audioInputPrice: hasValue(model.audioInputPrice),
|
|
|
|
|
|
audioOutputPrice: hasValue(model.audioOutputPrice),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const serializeModel = (model, t) => {
|
|
|
|
|
|
const result = {
|
|
|
|
|
|
ModelPrice: null,
|
|
|
|
|
|
ModelRatio: null,
|
|
|
|
|
|
CompletionRatio: null,
|
|
|
|
|
|
CacheRatio: null,
|
|
|
|
|
|
CreateCacheRatio: null,
|
|
|
|
|
|
ImageRatio: null,
|
|
|
|
|
|
AudioRatio: null,
|
|
|
|
|
|
AudioCompletionRatio: null,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (model.billingMode === 'per-request') {
|
|
|
|
|
|
if (hasValue(model.fixedPrice)) {
|
2026-03-14 15:29:47 +08:00
|
|
|
|
result.ModelPrice = toNormalizedNumber(model.fixedPrice);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const inputPrice = toNumberOrNull(model.inputPrice);
|
|
|
|
|
|
const completionPrice = toNumberOrNull(model.completionPrice);
|
|
|
|
|
|
const cachePrice = toNumberOrNull(model.cachePrice);
|
|
|
|
|
|
const createCachePrice = toNumberOrNull(model.createCachePrice);
|
|
|
|
|
|
const imagePrice = toNumberOrNull(model.imagePrice);
|
|
|
|
|
|
const audioInputPrice = toNumberOrNull(model.audioInputPrice);
|
|
|
|
|
|
const audioOutputPrice = toNumberOrNull(model.audioOutputPrice);
|
|
|
|
|
|
|
|
|
|
|
|
const hasDependentPrice = [
|
|
|
|
|
|
completionPrice,
|
|
|
|
|
|
cachePrice,
|
|
|
|
|
|
createCachePrice,
|
|
|
|
|
|
imagePrice,
|
|
|
|
|
|
audioInputPrice,
|
|
|
|
|
|
audioOutputPrice,
|
|
|
|
|
|
].some((value) => value !== null);
|
|
|
|
|
|
|
|
|
|
|
|
if (inputPrice === null) {
|
|
|
|
|
|
if (hasDependentPrice) {
|
|
|
|
|
|
throw new Error(
|
2026-03-14 15:29:47 +08:00
|
|
|
|
t(
|
|
|
|
|
|
'模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率',
|
|
|
|
|
|
{
|
|
|
|
|
|
name: model.name,
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
2026-03-06 21:36:51 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (hasValue(model.rawRatios.modelRatio)) {
|
2026-03-14 15:29:47 +08:00
|
|
|
|
result.ModelRatio = toNormalizedNumber(model.rawRatios.modelRatio);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (hasValue(model.rawRatios.completionRatio)) {
|
2026-03-14 15:29:47 +08:00
|
|
|
|
result.CompletionRatio = toNormalizedNumber(
|
|
|
|
|
|
model.rawRatios.completionRatio,
|
|
|
|
|
|
);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (hasValue(model.rawRatios.cacheRatio)) {
|
2026-03-14 15:29:47 +08:00
|
|
|
|
result.CacheRatio = toNormalizedNumber(model.rawRatios.cacheRatio);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (hasValue(model.rawRatios.createCacheRatio)) {
|
2026-03-14 15:29:47 +08:00
|
|
|
|
result.CreateCacheRatio = toNormalizedNumber(
|
|
|
|
|
|
model.rawRatios.createCacheRatio,
|
|
|
|
|
|
);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (hasValue(model.rawRatios.imageRatio)) {
|
2026-03-14 15:29:47 +08:00
|
|
|
|
result.ImageRatio = toNormalizedNumber(model.rawRatios.imageRatio);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (hasValue(model.rawRatios.audioRatio)) {
|
2026-03-14 15:29:47 +08:00
|
|
|
|
result.AudioRatio = toNormalizedNumber(model.rawRatios.audioRatio);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (hasValue(model.rawRatios.audioCompletionRatio)) {
|
2026-03-14 15:29:47 +08:00
|
|
|
|
result.AudioCompletionRatio = toNormalizedNumber(
|
|
|
|
|
|
model.rawRatios.audioCompletionRatio,
|
|
|
|
|
|
);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 15:29:47 +08:00
|
|
|
|
result.ModelRatio = toNormalizedNumber(inputPrice / 2);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
|
|
|
|
|
|
if (!model.completionRatioLocked && completionPrice !== null) {
|
2026-03-14 15:29:47 +08:00
|
|
|
|
result.CompletionRatio = toNormalizedNumber(completionPrice / inputPrice);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
} else if (
|
|
|
|
|
|
model.completionRatioLocked &&
|
|
|
|
|
|
hasValue(model.rawRatios.completionRatio)
|
|
|
|
|
|
) {
|
2026-03-14 15:29:47 +08:00
|
|
|
|
result.CompletionRatio = toNormalizedNumber(
|
|
|
|
|
|
model.rawRatios.completionRatio,
|
|
|
|
|
|
);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (cachePrice !== null) {
|
2026-03-14 15:29:47 +08:00
|
|
|
|
result.CacheRatio = toNormalizedNumber(cachePrice / inputPrice);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (createCachePrice !== null) {
|
2026-03-14 15:29:47 +08:00
|
|
|
|
result.CreateCacheRatio = toNormalizedNumber(createCachePrice / inputPrice);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (imagePrice !== null) {
|
2026-03-14 15:29:47 +08:00
|
|
|
|
result.ImageRatio = toNormalizedNumber(imagePrice / inputPrice);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (audioInputPrice !== null) {
|
2026-03-14 15:29:47 +08:00
|
|
|
|
result.AudioRatio = toNormalizedNumber(audioInputPrice / inputPrice);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (audioOutputPrice !== null) {
|
|
|
|
|
|
if (audioInputPrice === null || audioInputPrice === 0) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
t('模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率', {
|
|
|
|
|
|
name: model.name,
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-14 15:29:47 +08:00
|
|
|
|
result.AudioCompletionRatio = toNormalizedNumber(
|
|
|
|
|
|
audioOutputPrice / audioInputPrice,
|
|
|
|
|
|
);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const buildPreviewRows = (model, t) => {
|
|
|
|
|
|
if (!model) return [];
|
2026-03-16 16:00:22 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-06 21:36:51 +08:00
|
|
|
|
|
|
|
|
|
|
if (model.billingMode === 'per-request') {
|
2026-03-16 16:00:22 +08:00
|
|
|
|
const rows = [
|
2026-03-06 21:36:51 +08:00
|
|
|
|
{
|
|
|
|
|
|
key: 'ModelPrice',
|
|
|
|
|
|
label: 'ModelPrice',
|
|
|
|
|
|
value: hasValue(model.fixedPrice) ? model.fixedPrice : t('空'),
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
2026-03-16 16:00:22 +08:00
|
|
|
|
return rows;
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const inputPrice = toNumberOrNull(model.inputPrice);
|
|
|
|
|
|
if (inputPrice === null) {
|
2026-03-16 16:00:22 +08:00
|
|
|
|
const rows = [
|
2026-03-06 21:36:51 +08:00
|
|
|
|
{
|
|
|
|
|
|
key: 'ModelRatio',
|
|
|
|
|
|
label: 'ModelRatio',
|
|
|
|
|
|
value: hasValue(model.rawRatios.modelRatio)
|
|
|
|
|
|
? model.rawRatios.modelRatio
|
|
|
|
|
|
: t('空'),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'CompletionRatio',
|
|
|
|
|
|
label: 'CompletionRatio',
|
|
|
|
|
|
value: hasValue(model.rawRatios.completionRatio)
|
|
|
|
|
|
? model.rawRatios.completionRatio
|
|
|
|
|
|
: t('空'),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'CacheRatio',
|
|
|
|
|
|
label: 'CacheRatio',
|
|
|
|
|
|
value: hasValue(model.rawRatios.cacheRatio)
|
|
|
|
|
|
? model.rawRatios.cacheRatio
|
|
|
|
|
|
: t('空'),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'CreateCacheRatio',
|
|
|
|
|
|
label: 'CreateCacheRatio',
|
|
|
|
|
|
value: hasValue(model.rawRatios.createCacheRatio)
|
|
|
|
|
|
? model.rawRatios.createCacheRatio
|
|
|
|
|
|
: t('空'),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'ImageRatio',
|
|
|
|
|
|
label: 'ImageRatio',
|
|
|
|
|
|
value: hasValue(model.rawRatios.imageRatio)
|
|
|
|
|
|
? model.rawRatios.imageRatio
|
|
|
|
|
|
: t('空'),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'AudioRatio',
|
|
|
|
|
|
label: 'AudioRatio',
|
|
|
|
|
|
value: hasValue(model.rawRatios.audioRatio)
|
|
|
|
|
|
? model.rawRatios.audioRatio
|
|
|
|
|
|
: t('空'),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'AudioCompletionRatio',
|
|
|
|
|
|
label: 'AudioCompletionRatio',
|
|
|
|
|
|
value: hasValue(model.rawRatios.audioCompletionRatio)
|
|
|
|
|
|
? model.rawRatios.audioCompletionRatio
|
|
|
|
|
|
: t('空'),
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
2026-03-16 16:00:22 +08:00
|
|
|
|
return rows;
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const completionPrice = toNumberOrNull(model.completionPrice);
|
|
|
|
|
|
const cachePrice = toNumberOrNull(model.cachePrice);
|
|
|
|
|
|
const createCachePrice = toNumberOrNull(model.createCachePrice);
|
|
|
|
|
|
const imagePrice = toNumberOrNull(model.imagePrice);
|
|
|
|
|
|
const audioInputPrice = toNumberOrNull(model.audioInputPrice);
|
|
|
|
|
|
const audioOutputPrice = toNumberOrNull(model.audioOutputPrice);
|
|
|
|
|
|
|
2026-03-16 16:00:22 +08:00
|
|
|
|
const rows = [
|
2026-03-06 21:36:51 +08:00
|
|
|
|
{
|
|
|
|
|
|
key: 'ModelRatio',
|
|
|
|
|
|
label: 'ModelRatio',
|
|
|
|
|
|
value: formatNumber(inputPrice / 2),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'CompletionRatio',
|
|
|
|
|
|
label: 'CompletionRatio',
|
|
|
|
|
|
value: model.completionRatioLocked
|
|
|
|
|
|
? `${model.lockedCompletionRatio || t('空')} (${t('后端固定')})`
|
|
|
|
|
|
: completionPrice !== null
|
|
|
|
|
|
? formatNumber(completionPrice / inputPrice)
|
|
|
|
|
|
: t('空'),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'CacheRatio',
|
|
|
|
|
|
label: 'CacheRatio',
|
2026-03-14 15:29:47 +08:00
|
|
|
|
value:
|
|
|
|
|
|
cachePrice !== null ? formatNumber(cachePrice / inputPrice) : t('空'),
|
2026-03-06 21:36:51 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'CreateCacheRatio',
|
|
|
|
|
|
label: 'CreateCacheRatio',
|
|
|
|
|
|
value:
|
|
|
|
|
|
createCachePrice !== null
|
|
|
|
|
|
? formatNumber(createCachePrice / inputPrice)
|
|
|
|
|
|
: t('空'),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'ImageRatio',
|
|
|
|
|
|
label: 'ImageRatio',
|
2026-03-14 15:29:47 +08:00
|
|
|
|
value:
|
|
|
|
|
|
imagePrice !== null ? formatNumber(imagePrice / inputPrice) : t('空'),
|
2026-03-06 21:36:51 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'AudioRatio',
|
|
|
|
|
|
label: 'AudioRatio',
|
|
|
|
|
|
value:
|
|
|
|
|
|
audioInputPrice !== null
|
|
|
|
|
|
? formatNumber(audioInputPrice / inputPrice)
|
|
|
|
|
|
: t('空'),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'AudioCompletionRatio',
|
|
|
|
|
|
label: 'AudioCompletionRatio',
|
|
|
|
|
|
value:
|
2026-03-14 15:29:47 +08:00
|
|
|
|
audioOutputPrice !== null &&
|
|
|
|
|
|
audioInputPrice !== null &&
|
|
|
|
|
|
audioInputPrice !== 0
|
2026-03-06 21:36:51 +08:00
|
|
|
|
? formatNumber(audioOutputPrice / audioInputPrice)
|
|
|
|
|
|
: t('空'),
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
2026-03-16 16:00:22 +08:00
|
|
|
|
return rows;
|
2026-03-06 21:36:51 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export function useModelPricingEditorState({
|
|
|
|
|
|
options,
|
|
|
|
|
|
refresh,
|
|
|
|
|
|
t,
|
|
|
|
|
|
candidateModelNames = EMPTY_CANDIDATE_MODEL_NAMES,
|
|
|
|
|
|
filterMode = 'all',
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [models, setModels] = useState([]);
|
|
|
|
|
|
const [initialVisibleModelNames, setInitialVisibleModelNames] = useState([]);
|
|
|
|
|
|
const [selectedModelName, setSelectedModelName] = useState('');
|
|
|
|
|
|
const [selectedModelNames, setSelectedModelNames] = useState([]);
|
|
|
|
|
|
const [searchText, setSearchText] = useState('');
|
|
|
|
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [conflictOnly, setConflictOnly] = useState(false);
|
|
|
|
|
|
const [optionalFieldToggles, setOptionalFieldToggles] = useState({});
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const sourceMaps = {
|
|
|
|
|
|
ModelPrice: parseOptionJSON(options.ModelPrice),
|
|
|
|
|
|
ModelRatio: parseOptionJSON(options.ModelRatio),
|
|
|
|
|
|
CompletionRatio: parseOptionJSON(options.CompletionRatio),
|
|
|
|
|
|
CompletionRatioMeta: parseOptionJSON(options.CompletionRatioMeta),
|
|
|
|
|
|
CacheRatio: parseOptionJSON(options.CacheRatio),
|
|
|
|
|
|
CreateCacheRatio: parseOptionJSON(options.CreateCacheRatio),
|
|
|
|
|
|
ImageRatio: parseOptionJSON(options.ImageRatio),
|
|
|
|
|
|
AudioRatio: parseOptionJSON(options.AudioRatio),
|
|
|
|
|
|
AudioCompletionRatio: parseOptionJSON(options.AudioCompletionRatio),
|
2026-03-16 16:00:22 +08:00
|
|
|
|
ModelBillingMode: parseOptionJSON(options.ModelBillingMode),
|
|
|
|
|
|
ModelBillingExpr: parseOptionJSON(options.ModelBillingExpr),
|
2026-03-06 21:36:51 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const names = new Set([
|
|
|
|
|
|
...candidateModelNames,
|
|
|
|
|
|
...Object.keys(sourceMaps.ModelPrice),
|
|
|
|
|
|
...Object.keys(sourceMaps.ModelRatio),
|
|
|
|
|
|
...Object.keys(sourceMaps.CompletionRatio),
|
|
|
|
|
|
...Object.keys(sourceMaps.CompletionRatioMeta),
|
|
|
|
|
|
...Object.keys(sourceMaps.CacheRatio),
|
|
|
|
|
|
...Object.keys(sourceMaps.CreateCacheRatio),
|
|
|
|
|
|
...Object.keys(sourceMaps.ImageRatio),
|
|
|
|
|
|
...Object.keys(sourceMaps.AudioRatio),
|
|
|
|
|
|
...Object.keys(sourceMaps.AudioCompletionRatio),
|
2026-03-16 16:00:22 +08:00
|
|
|
|
...Object.keys(sourceMaps.ModelBillingMode),
|
|
|
|
|
|
...Object.keys(sourceMaps.ModelBillingExpr),
|
2026-03-06 21:36:51 +08:00
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
const nextModels = Array.from(names)
|
|
|
|
|
|
.map((name) => buildModelState(name, sourceMaps))
|
|
|
|
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
|
|
|
|
|
|
|
|
setModels(nextModels);
|
|
|
|
|
|
setInitialVisibleModelNames(
|
|
|
|
|
|
filterMode === 'unset'
|
|
|
|
|
|
? nextModels
|
|
|
|
|
|
.filter((model) => isBasePricingUnset(model))
|
|
|
|
|
|
.map((model) => model.name)
|
|
|
|
|
|
: nextModels.map((model) => model.name),
|
|
|
|
|
|
);
|
|
|
|
|
|
setOptionalFieldToggles(
|
|
|
|
|
|
nextModels.reduce((acc, model) => {
|
|
|
|
|
|
acc[model.name] = buildOptionalFieldToggles(model);
|
|
|
|
|
|
return acc;
|
|
|
|
|
|
}, {}),
|
|
|
|
|
|
);
|
|
|
|
|
|
setSelectedModelName((previous) => {
|
|
|
|
|
|
if (previous && nextModels.some((model) => model.name === previous)) {
|
|
|
|
|
|
return previous;
|
|
|
|
|
|
}
|
|
|
|
|
|
const nextVisibleModels =
|
|
|
|
|
|
filterMode === 'unset'
|
|
|
|
|
|
? nextModels.filter((model) => isBasePricingUnset(model))
|
|
|
|
|
|
: nextModels;
|
|
|
|
|
|
return nextVisibleModels[0]?.name || '';
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [candidateModelNames, filterMode, options]);
|
|
|
|
|
|
|
|
|
|
|
|
const visibleModels = useMemo(() => {
|
|
|
|
|
|
return filterMode === 'unset'
|
|
|
|
|
|
? models.filter((model) => initialVisibleModelNames.includes(model.name))
|
|
|
|
|
|
: models;
|
|
|
|
|
|
}, [filterMode, initialVisibleModelNames, models]);
|
|
|
|
|
|
|
|
|
|
|
|
const filteredModels = useMemo(() => {
|
|
|
|
|
|
return visibleModels.filter((model) => {
|
|
|
|
|
|
const keyword = searchText.trim().toLowerCase();
|
|
|
|
|
|
const keywordMatch = keyword
|
|
|
|
|
|
? model.name.toLowerCase().includes(keyword)
|
|
|
|
|
|
: true;
|
|
|
|
|
|
const conflictMatch = conflictOnly ? model.hasConflict : true;
|
|
|
|
|
|
return keywordMatch && conflictMatch;
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [conflictOnly, searchText, visibleModels]);
|
|
|
|
|
|
|
|
|
|
|
|
const pagedData = useMemo(() => {
|
|
|
|
|
|
const start = (currentPage - 1) * PAGE_SIZE;
|
|
|
|
|
|
return filteredModels.slice(start, start + PAGE_SIZE);
|
|
|
|
|
|
}, [currentPage, filteredModels]);
|
|
|
|
|
|
|
|
|
|
|
|
const selectedModel = useMemo(
|
2026-03-14 15:29:47 +08:00
|
|
|
|
() =>
|
|
|
|
|
|
visibleModels.find((model) => model.name === selectedModelName) || null,
|
2026-03-06 21:36:51 +08:00
|
|
|
|
[selectedModelName, visibleModels],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const selectedWarnings = useMemo(
|
|
|
|
|
|
() => getModelWarnings(selectedModel, t),
|
|
|
|
|
|
[selectedModel, t],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const previewRows = useMemo(
|
|
|
|
|
|
() => buildPreviewRows(selectedModel, t),
|
|
|
|
|
|
[selectedModel, t],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setCurrentPage(1);
|
|
|
|
|
|
}, [searchText, conflictOnly, filterMode, candidateModelNames]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setSelectedModelNames((previous) =>
|
2026-03-14 15:29:47 +08:00
|
|
|
|
previous.filter((name) =>
|
|
|
|
|
|
visibleModels.some((model) => model.name === name),
|
|
|
|
|
|
),
|
2026-03-06 21:36:51 +08:00
|
|
|
|
);
|
|
|
|
|
|
}, [visibleModels]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (visibleModels.length === 0) {
|
|
|
|
|
|
setSelectedModelName('');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!visibleModels.some((model) => model.name === selectedModelName)) {
|
|
|
|
|
|
setSelectedModelName(visibleModels[0].name);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [selectedModelName, visibleModels]);
|
|
|
|
|
|
|
|
|
|
|
|
const upsertModel = (name, updater) => {
|
|
|
|
|
|
setModels((previous) =>
|
|
|
|
|
|
previous.map((model) => {
|
|
|
|
|
|
if (model.name !== name) return model;
|
|
|
|
|
|
return typeof updater === 'function' ? updater(model) : updater;
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const isOptionalFieldEnabled = (model, field) => {
|
|
|
|
|
|
if (!model) return false;
|
|
|
|
|
|
const modelToggles = optionalFieldToggles[model.name];
|
|
|
|
|
|
if (modelToggles && typeof modelToggles[field] === 'boolean') {
|
|
|
|
|
|
return modelToggles[field];
|
|
|
|
|
|
}
|
|
|
|
|
|
return buildOptionalFieldToggles(model)[field];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const updateOptionalFieldToggle = (modelName, field, checked) => {
|
|
|
|
|
|
setOptionalFieldToggles((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[modelName]: {
|
|
|
|
|
|
...(prev[modelName] || {}),
|
|
|
|
|
|
[field]: checked,
|
|
|
|
|
|
},
|
|
|
|
|
|
}));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOptionalFieldToggle = (field, checked) => {
|
|
|
|
|
|
if (!selectedModel) return;
|
|
|
|
|
|
|
|
|
|
|
|
updateOptionalFieldToggle(selectedModel.name, field, checked);
|
|
|
|
|
|
|
|
|
|
|
|
if (checked) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
upsertModel(selectedModel.name, (model) => {
|
|
|
|
|
|
const nextModel = { ...model, [field]: '' };
|
|
|
|
|
|
|
|
|
|
|
|
if (field === 'audioInputPrice') {
|
|
|
|
|
|
nextModel.audioOutputPrice = '';
|
|
|
|
|
|
setOptionalFieldToggles((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[selectedModel.name]: {
|
|
|
|
|
|
...(prev[selectedModel.name] || {}),
|
|
|
|
|
|
audioInputPrice: false,
|
|
|
|
|
|
audioOutputPrice: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nextModel;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const fillDerivedPricesFromBase = (model, nextInputPrice) => {
|
|
|
|
|
|
const baseNumber = toNumberOrNull(nextInputPrice);
|
|
|
|
|
|
if (baseNumber === null) {
|
|
|
|
|
|
return model;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
...model,
|
|
|
|
|
|
completionPrice:
|
|
|
|
|
|
model.completionRatioLocked && hasValue(model.lockedCompletionRatio)
|
|
|
|
|
|
? formatNumber(baseNumber * Number(model.lockedCompletionRatio))
|
|
|
|
|
|
: !hasValue(model.completionPrice) &&
|
|
|
|
|
|
hasValue(model.rawRatios.completionRatio)
|
|
|
|
|
|
? formatNumber(baseNumber * Number(model.rawRatios.completionRatio))
|
|
|
|
|
|
: model.completionPrice,
|
|
|
|
|
|
cachePrice:
|
|
|
|
|
|
!hasValue(model.cachePrice) && hasValue(model.rawRatios.cacheRatio)
|
|
|
|
|
|
? formatNumber(baseNumber * Number(model.rawRatios.cacheRatio))
|
|
|
|
|
|
: model.cachePrice,
|
|
|
|
|
|
createCachePrice:
|
|
|
|
|
|
!hasValue(model.createCachePrice) &&
|
|
|
|
|
|
hasValue(model.rawRatios.createCacheRatio)
|
|
|
|
|
|
? formatNumber(baseNumber * Number(model.rawRatios.createCacheRatio))
|
|
|
|
|
|
: model.createCachePrice,
|
|
|
|
|
|
imagePrice:
|
|
|
|
|
|
!hasValue(model.imagePrice) && hasValue(model.rawRatios.imageRatio)
|
|
|
|
|
|
? formatNumber(baseNumber * Number(model.rawRatios.imageRatio))
|
|
|
|
|
|
: model.imagePrice,
|
|
|
|
|
|
audioInputPrice:
|
|
|
|
|
|
!hasValue(model.audioInputPrice) && hasValue(model.rawRatios.audioRatio)
|
|
|
|
|
|
? formatNumber(baseNumber * Number(model.rawRatios.audioRatio))
|
|
|
|
|
|
: model.audioInputPrice,
|
|
|
|
|
|
audioOutputPrice:
|
|
|
|
|
|
!hasValue(model.audioOutputPrice) &&
|
|
|
|
|
|
hasValue(model.rawRatios.audioRatio) &&
|
|
|
|
|
|
hasValue(model.rawRatios.audioCompletionRatio)
|
|
|
|
|
|
? formatNumber(
|
|
|
|
|
|
baseNumber *
|
|
|
|
|
|
Number(model.rawRatios.audioRatio) *
|
|
|
|
|
|
Number(model.rawRatios.audioCompletionRatio),
|
|
|
|
|
|
)
|
|
|
|
|
|
: model.audioOutputPrice,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleNumericFieldChange = (field, value) => {
|
|
|
|
|
|
if (!selectedModel || !NUMERIC_INPUT_REGEX.test(value)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
upsertModel(selectedModel.name, (model) => {
|
|
|
|
|
|
const updatedModel = { ...model, [field]: value };
|
|
|
|
|
|
|
|
|
|
|
|
if (field === 'inputPrice') {
|
|
|
|
|
|
return fillDerivedPricesFromBase(updatedModel, value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return updatedModel;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleBillingModeChange = (value) => {
|
2026-03-16 16:00:22 +08:00
|
|
|
|
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) => {
|
2026-03-06 21:36:51 +08:00
|
|
|
|
if (!selectedModel) return;
|
|
|
|
|
|
upsertModel(selectedModel.name, (model) => ({
|
|
|
|
|
|
...model,
|
2026-03-16 16:00:22 +08:00
|
|
|
|
billingExpr: newExpr,
|
|
|
|
|
|
}));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleRequestRuleExprChange = (newExpr) => {
|
|
|
|
|
|
if (!selectedModel) return;
|
|
|
|
|
|
upsertModel(selectedModel.name, (model) => ({
|
|
|
|
|
|
...model,
|
|
|
|
|
|
requestRuleExpr: newExpr,
|
2026-03-06 21:36:51 +08:00
|
|
|
|
}));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const addModel = (modelName) => {
|
|
|
|
|
|
const trimmedName = modelName.trim();
|
|
|
|
|
|
if (!trimmedName) {
|
|
|
|
|
|
showError(t('请输入模型名称'));
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (models.some((model) => model.name === trimmedName)) {
|
|
|
|
|
|
showError(t('模型名称已存在'));
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const nextModel = {
|
|
|
|
|
|
...EMPTY_MODEL,
|
|
|
|
|
|
name: trimmedName,
|
|
|
|
|
|
rawRatios: { ...EMPTY_MODEL.rawRatios },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
setModels((previous) => [nextModel, ...previous]);
|
|
|
|
|
|
setOptionalFieldToggles((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[trimmedName]: buildOptionalFieldToggles(nextModel),
|
|
|
|
|
|
}));
|
|
|
|
|
|
setSelectedModelName(trimmedName);
|
|
|
|
|
|
setCurrentPage(1);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const deleteModel = (name) => {
|
|
|
|
|
|
const nextModels = models.filter((model) => model.name !== name);
|
|
|
|
|
|
setModels(nextModels);
|
|
|
|
|
|
setOptionalFieldToggles((prev) => {
|
|
|
|
|
|
const next = { ...prev };
|
|
|
|
|
|
delete next[name];
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
2026-03-14 15:29:47 +08:00
|
|
|
|
setSelectedModelNames((previous) =>
|
|
|
|
|
|
previous.filter((item) => item !== name),
|
|
|
|
|
|
);
|
2026-03-06 21:36:51 +08:00
|
|
|
|
if (selectedModelName === name) {
|
|
|
|
|
|
setSelectedModelName(nextModels[0]?.name || '');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const applySelectedModelPricing = () => {
|
|
|
|
|
|
if (!selectedModel) {
|
|
|
|
|
|
showError(t('请先选择一个作为模板的模型'));
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (selectedModelNames.length === 0) {
|
|
|
|
|
|
showError(t('请先勾选需要批量设置的模型'));
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const sourceToggles = optionalFieldToggles[selectedModel.name] || {};
|
|
|
|
|
|
|
|
|
|
|
|
setModels((previous) =>
|
|
|
|
|
|
previous.map((model) => {
|
|
|
|
|
|
if (!selectedModelNames.includes(model.name)) {
|
|
|
|
|
|
return model;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const nextModel = {
|
|
|
|
|
|
...model,
|
|
|
|
|
|
billingMode: selectedModel.billingMode,
|
|
|
|
|
|
fixedPrice: selectedModel.fixedPrice,
|
|
|
|
|
|
inputPrice: selectedModel.inputPrice,
|
|
|
|
|
|
completionPrice: selectedModel.completionPrice,
|
|
|
|
|
|
cachePrice: selectedModel.cachePrice,
|
|
|
|
|
|
createCachePrice: selectedModel.createCachePrice,
|
|
|
|
|
|
imagePrice: selectedModel.imagePrice,
|
|
|
|
|
|
audioInputPrice: selectedModel.audioInputPrice,
|
|
|
|
|
|
audioOutputPrice: selectedModel.audioOutputPrice,
|
2026-03-16 16:00:22 +08:00
|
|
|
|
billingExpr: selectedModel.billingExpr || '',
|
|
|
|
|
|
requestRuleExpr: selectedModel.requestRuleExpr || '',
|
2026-03-06 21:36:51 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
nextModel.billingMode === 'per-token' &&
|
|
|
|
|
|
nextModel.completionRatioLocked &&
|
|
|
|
|
|
hasValue(nextModel.inputPrice) &&
|
|
|
|
|
|
hasValue(nextModel.lockedCompletionRatio)
|
|
|
|
|
|
) {
|
|
|
|
|
|
nextModel.completionPrice = formatNumber(
|
2026-03-14 15:29:47 +08:00
|
|
|
|
Number(nextModel.inputPrice) *
|
|
|
|
|
|
Number(nextModel.lockedCompletionRatio),
|
2026-03-06 21:36:51 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nextModel;
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
setOptionalFieldToggles((previous) => {
|
|
|
|
|
|
const next = { ...previous };
|
|
|
|
|
|
selectedModelNames.forEach((modelName) => {
|
|
|
|
|
|
const targetModel = models.find((item) => item.name === modelName);
|
|
|
|
|
|
next[modelName] = {
|
|
|
|
|
|
completionPrice: targetModel?.completionRatioLocked
|
|
|
|
|
|
? true
|
|
|
|
|
|
: Boolean(sourceToggles.completionPrice),
|
|
|
|
|
|
cachePrice: Boolean(sourceToggles.cachePrice),
|
|
|
|
|
|
createCachePrice: Boolean(sourceToggles.createCachePrice),
|
|
|
|
|
|
imagePrice: Boolean(sourceToggles.imagePrice),
|
|
|
|
|
|
audioInputPrice: Boolean(sourceToggles.audioInputPrice),
|
|
|
|
|
|
audioOutputPrice:
|
|
|
|
|
|
Boolean(sourceToggles.audioInputPrice) &&
|
|
|
|
|
|
Boolean(sourceToggles.audioOutputPrice),
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
showSuccess(
|
|
|
|
|
|
t('已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型', {
|
|
|
|
|
|
name: selectedModel.name,
|
|
|
|
|
|
count: selectedModelNames.length,
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const output = {
|
|
|
|
|
|
ModelPrice: {},
|
|
|
|
|
|
ModelRatio: {},
|
|
|
|
|
|
CompletionRatio: {},
|
|
|
|
|
|
CacheRatio: {},
|
|
|
|
|
|
CreateCacheRatio: {},
|
|
|
|
|
|
ImageRatio: {},
|
|
|
|
|
|
AudioRatio: {},
|
|
|
|
|
|
AudioCompletionRatio: {},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-16 16:00:22 +08:00
|
|
|
|
const tieredOutput = {
|
|
|
|
|
|
ModelBillingMode: {},
|
|
|
|
|
|
ModelBillingExpr: {},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-06 21:36:51 +08:00
|
|
|
|
for (const model of models) {
|
2026-03-16 16:00:22 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 21:36:51 +08:00
|
|
|
|
const serialized = serializeModel(model, t);
|
|
|
|
|
|
Object.entries(serialized).forEach(([key, value]) => {
|
|
|
|
|
|
if (value !== null) {
|
|
|
|
|
|
output[key][model.name] = value;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 16:00:22 +08:00
|
|
|
|
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),
|
|
|
|
|
|
}),
|
|
|
|
|
|
),
|
|
|
|
|
|
];
|
2026-03-06 21:36:51 +08:00
|
|
|
|
|
|
|
|
|
|
const results = await Promise.all(requestQueue);
|
|
|
|
|
|
for (const res of results) {
|
|
|
|
|
|
if (!res?.data?.success) {
|
|
|
|
|
|
throw new Error(res?.data?.message || t('保存失败,请重试'));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
showSuccess(t('保存成功'));
|
|
|
|
|
|
await refresh();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('保存失败:', error);
|
|
|
|
|
|
showError(error.message || t('保存失败,请重试'));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
models,
|
|
|
|
|
|
selectedModel,
|
|
|
|
|
|
selectedModelName,
|
|
|
|
|
|
selectedModelNames,
|
|
|
|
|
|
setSelectedModelName,
|
|
|
|
|
|
setSelectedModelNames,
|
|
|
|
|
|
searchText,
|
|
|
|
|
|
setSearchText,
|
|
|
|
|
|
currentPage,
|
|
|
|
|
|
setCurrentPage,
|
|
|
|
|
|
loading,
|
|
|
|
|
|
conflictOnly,
|
|
|
|
|
|
setConflictOnly,
|
|
|
|
|
|
filteredModels,
|
|
|
|
|
|
pagedData,
|
|
|
|
|
|
selectedWarnings,
|
|
|
|
|
|
previewRows,
|
|
|
|
|
|
isOptionalFieldEnabled,
|
|
|
|
|
|
handleOptionalFieldToggle,
|
|
|
|
|
|
handleNumericFieldChange,
|
|
|
|
|
|
handleBillingModeChange,
|
2026-03-16 16:00:22 +08:00
|
|
|
|
handleBillingExprChange,
|
|
|
|
|
|
handleRequestRuleExprChange,
|
2026-03-06 21:36:51 +08:00
|
|
|
|
handleSubmit,
|
|
|
|
|
|
addModel,
|
|
|
|
|
|
deleteModel,
|
|
|
|
|
|
applySelectedModelPricing,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|