package gateway import ( "encoding/json" "testing" "modelrouter/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 }