From 441ba155ccbbb0c7f9f2d151f30200991aadf00e Mon Sep 17 00:00:00 2001 From: john Date: Thu, 18 Jun 2026 14:25:47 +0800 Subject: [PATCH] =?UTF-8?q?Bill=20H5=20usage=20by=20upstream=20cost=20?= =?UTF-8?q?=C3=97=20margin,=20not=20flat=20token=20rate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- billing.mjs | 16 +++++++++--- billing.test.mjs | 26 ++++++++++++++++++- docs/h5-metering-gateway.md | 51 +++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 docs/h5-metering-gateway.md diff --git a/billing.mjs b/billing.mjs index 1a0572f..a04ee8b 100644 --- a/billing.mjs +++ b/billing.mjs @@ -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); diff --git a/billing.test.mjs b/billing.test.mjs index 5279949..196f632 100644 --- a/billing.test.mjs +++ b/billing.test.mjs @@ -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=1:deltaUsd 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); +}); diff --git a/docs/h5-metering-gateway.md b/docs/h5-metering-gateway.md new file mode 100644 index 0000000..67da475 --- /dev/null +++ b/docs/h5-metering-gateway.md @@ -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 配置化。