mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 04:07:25 +00:00
feat(billing): 长上下文阶梯定价(边际费率,与缓存折扣/峰段可叠加)
## 影响面实测 审计里 1000 条请求:**883 条(88.3%)prompt 超过 128k**,中位数 246849, p90 298179,最大 354251。这不是小众场景,是 agent 负载的主流形态。 按「模型 × 源」拆开:其中真正按 token 计价的只有 alittokenplan 的 10 次 (deepseek-v4.1-flash,约 350k prompt),其余走 $70 套餐(commandcode)或 免费源(localzen),记 0 是正确的,对阶梯无影响。 那 10 次当前少算 54.9%($0.3148 → $0.4877)。绝对值不大,但比例严重, 而且这个偏差只会随上下文变长而变大。 ## 边际而非混合平均 阶梯只对**超过阈值的部分**重新计价,不是把整段按高价算。理由是可叠加性: 缓存读取同时享受缓存折扣**和**长上下文阶梯价,这与供应商的计费方式一致。 混合平均必须二选一,选错就正好低估「缓存命中的长请求」——而这恰是缓存 存在的意义所在(本部署缓存命中率 88%)。 超出部分按缓存/新鲜的比例拆分,不重不漏。 ## DSL 两种写法 供应商两种发布方式都支持:绝对费率(OpenAI 报 \$3.00/M past 200k)和 倍数(DeepSeek 报 2x past 128k)。两者都缺必须报错 —— 一个没有费率的阶梯 会编译成一条「看起来处理了长上下文、实际没处理」的规则,静默少算且无迹可寻。 两者都给时以绝对费率为准,因为那是供应商真正报出的数字。 ## 判据 Lua 侧两条(边际性、与缓存折扣叠加)+ Go 侧一条(两种写法与拒绝规则)。 变异自证三次:整段按高价 / 缓存部分不叠加折扣 / 缓存部分不计阶梯, 分别命中 0.40 vs 0.272、0.165 vs 0.2325、0.09 vs 0.2325。 规则编辑器加了三个输入(阈值 + 绝对费率 + 倍数),三项都留空 = 无阶梯 —— 与「阶梯费率为 0」刻意区分,所以是三个独立输入而不是从 0 推断是否存在阶梯。 Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
This commit is contained in:
@ -1139,3 +1139,100 @@ func TestReasoningClampedToCompletion(t *testing.T) {
|
||||
// number plausible enough to survive a glance at a dashboard.
|
||||
approx(t, "reasoning clamped to completion", cost, 0.00195)
|
||||
}
|
||||
|
||||
// TestLongContextStepIsMarginal: the threshold reprices only the tokens PAST
|
||||
// it, at a marginal rate.
|
||||
//
|
||||
// A flat prompt price under-bills exactly the traffic an agent workload
|
||||
// produces — on this deployment 88% of requests exceed 128k with a median of
|
||||
// 247k — and it under-bills silently, because the number stays plausible. The
|
||||
// step must apply to the excess only; charging the WHOLE prompt at the higher
|
||||
// rate is the other obvious implementation and it overcharges short requests by
|
||||
// the same factor it was meant to fix long ones.
|
||||
func TestLongContextStepIsMarginal(t *testing.T) {
|
||||
|
||||
// Short request: below the threshold, so the step must not touch it at all.
|
||||
ps, _ := billingVM(t)
|
||||
if err := ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"currency": "USD",
|
||||
"models": map[string]interface{}{
|
||||
"m": map[string]interface{}{
|
||||
"prompt": 1e-6, "completion": 0,
|
||||
"long_context": map[string]interface{}{"above": 128000, "prompt": 2e-6},
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 100000, "completion_tokens": 0, "time": 1750000000000,
|
||||
})
|
||||
got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64)
|
||||
approx(t, "short request unaffected", got, 0.1)
|
||||
|
||||
// Long request: 200k prompt with a 128k threshold → 72k tokens at the step.
|
||||
ps2, _ := billingVM(t)
|
||||
if err := ps2.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"currency": "USD",
|
||||
"models": map[string]interface{}{
|
||||
"m": map[string]interface{}{
|
||||
"prompt": 1e-6, "completion": 0,
|
||||
"long_context": map[string]interface{}{"above": 128000, "prompt": 2e-6},
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps2.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 200000, "completion_tokens": 0, "time": 1750000000000,
|
||||
})
|
||||
got2 := stateOf(t, ps2)["total"].(map[string]interface{})["cost"].(float64)
|
||||
// 128000*1e-6 + 72000*2e-6 = 0.128 + 0.144 = 0.272
|
||||
approx(t, "long request marginal step", got2, 0.272)
|
||||
}
|
||||
|
||||
// TestLongContextComposesWithCacheDiscount pins the interaction, which is the
|
||||
// whole reason for computing the step as a marginal rate.
|
||||
//
|
||||
// On this deployment the cache hit rate is 88% and long prompts dominate, so
|
||||
// "cached tokens beyond the threshold" is the common case, not an edge case. A
|
||||
// cache read already bills at prompt*discount; the long-context rate applies on
|
||||
// top of THAT, not on top of the full prompt price. Computing it any other way
|
||||
// silently changes the price of the majority of traffic.
|
||||
func TestLongContextComposesWithCacheDiscount(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
if err := ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"currency": "USD", "cache_discount": 0.1,
|
||||
"models": map[string]interface{}{
|
||||
"m": map[string]interface{}{
|
||||
"prompt": 1e-6, "completion": 0,
|
||||
"long_context": map[string]interface{}{"above": 100000, "prompt": 2e-6},
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 200k prompt, 150k of it cached, 50k fresh. Excess past 100k = 100k, split
|
||||
// 25% fresh / 75% cached.
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 200000, "completion_tokens": 0,
|
||||
"cache_hit_tokens": 150000, "cache_reported": true,
|
||||
"time": 1750000000000,
|
||||
})
|
||||
got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64)
|
||||
// base: fresh 50000*1e-6 = 0.05
|
||||
// cache 150000*1e-6*0.1 = 0.015
|
||||
// step: fresh excess 25000*(2-1)e-6 = 0.025
|
||||
// cache excess 75000*(2-0.1)e-6 = 0.1425
|
||||
// total = 0.2325
|
||||
approx(t, "step composes with cache discount", got, 0.2325)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user