Files
memind/billing.mjs
T
john 441ba155cc Bill H5 usage by upstream cost × margin, not flat token rate
Flat 2分/1k input overcharged the DeepSeek relay ~20x (verified on 100:
H5-charged / goose accumulated_cost stayed at ~20x across every session).
The flat rate is decoupled from real provider cost, so it overcharges
cheap providers and would underbill expensive ones.

Extend the H5_USE_BACKEND_COST path with H5_MARGIN_MULTIPLIER: charge
upstream accumulated_cost(USD) × rate × margin, which tracks the provider
automatically. Falls back to the token path when the upstream omits
accumulatedCost, so the change is safe to ship dormant.

See docs/h5-metering-gateway.md for the metering-gateway design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:25:47 +08:00

80 lines
3.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export function loadBillingConfig() {
// 默认按人民币分(CNY cents)计费;仅当 H5_USE_BACKEND_COST=1 时才用上游 USD 成本换算。
const useBackendCost = process.env.H5_USE_BACKEND_COST === '1';
// 成本模式下的毛利倍数:最终扣费 = 上游真实成本(USD) × 汇率 × marginMultiplier。
// 默认 1(按成本价卖,零毛利)——启用 useBackendCost 时务必显式设置目标倍数。
const marginMultiplier = Number(process.env.H5_MARGIN_MULTIPLIER ?? 1);
return {
useBackendCost,
usdCnyRate: Number(process.env.H5_USD_CNY_RATE ?? 7.2),
marginMultiplier: Number.isFinite(marginMultiplier) && marginMultiplier > 0 ? marginMultiplier : 1,
inputCentsPer1k: Number(process.env.H5_BILL_INPUT_CENTS_PER_1K ?? 2),
outputCentsPer1k: Number(process.env.H5_BILL_OUTPUT_CENTS_PER_1K ?? 6),
minBillCents: Number(process.env.H5_MIN_BILL_CENTS ?? 1),
};
}
export function normalizeTokenState(raw) {
if (!raw || typeof raw !== 'object') {
return {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
accumulatedInputTokens: 0,
accumulatedOutputTokens: 0,
accumulatedTotalTokens: 0,
accumulatedCost: null,
};
}
return {
inputTokens: Number(raw.inputTokens ?? raw.input_tokens ?? 0),
outputTokens: Number(raw.outputTokens ?? raw.output_tokens ?? 0),
totalTokens: Number(raw.totalTokens ?? raw.total_tokens ?? 0),
accumulatedInputTokens: Number(
raw.accumulatedInputTokens ?? raw.accumulated_input_tokens ?? raw.inputTokens ?? 0,
),
accumulatedOutputTokens: Number(
raw.accumulatedOutputTokens ?? raw.accumulated_output_tokens ?? raw.outputTokens ?? 0,
),
accumulatedTotalTokens: Number(
raw.accumulatedTotalTokens ?? raw.accumulated_total_tokens ?? raw.totalTokens ?? 0,
),
accumulatedCost:
raw.accumulatedCost ?? raw.accumulated_cost ?? null,
};
}
export function computeDeltaCostCents(previous, current, config = loadBillingConfig()) {
const prevCost =
previous?.lastAccumulatedCost == null ? null : Number(previous.lastAccumulatedCost);
const currCost =
current.accumulatedCost == null ? null : Number(current.accumulatedCost);
// 成本模式:按上游真实成本(USD)增量 × 汇率 × 毛利倍数扣费,自动跟随 provider/模型,
// 无需为每个模型重调 token 单价。仅在上游确实回传 accumulatedCost 时生效;否则回退 token 路径。
const margin = config.marginMultiplier ?? 1;
if (config.useBackendCost && prevCost != null && currCost != null && currCost >= prevCost) {
const deltaUsd = currCost - prevCost;
if (deltaUsd <= 0) return 0;
return Math.max(config.minBillCents, Math.ceil(deltaUsd * config.usdCnyRate * 100 * margin));
}
if (config.useBackendCost && prevCost == null && currCost != null && currCost > 0) {
return Math.max(config.minBillCents, Math.ceil(currCost * config.usdCnyRate * 100 * margin));
}
// 默认/回退路径:按 Token 增量 × 人民币单价(分/1k tokens)扣费。
// 注意:flat 单价与 provider 真实成本脱钩(对 DeepSeek 中继约 20× 超收),
// 生产应启用 useBackendCost + marginMultiplier 走成本模式,详见 docs/h5-metering-gateway.md。
const prevIn = Number(previous?.lastInputTokens ?? 0);
const prevOut = Number(previous?.lastOutputTokens ?? 0);
const deltaIn = Math.max(0, current.accumulatedInputTokens - prevIn);
const deltaOut = Math.max(0, current.accumulatedOutputTokens - prevOut);
if (deltaIn === 0 && deltaOut === 0) return 0;
const raw =
(deltaIn / 1000) * config.inputCentsPer1k + (deltaOut / 1000) * config.outputCentsPer1k;
if (raw <= 0) return 0;
return Math.max(config.minBillCents, Math.ceil(raw));
}