Files
ModelRouter/internal/gateway/thinking_test.go
JianFeeeee 93eeba3762 feat(types): 统一格式落地为内核实际使用的类型 —— 私有字段泄漏归零
Phase 2 收尾(入向)。ChatRequest 从"客户端发什么就是什么"改为
UnifiedRequest(最大集合)的别名,内核真正用上了那个超集类型。

改掉的具体缺陷:

1. 私有字段泄漏归零(v1 的核心问题)
   v1 的 disable_thinking 是内核私有开关,却被序列化进交给适配器的 body,
   迫使 10 个适配器各自写 req.disable_thinking = nil 来清理。
   现在它是中立的 thinking 字段({enabled, budget_tokens}),客户端与
   适配器都认这个词:
   - gateway/chat.go:thinkingFromClient() 把 disable_thinking 翻译成
     thinking.enabled=false;冲突时中立字段优先(它能带预算,布尔不能)
   - deepseek.lua:thinking.enabled=false → extra_body.thinking.type=disabled
   - anthropic.lua:thinking.enabled=true + budget → 原生 {type,budget_tokens}
     (enabled=false 时不传该字段——Anthropic 没有 type=disabled 这个写法)
   - 其余 7 个适配器:把"清理内核私有字段"改成"清理中立字段"

2. Tools/ToolChoice 改 json.RawMessage
   原为 []interface{}(OpenAI 原始形状 = 内核在假设某个协议)。
   内核从不解释工具 schema——它原样交给适配器,所以原始形状不损失任何
   内核侧统一性。ToolDef 结构化类型保留在包内供新适配器选用。

3. 删除 MarshalJSON 后门
   v1 手工 splice ExtraBody 进 OpenAI 形状的 body;现在 ExtraBody 是
   UnifiedRequest 上一个正正经经声明的字段。

渐进迁移:ChatRequest 保留为别名(不是新类型),49 处引用零改动编译,
语义变更不被机械改名淹没。

测试:新增 thinking_test.go(翻译链 4 例 + 泄漏回归 + tools 透传);
vm_test.go 两处迁移到中立语义。golden 字节级未漂移。
2026-10-11 09:35:32 +08:00

134 lines
4.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package gateway
import (
"encoding/json"
"testing"
"llmsproxy/internal/types"
)
// TestThinkingFromClient 把「客户端措辞 → 中立语义」这条翻译链钉死。
//
// v1 的问题:disable_thinking 是内核私有字段,被序列化进交给适配器的 body,
// 于是 10 个适配器各自写 req.disable_thinking = nil 来清理它。v2 把它换成
// 双方都认可的 thinking 字段,这条测试确保翻译只发生一次、且在适配器之前。
func TestThinkingFromClient(t *testing.T) {
cases := []struct {
name string
req chatRequest
want *types.ThinkingConfig
}{
{
name: "无任何字段 → 不带 thinking(上游用自己默认)",
req: chatRequest{},
want: nil,
},
{
name: "disable_thinking=true → enabled=false",
req: chatRequest{DisableThinking: true},
want: &types.ThinkingConfig{Enabled: boolPtr(false)},
},
{
name: "中立 thinking 显式给出 → 原样透传",
req: chatRequest{Thinking: &types.ThinkingConfig{Enabled: boolPtr(true), BudgetTokens: 4096}},
want: &types.ThinkingConfig{Enabled: boolPtr(true), BudgetTokens: 4096},
},
{
name: "两者冲突时,中立的 thinking 优先(它带预算,布尔表达不了)",
req: chatRequest{
DisableThinking: true,
Thinking: &types.ThinkingConfig{Enabled: boolPtr(true), BudgetTokens: 1024},
},
want: &types.ThinkingConfig{Enabled: boolPtr(true), BudgetTokens: 1024},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := c.req.thinkingFromClient()
if c.want == nil {
if got != nil {
t.Fatalf("want nil, got %+v", got)
}
return
}
if got == nil {
t.Fatal("want non-nil thinking, got nil")
}
if (got.Enabled == nil) != (c.want.Enabled == nil) {
t.Fatalf("Enabled presence mismatch: got %v want %v", got.Enabled, c.want.Enabled)
}
if got.Enabled != nil && *got.Enabled != *c.want.Enabled {
t.Errorf("Enabled = %v, want %v", *got.Enabled, *c.want.Enabled)
}
if got.BudgetTokens != c.want.BudgetTokens {
t.Errorf("BudgetTokens = %d, want %d", got.BudgetTokens, c.want.BudgetTokens)
}
})
}
}
// TestDisableThinkingNeverReachesAdapterBody 是那条泄漏的回归测试:内核私有
// 字段不得出现在交给适配器的 body 里。
func TestDisableThinkingNeverReachesAdapterBody(t *testing.T) {
var r chatRequest
if err := json.Unmarshal([]byte(`{"model":"m","messages":[],"disable_thinking":true}`), &r); err != nil {
t.Fatal(err)
}
inner := &types.ChatRequest{
Model: "m",
Thinking: r.thinkingFromClient(),
Tools: r.rawTools(),
ToolChoice: r.rawToolChoice(),
ExtraBody: r.ExtraBody,
ClientSession: "",
}
body, err := json.Marshal(inner)
if err != nil {
t.Fatal(err)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(body, &m); err != nil {
t.Fatal(err)
}
if _, leaked := m["disable_thinking"]; leaked {
t.Errorf("内核私有字段 disable_thinking 泄漏进适配器 body(v1 回归): %s", body)
}
if _, ok := m["thinking"]; !ok {
t.Errorf("中立 thinking 字段缺失: %s", body)
}
}
// TestToolsPassthroughRaw 确认 tools/tool_choice 以原始形状交给适配器:
// 内核不解释工具 schema,那是适配器的职责。
func TestToolsPassthroughRaw(t *testing.T) {
var r chatRequest
body := `{"model":"m","messages":[],"tools":[{"type":"function","function":{"name":"f","parameters":{"type":"object"}}}],"tool_choice":"auto"}`
if err := json.Unmarshal([]byte(body), &r); err != nil {
t.Fatal(err)
}
tools := r.rawTools()
if len(tools) == 0 {
t.Fatal("tools 未透传")
}
var decoded []map[string]interface{}
if err := json.Unmarshal(tools, &decoded); err != nil {
t.Fatalf("tools 不是合法 JSON: %v (%s)", err, tools)
}
if len(decoded) != 1 || decoded[0]["type"] != "function" {
t.Errorf("tools 形状被内核改写: %s", tools)
}
if tc := string(r.rawToolChoice()); tc != `"auto"` {
t.Errorf("tool_choice = %s, want \"auto\"", tc)
}
// 空值不应变成 "null" 出现在 body 里
var empty chatRequest
if got := empty.rawTools(); got != nil {
t.Errorf("无 tools 时应返回 nil, got %s", got)
}
if got := empty.rawToolChoice(); got != nil {
t.Errorf("无 tool_choice 时应返回 nil, got %s", got)
}
}
func boolPtr(v bool) *bool { return &v }