feat: support cached input token billing

This commit is contained in:
2026-06-23 17:17:57 +08:00
parent 6089aa6085
commit 7f32446466
16 changed files with 570 additions and 45 deletions
@@ -30,9 +30,10 @@ const modeDefinitions: ModeDefinition[] = [
{
key: 'text',
label: '文本',
formula: '一次对话扣费 = 输入 Token / 1000 × 输入扣费单价 + 输出 Token / 1000 × 输出扣费单价。',
match: (rule) => rule.resourceType.startsWith('text_'),
templates: (currency) => [createTextRule(currency, 0.01, 0.03)],
formula:
'一次对话扣费 = 未命中缓存输入 Token / 1000 × 输入扣费单价 + 命中缓存输入 Token / 1000 × 缓存输入扣费单价 + 输出 Token / 1000 × 输出扣费单价。',
match: isTextPricingRule,
templates: (currency) => [createTextRule(currency, 0.01, 0.01, 0.03)],
parameterGroups: [],
},
{
@@ -160,7 +161,7 @@ function summarizePricingMode(mode: ModeDefinition, rules: PricingRuleInput[]) {
const rule = rules[0];
if (mode.key === 'text') {
const prices = textPrices(rule);
return `文本按输入/输出 Token 分别计费,输入 ${prices.inputTokenPrice}/${unitLabel(rule.unit)},输出 ${prices.outputTokenPrice}/${unitLabel(rule.unit)}`;
return `文本按输入/缓存输入/输出 Token 分别计费,输入 ${prices.inputTokenPrice}/${unitLabel(rule.unit)}缓存输入 ${prices.cachedInputTokenPrice}/${unitLabel(rule.unit)}输出 ${prices.outputTokenPrice}/${unitLabel(rule.unit)}`;
}
const groupSummaries = mode.parameterGroups
.map((group) => summarizeWeightGroup(group, readGroup(rule.dynamicWeight, group)))
@@ -260,7 +261,11 @@ function TextRuleEditor(props: {
}) {
const prices = textPrices(props.rule);
function updatePrices(inputTokenPrice: number, outputTokenPrice: number) {
function updatePrices(
inputTokenPrice: number,
cachedInputTokenPrice: number,
outputTokenPrice: number,
) {
props.onChange({
...props.rule,
basePrice: inputTokenPrice,
@@ -272,11 +277,17 @@ function TextRuleEditor(props: {
...(props.rule.formulaConfig ?? {}),
formula: textFormula,
inputTokenPrice,
cachedInputTokenPrice,
outputTokenPrice,
},
dimensionSchema: {
...(props.rule.dimensionSchema ?? {}),
metrics: ['input_tokens', 'output_tokens'],
metrics: [
'input_tokens',
'uncached_input_tokens',
'cached_input_tokens',
'output_tokens',
],
unitScale: 1000,
},
});
@@ -301,10 +312,52 @@ function TextRuleEditor(props: {
<Select size="sm" value={props.rule.status ?? 'active'} onChange={(event) => props.onChange({ ...props.rule, status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
</PricingFormRow>
<PricingFormRow label="输入单价/1K tokens">
<Input size="sm" min="0" step="0.0001" type="number" value={prices.inputTokenPrice} onChange={(event) => updatePrices(Number(event.target.value), prices.outputTokenPrice)} />
<Input
size="sm"
min="0"
step="0.0001"
type="number"
value={prices.inputTokenPrice}
onChange={(event) =>
updatePrices(
Number(event.target.value),
prices.cachedInputTokenPrice,
prices.outputTokenPrice,
)
}
/>
</PricingFormRow>
<PricingFormRow label="缓存输入单价/1K tokens">
<Input
size="sm"
min="0"
step="0.0001"
type="number"
value={prices.cachedInputTokenPrice}
onChange={(event) =>
updatePrices(
prices.inputTokenPrice,
Number(event.target.value),
prices.outputTokenPrice,
)
}
/>
</PricingFormRow>
<PricingFormRow label="输出单价/1K tokens">
<Input size="sm" min="0" step="0.0001" type="number" value={prices.outputTokenPrice} onChange={(event) => updatePrices(prices.inputTokenPrice, Number(event.target.value))} />
<Input
size="sm"
min="0"
step="0.0001"
type="number"
value={prices.outputTokenPrice}
onChange={(event) =>
updatePrices(
prices.inputTokenPrice,
prices.cachedInputTokenPrice,
Number(event.target.value),
)
}
/>
</PricingFormRow>
<PricingReadonlyRow label="计价单位" value={unitLabel(props.rule.unit)} />
<PricingReadonlyRow label="计算方式" value={calculatorLabel(props.rule.calculatorType)} />
@@ -462,14 +515,38 @@ function mergeTemplateRules(templates: PricingRuleInput[], rules: PricingRuleInp
}
function mergeTextRules(rules: PricingRuleInput[], currency: string): PricingRuleInput {
const totalRule = rules.find((rule) => rule.resourceType === 'text_total');
const totalRule = rules.find((rule) => rule.resourceType === 'text_total' || rule.resourceType === 'text' || rule.ruleKey === 'text');
const inputRule = rules.find((rule) => rule.resourceType === 'text_input');
const cachedInputRule = rules.find(
(rule) => rule.resourceType === 'text_cached_input',
);
const outputRule = rules.find((rule) => rule.resourceType === 'text_output');
const source = totalRule ?? inputRule ?? outputRule ?? createTextRule(currency, 0.01, 0.03);
const source =
totalRule ??
inputRule ??
cachedInputRule ??
outputRule ??
createTextRule(currency, 0.01, 0.01, 0.03);
return createTextRule(
source.currency ?? currency,
Number(source.formulaConfig?.inputTokenPrice ?? inputRule?.basePrice ?? source.basePrice ?? 0.01),
Number(source.formulaConfig?.outputTokenPrice ?? outputRule?.basePrice ?? 0.03),
Number(
source.formulaConfig?.inputTokenPrice ??
inputRule?.basePrice ??
source.basePrice ??
0.01,
),
Number(
source.formulaConfig?.cachedInputTokenPrice ??
source.formulaConfig?.inputCacheHitTokenPrice ??
cachedInputRule?.basePrice ??
source.formulaConfig?.inputTokenPrice ??
inputRule?.basePrice ??
source.basePrice ??
0.01,
),
Number(
source.formulaConfig?.outputTokenPrice ?? outputRule?.basePrice ?? 0.03,
),
source,
);
}
@@ -478,9 +555,16 @@ function readGroup(value: RecordValue | undefined, group: ModeDefinition['parame
return isPlainObject(value?.[group.key]) ? value?.[group.key] as RecordValue : group.defaults;
}
const textFormula = 'input_tokens / 1000 * input_token_price + output_tokens / 1000 * output_token_price';
const textFormula =
'uncached_input_tokens / 1000 * input_token_price + cached_input_tokens / 1000 * cached_input_token_price + output_tokens / 1000 * output_token_price';
function createTextRule(currency: string, inputTokenPrice: number, outputTokenPrice: number, source?: PricingRuleInput): PricingRuleInput {
function createTextRule(
currency: string,
inputTokenPrice: number,
cachedInputTokenPrice: number,
outputTokenPrice: number,
source?: PricingRuleInput,
): PricingRuleInput {
return {
...(source ?? {}),
ruleKey: 'text',
@@ -493,7 +577,12 @@ function createTextRule(currency: string, inputTokenPrice: number, outputTokenPr
baseWeight: {},
dynamicWeight: {},
dimensionSchema: {
metrics: ['input_tokens', 'output_tokens'],
metrics: [
'input_tokens',
'uncached_input_tokens',
'cached_input_tokens',
'output_tokens',
],
unitScale: 1000,
...(source?.dimensionSchema ?? {}),
},
@@ -501,6 +590,7 @@ function createTextRule(currency: string, inputTokenPrice: number, outputTokenPr
...(source?.formulaConfig ?? {}),
formula: textFormula,
inputTokenPrice,
cachedInputTokenPrice,
outputTokenPrice,
},
priority: source?.priority ?? 100,
@@ -510,12 +600,36 @@ function createTextRule(currency: string, inputTokenPrice: number, outputTokenPr
}
function textPrices(rule: PricingRuleInput) {
const compatRule = rule as PricingRuleInput & {
cachedInputTokenPrice?: number;
inputCacheHitTokenPrice?: number;
inputTokenPrice?: number;
outputTokenPrice?: number;
};
const inputTokenPrice = Number(
rule.formulaConfig?.inputTokenPrice ?? compatRule.inputTokenPrice ?? rule.basePrice ?? 0.01,
);
return {
inputTokenPrice: Number(rule.formulaConfig?.inputTokenPrice ?? rule.basePrice ?? 0.01),
outputTokenPrice: Number(rule.formulaConfig?.outputTokenPrice ?? 0.03),
inputTokenPrice,
cachedInputTokenPrice: Number(
rule.formulaConfig?.cachedInputTokenPrice ??
rule.formulaConfig?.inputCacheHitTokenPrice ??
compatRule.cachedInputTokenPrice ??
compatRule.inputCacheHitTokenPrice ??
inputTokenPrice,
),
outputTokenPrice: Number(rule.formulaConfig?.outputTokenPrice ?? compatRule.outputTokenPrice ?? 0.03),
};
}
function isTextPricingRule(rule: PricingRuleInput) {
const resourceType = String(rule.resourceType ?? '');
const ruleKey = String(rule.ruleKey ?? '');
return textPricingKeys.has(resourceType) || textPricingKeys.has(ruleKey);
}
const textPricingKeys = new Set(['text', 'text_total', 'text_input', 'text_cached_input', 'text_output']);
function createRule(ruleKey: string, displayName: string, resourceType: string, unit: string, basePrice: number, currency: string, calculatorType: string, formula: string, dynamicWeight: RecordValue, dimensionSchema: RecordValue): PricingRuleInput {
return {
ruleKey,
+15 -2
View File
@@ -266,14 +266,27 @@ function pricingRuleSummaries(ruleSet: PricingRuleSet): PricingRuleSummary[] {
const rules = ruleSet.rules ?? [];
const textTotal = rules.find((rule) => rule.resourceType === 'text_total' || rule.ruleKey === 'text');
const textInput = rules.find((rule) => rule.resourceType === 'text_input');
const textCachedInput = rules.find(
(rule) => rule.resourceType === 'text_cached_input',
);
const textOutput = rules.find((rule) => rule.resourceType === 'text_output');
const image = rules.find((rule) => rule.resourceType === 'image');
const video = rules.find((rule) => rule.resourceType === 'video');
const items: PricingRuleSummary[] = [];
if (textTotal || textInput || textOutput) {
if (textTotal || textInput || textCachedInput || textOutput) {
const inputPrice = Number(textTotal?.formulaConfig?.inputTokenPrice ?? textInput?.basePrice ?? textTotal?.basePrice ?? 0);
const cachedInputPrice = Number(
textTotal?.formulaConfig?.cachedInputTokenPrice ??
textTotal?.formulaConfig?.inputCacheHitTokenPrice ??
textCachedInput?.basePrice ??
inputPrice,
);
const outputPrice = Number(textTotal?.formulaConfig?.outputTokenPrice ?? textOutput?.basePrice ?? 0);
items.push({ kind: 'text', label: '文本', value: `输入 ${formatPrice(inputPrice)} / 输出 ${formatPrice(outputPrice)} / 1K Token` });
items.push({
kind: 'text',
label: '文本',
value: `输入 ${formatPrice(inputPrice)} / 缓存输入 ${formatPrice(cachedInputPrice)} / 输出 ${formatPrice(outputPrice)} / 1K Token`,
});
}
if (image) items.push({ kind: 'image', label: '图像', value: `${formatPrice(image.basePrice)} / ${unitLabel(image.unit)}` });
if (video) items.push({ kind: 'video', label: '视频', value: `${formatPrice(video.basePrice)} / ${unitLabel(video.unit)}` });