mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(billing): 记录 reasoning token,并支持按规则单独定价(默认不启用)
## 断掉的链路
types.go 里 ReasoningTokens 从被定义起就没人读过。适配器层早就解析并透传了
(opencodego / gemini / anthropic 都写了那段提取),网关在 chat.go 记录 usage
时读了 PromptTokensDetails.CachedTokens,却在隔壁的
CompletionTokensDetails.ReasoningTokens 上把它丢了 —— 数字到了响应里,
在唯一能留住它的地方被扔掉。
后果:任何计费规则都无法区分「思考」和「可见输出」。
## 为什么默认不启用定价
实测四个源(deepseek-v4.1-flash / claude-opus-4-8 / glm-5.3-flash /
space-bunny-free,含 anthropic thinking opt-in)全部不返回该字段。所以
reasoning_price 默认为 nil,costFor 完全跳过该分支,标准公式与改动前逐位相同
—— 这是关键:现有账单不能因为这次改动而变动。
启用时只应用「差额」:把思考部分从普通输出价重定价到思考价。完整再收一次会
对每个思考 token 双重计费,这正是判据里最容易被写出的错误实现。
clamp 到 completion_tokens:供应商报出比 completion 还大的 reasoning 是畸形
数据,不 clamp 会得到 92 倍于整个请求的费用,而且这个数字看起来足够合理,
能在仪表盘上活下来。
判据三条 + 三次变异自证(移除 clamp / 双重计费 / 默认启用)全部变红。
## 遗留
reasoning_price 未接入 BillingRule DSL —— 目前只能通过直接写 prices 生效。
DSL 字段要连带改 config 结构、编译器和规则编辑器 UI,是独立一步。
实测全部源都不返回该字段,所以这条暂时没有实际影响面。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit c36200b78b)
This commit is contained in:
@ -789,6 +789,18 @@ func recordChatUsage(rec *Req, req *types.ChatRequest, resp *types.UnifiedRespon
|
||||
// Cache accounting: whichever source format the adapter normalized into
|
||||
// (prompt_tokens_details.cached_tokens or legacy prompt_cache_hit_tokens),
|
||||
// read it back so the request record carries the hit/miss split.
|
||||
// reasoning_tokens is a SUBSET of completion_tokens and is billed at the
|
||||
// completion rate by every provider measured, so it must NOT be added to
|
||||
// Compl. Recording it separately is what lets a price rule split thinking
|
||||
// from output for the providers that do price them differently, and what
|
||||
// keeps the billing plugin from guessing.
|
||||
//
|
||||
// Leaving it unrecorded is worse than useless: the adapters already parse it
|
||||
// and forward it (opencodego/gemini/anthropic all do), so the number exists
|
||||
// in the response and is thrown away at the one place that could keep it.
|
||||
if d := resp.TokenUsage.CompletionTokensDetails; d != nil && d.ReasoningTokens > 0 {
|
||||
rec.Reasoning = int64(d.ReasoningTokens)
|
||||
}
|
||||
if d := resp.TokenUsage.PromptTokensDetails; d != nil && d.CachedTokens > 0 {
|
||||
rec.CacheHit = int64(d.CachedTokens)
|
||||
}
|
||||
@ -1044,6 +1056,9 @@ func (g *Gateway) fireEnd(rec *Req) {
|
||||
// carried this (rec.CacheReported); the plugin payload did not, so any
|
||||
// plugin-level cache accounting had to guess.
|
||||
"cache_reported": rec.CacheReported,
|
||||
// A SUBSET of completion_tokens, not an extra. Billing plugins that
|
||||
// add it to completion would double-charge every thinking token.
|
||||
"reasoning_tokens": rec.Reasoning,
|
||||
"image_count": rec.ImageCount,
|
||||
"error": rec.Err,
|
||||
"time": rec.Time,
|
||||
|
||||
71
internal/gateway/chat_reasoning_test.go
Normal file
71
internal/gateway/chat_reasoning_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/types"
|
||||
)
|
||||
|
||||
// TestRecordChatUsageKeepsReasoningTokens pins the reasoning-token plumbing.
|
||||
//
|
||||
// The adapters already parse completion_tokens_details.reasoning_tokens and
|
||||
// forward it (opencodego, gemini, anthropic all do), and types.go has carried
|
||||
// the field since it was defined — but nothing ever READ it. The number arrived
|
||||
// in the response and was discarded at the one place that could have kept it,
|
||||
// so a pricing rule had no way to split thinking from output.
|
||||
//
|
||||
// The property that matters most: reasoning is a SUBSET of completion. Every
|
||||
// provider measured bills it at the completion rate, so a billing plugin that
|
||||
// added it to completion_tokens would double-charge every thinking token. The
|
||||
// test asserts completion is left alone.
|
||||
func TestRecordChatUsageKeepsReasoningTokens(t *testing.T) {
|
||||
rec := &Req{}
|
||||
resp := &types.UnifiedResponse{
|
||||
TokenUsage: types.TokenUsage{
|
||||
Prompt: 100,
|
||||
Completion: 500,
|
||||
CompletionTokensDetails: &types.CompletionTokensDetails{
|
||||
ReasoningTokens: 320,
|
||||
},
|
||||
},
|
||||
}
|
||||
recordChatUsage(rec, &types.ChatRequest{}, resp)
|
||||
|
||||
if rec.Reasoning != 320 {
|
||||
t.Errorf("Reasoning = %d, want 320", rec.Reasoning)
|
||||
}
|
||||
// The subset must not be folded into completion — it is already counted there.
|
||||
if rec.Compl != 500 {
|
||||
t.Errorf("Compl = %d, want 500 (reasoning is a subset, never additive)", rec.Compl)
|
||||
}
|
||||
if rec.Prompt != 100 {
|
||||
t.Errorf("Prompt = %d, want 100", rec.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordChatUsageReasoningAbsentStaysZero: "not reported" and "reported as
|
||||
// zero" must both leave the field at 0, and neither may invent a number. A
|
||||
// plugin reading Reasoning cannot tell them apart, which is why the field is
|
||||
// documented as "0 means not reported" rather than "0 means no thinking".
|
||||
func TestRecordChatUsageReasoningAbsentStaysZero(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
u types.TokenUsage
|
||||
}{
|
||||
{"no details object", types.TokenUsage{Prompt: 10, Completion: 20}},
|
||||
{"details without reasoning", types.TokenUsage{
|
||||
Prompt: 10,
|
||||
Completion: 20,
|
||||
CompletionTokensDetails: &types.CompletionTokensDetails{},
|
||||
}},
|
||||
} {
|
||||
rec := &Req{}
|
||||
recordChatUsage(rec, &types.ChatRequest{}, &types.UnifiedResponse{TokenUsage: tc.u})
|
||||
if rec.Reasoning != 0 {
|
||||
t.Errorf("%s: Reasoning = %d, want 0", tc.name, rec.Reasoning)
|
||||
}
|
||||
if rec.Compl != 20 {
|
||||
t.Errorf("%s: Compl = %d, want 20", tc.name, rec.Compl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,16 @@ type Req struct {
|
||||
// accounting at all (even when the hit count is 0). The WebUI shows
|
||||
// "0%" instead of "—" for such rows.
|
||||
CacheReported bool `json:"cache_reported,omitempty"`
|
||||
// Reasoning is completion_tokens_details.reasoning_tokens.
|
||||
//
|
||||
// It is a SUBSET of Compl, not an addition: reasoning tokens are billed at
|
||||
// the completion rate and already counted there. This field exists so a
|
||||
// pricing rule can split them when a provider prices thinking tokens
|
||||
// differently (most do NOT — OpenAI and DeepSeek bill them as ordinary
|
||||
// output), and so a reader can see what fraction of a bill is thinking.
|
||||
// 0 means "not reported", which is NOT the same as "no reasoning happened" —
|
||||
// three of the sources measured return no such field at all.
|
||||
Reasoning int64 `json:"reasoning_tokens,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
// Status http status code
|
||||
Status int `json:"status"`
|
||||
|
||||
@ -1040,3 +1040,102 @@ func TestUnpricedIsTrackedPerDay(t *testing.T) {
|
||||
t.Errorf("unpriced_reqs = %v, want 2", st["unpriced_reqs"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestReasoningBilledAtCompletionRateByDefault is the property that keeps the
|
||||
// standard formula matching a real invoice.
|
||||
//
|
||||
// reasoning_tokens is a subset of completion_tokens and every provider measured
|
||||
// (OpenAI, DeepSeek, Anthropic) charges it at the ordinary output rate. A rule
|
||||
// with no reasoning_price must therefore produce EXACTLY the number the
|
||||
// pre-reasoning formula produced — the field exists for a provider that prices
|
||||
// thinking differently, not as a new charge.
|
||||
func TestReasoningBilledAtCompletionRateByDefault(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
if err := ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"currency": "USD",
|
||||
"models": map[string]interface{}{
|
||||
// USD PER TOKEN (compile.go divides the per-million figure by 1e6).
|
||||
// prompt 0.15/M, completion 0.60/M. No reasoning_price anywhere.
|
||||
"m": map[string]interface{}{"prompt": 0.15e-6, "completion": 0.60e-6},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000,
|
||||
"reasoning_tokens": 800, // all of it thinking; still billed as output
|
||||
"time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
cost := st["total"].(map[string]interface{})["cost"].(float64)
|
||||
approx(t, "reasoning at completion rate", cost, 0.00075)
|
||||
}
|
||||
|
||||
// TestReasoningPriceOverridesOnlyWhenSet: with a rule that DOES price thinking
|
||||
// differently, only the difference is applied. Charging the reasoning portion a
|
||||
// second time in full is the obvious wrong implementation and it doubles the
|
||||
// completion line.
|
||||
func TestReasoningPriceOverridesOnlyWhenSet(t *testing.T) {
|
||||
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": 0.15e-6, "completion": 0.60e-6,
|
||||
// Thinking costs 3x ordinary output: 1.80/M.
|
||||
"reasoning_price": 1.80e-6,
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000, "reasoning_tokens": 800,
|
||||
"time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
cost := st["total"].(map[string]interface{})["cost"].(float64)
|
||||
// base = 1000*0.15e-6 + 1000*0.60e-6 = 0.00075
|
||||
// + 800 * (1.80-0.60)e-6 = 0.00096 → 0.00171
|
||||
approx(t, "reasoning repriced to 1.80/M", cost, 0.00171)
|
||||
}
|
||||
|
||||
// TestReasoningClampedToCompletion: a provider claiming MORE reasoning than
|
||||
// completion is malformed. Without a clamp the visible-output count goes
|
||||
// negative and the delta exceeds the whole request, so the bill silently
|
||||
// explodes on bad data instead of degrading.
|
||||
func TestReasoningClampedToCompletion(t *testing.T) {
|
||||
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": 0.15e-6, "completion": 0.60e-6, "reasoning_price": 1.80e-6,
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000, "reasoning_tokens": 99999,
|
||||
"time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
cost := st["total"].(map[string]interface{})["cost"].(float64)
|
||||
// reasoning is clamped from 99999 down to completion (1000):
|
||||
// base = 1000*0.15e-6 + 1000*0.60e-6 = 0.00075
|
||||
// delta = 1000 * (1.80-0.60)e-6 = 0.00120
|
||||
// total = 0.00195
|
||||
// Without the clamp this would be 0.18 — 92x the whole request, and a
|
||||
// number plausible enough to survive a glance at a dashboard.
|
||||
approx(t, "reasoning clamped to completion", cost, 0.00195)
|
||||
}
|
||||
|
||||
@ -192,6 +192,10 @@ local function priceFor(payload)
|
||||
out = {
|
||||
prompt = d.prompt or 0, completion = d.completion or 0,
|
||||
per_request = 0, cache_discount = d.cache_discount, peak = d.peak,
|
||||
-- nil = bill reasoning at the completion rate, which is what every
|
||||
-- provider measured actually charges. Set per-rule only for a provider
|
||||
-- that publishes a distinct rate for thinking tokens.
|
||||
reasoning_price = d.reasoning_price,
|
||||
}
|
||||
|
||||
-- model dimension (a token price overrides the default's token prices)
|
||||
@ -202,6 +206,7 @@ local function priceFor(payload)
|
||||
if mp.completion ~= nil then out.completion = mp.completion end
|
||||
if mp.per_request ~= nil then out.per_request = out.per_request + mp.per_request end
|
||||
if mp.cache_discount ~= nil then out.cache_discount = mp.cache_discount end
|
||||
if mp.reasoning_price ~= nil then out.reasoning_price = mp.reasoning_price end
|
||||
if mp.peak ~= nil then out.peak = mp.peak end
|
||||
end
|
||||
|
||||
@ -213,6 +218,7 @@ local function priceFor(payload)
|
||||
if sp.completion ~= nil then out.completion = sp.completion end
|
||||
if sp.per_request ~= nil then out.per_request = out.per_request + sp.per_request end
|
||||
if sp.cache_discount ~= nil then out.cache_discount = sp.cache_discount end
|
||||
if sp.reasoning_price ~= nil then out.reasoning_price = sp.reasoning_price end
|
||||
if sp.peak ~= nil then out.peak = sp.peak end
|
||||
end
|
||||
|
||||
@ -346,6 +352,30 @@ local function costFor(payload, price)
|
||||
+ cacheHit * price.prompt * discount
|
||||
+ completion * price.completion * mult
|
||||
|
||||
-- Reasoning tokens, when the rule prices them separately.
|
||||
--
|
||||
-- reasoning_tokens is a SUBSET of completion_tokens and is already inside
|
||||
-- the term above. This block only matters when a provider charges thinking
|
||||
-- at a DIFFERENT rate — and every provider measured does not: OpenAI,
|
||||
-- DeepSeek and Anthropic all bill reasoning at the ordinary output rate. So
|
||||
-- reasoning_price is nil by default and this is skipped entirely, which is
|
||||
-- what makes the standard formula match the invoice.
|
||||
--
|
||||
-- When it IS set, only the DIFFERENCE is applied: the reasoning portion is
|
||||
-- re-priced from the normal rate to the reasoning rate. Charging it a
|
||||
-- second time in full would double-bill every thinking token.
|
||||
local reasoningPrice = tonumber(price.reasoning_price)
|
||||
if reasoningPrice and reasoningPrice ~= price.completion then
|
||||
local reasoning = tonumber(payload.reasoning_tokens) or 0
|
||||
if reasoning > 0 then
|
||||
-- Defensive clamp: a provider reporting more reasoning than completion
|
||||
-- would otherwise produce a negative visible-output count and a cost
|
||||
-- delta larger than the whole request.
|
||||
if reasoning > completion then reasoning = completion end
|
||||
cost = cost + reasoning * (reasoningPrice - price.completion) * mult
|
||||
end
|
||||
end
|
||||
|
||||
local flat = price.per_request
|
||||
if not payload.ok and not plugin.count_failures then
|
||||
flat = 0
|
||||
|
||||
Reference in New Issue
Block a user