From c36200b78b046812cf2a7479e567fc825d66fa7e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 13:04:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(billing):=20=E8=AE=B0=E5=BD=95=20reasoning?= =?UTF-8?q?=20token=EF=BC=8C=E5=B9=B6=E6=94=AF=E6=8C=81=E6=8C=89=E8=A7=84?= =?UTF-8?q?=E5=88=99=E5=8D=95=E7=8B=AC=E5=AE=9A=E4=BB=B7=EF=BC=88=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E4=B8=8D=E5=90=AF=E7=94=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 断掉的链路 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 --- internal/gateway/chat.go | 15 ++++ internal/gateway/chat_reasoning_test.go | 71 ++++++++++++++++++ internal/gateway/stats.go | 10 +++ internal/lua/billing_test.go | 99 +++++++++++++++++++++++++ internal/lua/plugins/billing.lua | 30 ++++++++ 5 files changed, 225 insertions(+) create mode 100644 internal/gateway/chat_reasoning_test.go diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index fa5ceee..7df2eae 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -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, diff --git a/internal/gateway/chat_reasoning_test.go b/internal/gateway/chat_reasoning_test.go new file mode 100644 index 0000000..948806d --- /dev/null +++ b/internal/gateway/chat_reasoning_test.go @@ -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) + } + } +} diff --git a/internal/gateway/stats.go b/internal/gateway/stats.go index a376ee6..6e73022 100644 --- a/internal/gateway/stats.go +++ b/internal/gateway/stats.go @@ -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"` diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index 2dac77b..d60794c 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -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) +} diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index b6aa316..40da2f2 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -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