mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
## 断掉的链路 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>
72 lines
2.4 KiB
Go
72 lines
2.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|