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>
This commit is contained in:
john
2026-06-18 14:25:47 +08:00
parent 5ec3011bb7
commit 441ba155cc
3 changed files with 89 additions and 4 deletions
+13 -3
View File
@@ -1,9 +1,13 @@
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),
@@ -46,15 +50,21 @@ export function computeDeltaCostCents(previous, current, config = loadBillingCon
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;
return Math.max(config.minBillCents, Math.ceil(deltaUsd * config.usdCnyRate * 100));
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));
return Math.max(config.minBillCents, Math.ceil(currCost * config.usdCnyRate * 100 * margin));
}
// 默认路径:按 Token 增量 × 人民币单价(分/1k tokens)扣费。
// 默认/回退路径:按 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);
+25 -1
View File
@@ -49,6 +49,30 @@ test('computeDeltaCostCents ignores backend USD cost unless explicitly enabled',
});
const rmbConfig = { ...config, useBackendCost: false };
assert.equal(computeDeltaCostCents(previous, current, rmbConfig), 5);
const usdConfig = { ...config, useBackendCost: true };
// 成本模式默认 margin=1deltaUsd 0.01 × 7.2 × 100 = 7.2 → ceil = 8
const usdConfig = { ...config, useBackendCost: true, marginMultiplier: 1 };
assert.equal(computeDeltaCostCents(previous, current, usdConfig), 8);
});
test('computeDeltaCostCents applies margin multiplier in cost mode', () => {
const previous = { lastInputTokens: 0, lastOutputTokens: 0, lastAccumulatedCost: 0 };
const current = normalizeTokenState({
accumulatedInputTokens: 1_000_000,
accumulatedOutputTokens: 10_000,
accumulatedCost: 0.25,
});
// deltaUsd 0.25 × 7.2 × 100 × 3 = 540 分(而非 flat token 的 2060 分)
const costConfig = { ...config, useBackendCost: true, marginMultiplier: 3 };
assert.equal(computeDeltaCostCents(previous, current, costConfig), 540);
});
test('computeDeltaCostCents falls back to token path when accumulatedCost missing', () => {
const previous = { lastInputTokens: 0, lastOutputTokens: 0 };
const current = normalizeTokenState({
accumulatedInputTokens: 1000,
accumulatedOutputTokens: 500,
// 上游未回传 accumulatedCost → 即便开启成本模式也回退 token 计费
});
const costConfig = { ...config, useBackendCost: true, marginMultiplier: 3 };
assert.equal(computeDeltaCostCents(previous, current, costConfig), 5);
});
+51
View File
@@ -0,0 +1,51 @@
# H5 计量计费网关设计
## 背景:当前计费的问题
当前计费内联在 `tkmind-proxy.mjs` 里,靠嗅探上游 SSE 的 `Finish` 帧扣费。100 服务实测发现三类问题:
1. **定价与成本脱钩,约 20× 超收**:默认 flat `2分/1k input` 对 DeepSeek 中继(goose `custom_tkmind_relay_deepseek`)严重偏贵。对账每条会话 H5 实扣 / goose 自报 `accumulated_cost × 7.2` 稳定落在 ~20×。换 provider 又会反向亏——单一硬编码 token 单价无法跟随模型。
2. **计量点分散**:只覆盖走 `tkmind-proxy` 的 H5 会话;`mindspace-agent-runner` 等其他 LLM 出口可能未计费。
3. **可靠性弱**`onFinish` 为 fire-and-forget`sse-billing.mjs``.catch(()=>{})`),扣费失败即漏计;`requestId` 全程传 `null`,无幂等键、无流水对账;读 `previous` 在事务外存在并发双扣竞态(`user-auth.mjs:943`)。
## 已落地的止血改动(本次)
`billing.mjs` 扩展 `H5_USE_BACKEND_COST` 成本路径,新增 `H5_MARGIN_MULTIPLIER`
```
最终扣费 = 上游 accumulated_cost(USD) 增量 × H5_USD_CNY_RATE × H5_MARGIN_MULTIPLIER
```
- 自动跟随 provider/模型,换模型不需重调 token 单价。
- 仅当上游回传 `accumulatedCost` 时生效;缺失则**回退**原 flat token 路径,不破坏现有计费。
- 生产启用:`.env``H5_USE_BACKEND_COST=1` + `H5_MARGIN_MULTIPLIER=<目标毛利>`
> ⚠️ 上线前需确认:一帧真实 SSE 的 `token_state` 是否带 `accumulated_cost`goose `sessions.db` 有该列,但要确认 SSE Finish 事件也序列化了它)。确认前 multiplier 改动是安全的(无 cost 即回退)。
## 目标架构:独立计量网关
把"计量 + 定价 + 扣费 + 对账"从 H5 代理里抽出,成为所有 LLM 出口的统一收口点。
```
H5 session ─┐
agent-runner ┤→ [计量网关] → upstream(goose/...) → usage+cost
cover-ai 等 ─┘ │
├─ 定价引擎(按 provider/模型,cost × margin
├─ 幂等扣费(requestId 去重 + 事务内读写钱包)
└─ ledger 流水(每次调用一条,可对账/审计/退款)
```
### 关键设计点
1. **统一收口**:所有发往 LLM 的调用都经网关,拿 provider 回传的真实 `usage` + `cost`,而非各出口各自实现。
2. **定价引擎按 provider 配置**`{ provider, model } → { 计价基准, marginMultiplier }`。DeepSeek 与未来的 Claude 成本差几十倍,必须差异化,不能一个 flat 单价打天下。优先用成本基准(cost × margin),无 cost 的 provider 才退回 token 单价表。
3. **幂等扣费**:每次调用带 `requestId`ledger 以 `requestId` 唯一约束,重放/重连不重复扣。当前 SSE 重连靠"accumulated 单调 → delta 0"兜底,脆弱,应换显式幂等键。
4. **事务内读写**:钱包扣减与计费状态读取在同一事务 + `FOR UPDATE`,消除并发双扣。
5. **ledger 流水表**`(request_id, user_id, session_id, provider, model, input_tokens, output_tokens, upstream_cost_usd, charged_cents, margin, created_at)`。支撑对账(H5 扣费 vs 上游成本)、用户账单明细、纠纷退款。
6. **失败可恢复**:扣费写库失败要可重试/补偿,不能像现在 fire-and-forget 直接吞掉。
### 落地阶段
- **P0(已做)**:成本模式 + margin,env 切换止血 20× 超收。
- **P1**:补 `requestId` 幂等 + 修事务竞态 + ledger 流水表(仍在 proxy 内,先把可靠性和对账补齐)。
- **P2**:抽出独立计量模块/服务,纳入 agent-runner 等其他出口,定价引擎按 provider 配置化。