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
+43 -4
View File
@@ -1561,18 +1561,57 @@ function transactionChargeCurrency(transaction: GatewayWalletTransaction) {
}
function formatTokenUsage(usage: Record<string, unknown>) {
const input = tokenValue(usage.inputTokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens);
const output = tokenValue(usage.outputTokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.completion_tokens);
const total = tokenValue(usage.totalTokens ?? usage.total_tokens ?? (input !== null && output !== null ? input + output : null));
if (input === null && output === null && total === null) return '-';
const input = tokenValue(
usage.inputTokens ??
usage.promptTokens ??
usage.input_tokens ??
usage.prompt_tokens,
);
const promptDetails = usageObject(
usage.prompt_tokens_details ?? usage.promptTokensDetails,
);
const inputDetails = usageObject(
usage.input_tokens_details ?? usage.inputTokensDetails,
);
const cachedInput = tokenValue(
usage.cachedInputTokens ??
usage.cachedPromptTokens ??
usage.cached_input_tokens ??
usage.cached_tokens ??
promptDetails.cached_tokens ??
promptDetails.cachedTokens ??
inputDetails.cached_tokens ??
inputDetails.cachedTokens,
);
const output = tokenValue(
usage.outputTokens ??
usage.completionTokens ??
usage.output_tokens ??
usage.completion_tokens,
);
const total = tokenValue(
usage.totalTokens ??
usage.total_tokens ??
(input !== null && output !== null ? input + output : null),
);
if (input === null && cachedInput === null && output === null && total === null) {
return '-';
}
return (
<span className="taskRecordTokenUsage">
<span>{formatCellValue(input)}/{formatCellValue(output)}</span>
{cachedInput !== null ? <span>{formatCellValue(cachedInput)}</span> : null}
<span>{formatCellValue(total)}</span>
</span>
);
}
function usageObject(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function tokenValue(value: unknown) {
if (value === undefined || value === null || value === '') return null;
const numericValue = typeof value === 'number' ? value : Number(value);
@@ -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)}` });