package model import ( "context" "net/http" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) // 本文件钉住「按提供商独立适配推理开关」(裁决 03.4.2 / 08.4.1):声明了形状的端点 // 必须真的下发**自己形状**的开关(而不是只 WARN),且绝不发别人的形状——发错形状会 // 400,比不发更糟。自动模式(未声明形状)保持保守:形状有歧义时不下发。 // recvWithin 在超时内从记录通道取一次请求。路由/下发出错时(请求打到了另一个端点 // 或根本没发),用例必须**立刻失败**并指明哪个端点没收到,而不是挂在通道上把整个包 // 拖到测试超时——挂死的红是让人看不懂的红。 func recvWithin[T any](t *testing.T, ch <-chan T, endpoint string) T { t.Helper() select { case got := <-ch: return got case <-time.After(2 * time.Second): var zero T t.Fatalf("%s did not receive the request within 2s", endpoint) return zero } } // newKindAdapter 构造一个 OpenAI 兼容适配器,其配置声明了给定的推理开关形状。 // 真实端点由 stub server 顶替,请求体被记录供断言。 func newKindAdapter(t *testing.T, kind string, ch chan<- capturedRequest, opts ...OpenAIOption) *OpenAIChatAdapter { t.Helper() srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch) return NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "endpoint-default", ReasoningKind: kind}, opts...) } // declaredShapeCases 是「声明形状 → 该形状的开关字段」的对照表。model 刻意取一个 // **会误导自动识别**的名字:显式形状必须压过模型名/BaseURL 的厂商推断。 var declaredShapeCases = []struct { name string kind string model string field string }{ {"deepseek", runtimeconfig.ReasoningKindDeepSeek, "qwen3-max", "thinking"}, {"vllm", runtimeconfig.ReasoningKindVLLM, "deepseek-flash", "chat_template_kwargs"}, {"dashscope", runtimeconfig.ReasoningKindDashScope, "deepseek-flash", "enable_thinking"}, } // allSwitchFields 是全部形状的开关字段,用于断言「只发自己那一个」。 var allSwitchFields = []string{"thinking", "chat_template_kwargs", "enable_thinking"} // switchBool 把一个开关字段读成布尔值(thinking 的开关读嵌套的 type)。 func switchBool(body map[string]any, field string) (bool, bool) { value, present := body[field] if !present { return false, false } switch v := value.(type) { case bool: return v, true case map[string]any: if field == "thinking" { return v["type"] == thinkingTypeEnabled, true } if nested, ok := v["enable_thinking"].(bool); ok { return nested, true } } return false, true } // TestOpenAIChat_DeclaredShapeSendsItsOwnSwitch 是本次裁决的主用例:每个 provider // 声明自己的形状后,关闭思考必须真的下发到该形状的字段上,且请求体不含任何别的形状。 func TestOpenAIChat_DeclaredShapeSendsItsOwnSwitch(t *testing.T) { t.Parallel() for _, tc := range declaredShapeCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) ad := newKindAdapter(t, tc.kind, ch) if _, err := ad.Chat(context.Background(), Request{ Model: tc.model, Messages: []Message{TextMessage(RoleUser, "hi")}, Thinking: &Thinking{Enable: boolPtr(false)}, }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } body := recvWithin(t, ch, "the declared endpoint").body got, present := switchBool(body, tc.field) if !present { t.Fatalf("body[%q] missing: the declared shape %q was not forwarded: %v", tc.field, tc.kind, body) } if got { t.Fatalf("body[%q] = thinking on, want off", tc.field) } for _, other := range allSwitchFields { if other == tc.field { continue } if _, present := body[other]; present { t.Errorf("body[%q] = %v, want no other shape's field (shape %q)", other, body[other], tc.kind) } } }) } } // TestOpenAIChat_DeclaredShapeEnablesThinking 也钉住「开启」方向, // 以免开关被一次重构硬接成关闭。 func TestOpenAIChat_DeclaredShapeEnablesThinking(t *testing.T) { t.Parallel() for _, tc := range declaredShapeCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) ad := newKindAdapter(t, tc.kind, ch) if _, err := ad.Chat(context.Background(), Request{ Model: tc.model, Messages: []Message{TextMessage(RoleUser, "hi")}, Thinking: &Thinking{Enable: boolPtr(true)}, }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } got, present := switchBool(recvWithin(t, ch, "the declared endpoint").body, tc.field) if !present || !got { t.Fatalf("body[%q] present=%v value=%v, want an explicit on", tc.field, present, got) } }) } } // TestOpenAIChat_DeclaredSwitchShapeDropsEffort 是「该形状没有 reasoning_effort」 // 的红线用例:强度档被丢弃并报告,绝不改写成近似档(改档会反转调用方意图)。 func TestOpenAIChat_DeclaredSwitchShapeDropsEffort(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) log, buf := captureLog() ad := newKindAdapter(t, runtimeconfig.ReasoningKindVLLM, ch, WithOpenAILogger(log)) if _, err := ad.Chat(context.Background(), Request{ Model: "qwen3-max", Messages: []Message{TextMessage(RoleUser, "hi")}, ReasoningEffort: "high", }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } body := recvWithin(t, ch, "the declared endpoint").body if _, present := body["reasoning_effort"]; present { t.Fatalf("body.reasoning_effort = %v, want the field dropped", body["reasoning_effort"]) } if _, present := body["chat_template_kwargs"]; present { t.Fatalf("body.chat_template_kwargs = %v, want no switch invented from an effort", body["chat_template_kwargs"]) } rec := buf.String() for _, want := range []string{"level=WARN", "reasoning_effort", "high", "vllm", "chat_template_kwargs.enable_thinking"} { if !strings.Contains(rec, want) { t.Errorf("log %q missing %q", rec, want) } } } // TestOpenAIChat_EffortNoneBecomesExplicitSwitchOff 是红线用例:`reasoning_effort: // none` 的语义是**关思考**。在只有布尔开关的端点上它必须落成显式关闭,绝不能被 // 翻成任何强度档(那正是旧实现把「关」变成「开」的语义反转)。 func TestOpenAIChat_EffortNoneBecomesExplicitSwitchOff(t *testing.T) { t.Parallel() for _, tc := range declaredShapeCases { if tc.kind == runtimeconfig.ReasoningKindDeepSeek { continue // DeepSeek 有 reasoning_effort 字段,none 原样下发(另有用例钉住) } t.Run(tc.kind, func(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) log, buf := captureLog() ad := newKindAdapter(t, tc.kind, ch, WithOpenAILogger(log)) if _, err := ad.Chat(context.Background(), Request{ Model: tc.model, Messages: []Message{TextMessage(RoleUser, "hi")}, ReasoningEffort: effortNone, }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } body := recvWithin(t, ch, "the declared endpoint").body got, present := switchBool(body, tc.field) if !present || got { t.Fatalf("body[%q] present=%v value=%v, want an explicit off", tc.field, present, got) } if _, present := body["reasoning_effort"]; present { t.Fatalf("body.reasoning_effort = %v, want the field dropped", body["reasoning_effort"]) } if rec := buf.String(); !strings.Contains(rec, effortNone) || !strings.Contains(rec, "level=WARN") { t.Errorf("log %q, want a WARN explaining the none→off translation", rec) } }) } } // TestOpenAIChat_ReasoningKindOpenAIKeepsOpenAITiers 用一个 DeepSeek 风格的模型名 // 证明显式形状压过厂商推断:`medium` 在 OpenAI 白名单里是合法档,必须原样下发, // 不能按 DeepSeek 别名表被改写成 high。 func TestOpenAIChat_ReasoningKindOpenAIKeepsOpenAITiers(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) ad := newKindAdapter(t, runtimeconfig.ReasoningKindOpenAI, ch) if _, err := ad.Chat(context.Background(), Request{ Model: "deepseek-flash", Messages: []Message{TextMessage(RoleUser, "hi")}, ReasoningEffort: "medium", }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } body := recvWithin(t, ch, "the declared endpoint").body if got := body["reasoning_effort"]; got != "medium" { t.Fatalf("body.reasoning_effort = %v, want medium (an OpenAI tier, not the DeepSeek alias high)", got) } for _, other := range allSwitchFields { if _, present := body[other]; present { t.Errorf("body[%q] = %v, want no switch shape on an effort-only endpoint", other, body[other]) } } } // TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch 断言「仅 effort」 // 形状不会静默吞掉布尔 thinking 开关:它会报告该情况,并 // 指出文档记录在案的关闭推理的做法。 func TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) log, buf := captureLog() ad := newKindAdapter(t, runtimeconfig.ReasoningKindOpenAI, ch, WithOpenAILogger(log)) if _, err := ad.Chat(context.Background(), Request{ Model: "gpt-4o", Messages: []Message{TextMessage(RoleUser, "hi")}, Thinking: &Thinking{Enable: boolPtr(false)}, }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } body := recvWithin(t, ch, "the declared endpoint").body for _, other := range allSwitchFields { if _, present := body[other]; present { t.Fatalf("body[%q] present, want nothing sent for a boolean switch on an effort-only endpoint", other) } } rec := buf.String() for _, want := range []string{"level=WARN", "thinking.enable", "openai", "reasoning_effort"} { if !strings.Contains(rec, want) { t.Errorf("log %q missing %q", rec, want) } } } // TestOpenAIChat_UnknownReasoningKindSendsNothing 断言本次构建 // 未实现的形状会被上报、且什么都不发——即便模型名 // 看上去像我们确实理解的厂商也一样。在这里靠猜正是 // 裁决所禁止的「假适配」。 func TestOpenAIChat_UnknownReasoningKindSendsNothing(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) log, buf := captureLog() ad := newKindAdapter(t, "gemini", ch, WithOpenAILogger(log)) if _, err := ad.Chat(context.Background(), Request{ Model: "gpt-4o", Messages: []Message{TextMessage(RoleUser, "hi")}, Thinking: &Thinking{Enable: boolPtr(false)}, ReasoningEffort: "low", }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } body := recvWithin(t, ch, "the declared endpoint").body for _, field := range append(append([]string{}, allSwitchFields...), "reasoning_effort") { if _, present := body[field]; present { t.Errorf("body[%q] = %v, want nothing sent for an unknown shape", field, body[field]) } } rec := buf.String() for _, want := range []string{"level=WARN", "reasoning.kind", "gemini"} { if !strings.Contains(rec, want) { t.Errorf("log %q missing %q", rec, want) } } } // TestOpenAIChat_AutoQwenShapeStaysConservative 钉住未声明的默认: // Qwen 端点可能是 vLLM 或 DashScope,两者字段形状不同,所以 auto 模式 // 什么都不发,并改为要求显式声明,而不是替它猜一个。 func TestOpenAIChat_AutoQwenShapeStaysConservative(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) log, buf := captureLog() ad := newKindAdapter(t, "", ch, WithOpenAILogger(log)) if _, err := ad.Chat(context.Background(), Request{ Model: "qwen3-max", Messages: []Message{TextMessage(RoleUser, "hi")}, Thinking: &Thinking{Enable: boolPtr(false)}, }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } body := recvWithin(t, ch, "the declared endpoint").body for _, field := range allSwitchFields { if _, present := body[field]; present { t.Errorf("body[%q] = %v, want nothing sent while the shape is ambiguous", field, body[field]) } } rec := buf.String() for _, want := range []string{"level=WARN", "enable_thinking", "qwen", "reasoning.kind"} { if !strings.Contains(rec, want) { t.Errorf("log %q missing %q", rec, want) } } } // TestNewChatChain_CarriesReasoningKind 钉住配置到适配器的贯通:链的每一步都从 // 同一个 group 配置构建,因此声明过的形状必须随配置抵达适配器(否则 models.json // 里的声明会静默失效)。 func TestNewChatChain_CarriesReasoningKind(t *testing.T) { t.Parallel() chain, err := NewChatChain(config.LLMConfig{ Provider: ProviderOpenAI, BaseURL: "http://127.0.0.1:1/v1", Model: "m", Fallback: "fb", ReasoningKind: runtimeconfig.ReasoningKindDashScope, }) if err != nil { t.Fatalf("NewChatChain: %v", err) } if len(chain.steps) != 2 { t.Fatalf("chain steps = %d, want 2 (primary + fallback)", len(chain.steps)) } for i, step := range chain.steps { ad, ok := step.Adapter.(*OpenAIChatAdapter) if !ok { t.Fatalf("step %d adapter is %T, want *OpenAIChatAdapter", i, step.Adapter) } if ad.reasoningKind != runtimeconfig.ReasoningKindDashScope { t.Errorf("step %d reasoningKind = %q, want %q", i, ad.reasoningKind, runtimeconfig.ReasoningKindDashScope) } } }