package model import ( "context" "net/http" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // TestDeepSeekAnthropicEndpoint_Classification 钉住端点检测: // 即便配置的模型名不是 DeepSeek 的,也必须认出 // DeepSeek /anthropic URL(该服务会把未知名称静默映射到 // deepseek-flash,所以那里模型名不是可靠信号)。 func TestDeepSeekAnthropicEndpoint_Classification(t *testing.T) { t.Parallel() cases := []struct { model string baseURL string want bool }{ {model: deepSeekModel, baseURL: "http://127.0.0.1:9", want: true}, {model: "claude-sonnet-4", baseURL: "https://api.deepseek.com/anthropic", want: true}, {model: "gpt-4o", baseURL: "https://api.deepseek.com/anthropic", want: true}, {model: "claude-sonnet-4", baseURL: "https://api.anthropic.com", want: false}, {model: "deepseek-v4-pro", baseURL: "https://gateway.internal/anthropic", want: true}, } for _, tc := range cases { if got := deepSeekAnthropicEndpoint(tc.model, tc.baseURL); got != tc.want { t.Errorf("deepSeekAnthropicEndpoint(%q, %q) = %v, want %v", tc.model, tc.baseURL, got, tc.want) } } } // newDeepSeekAnthropicAdapter 构建一个 Anthropic 协议适配器,其模型 // 名标识 DeepSeek,从而走到 DeepSeek /anthropic 分支, // 同时请求仍发往本地 stub。 func newDeepSeekAnthropicAdapter(t *testing.T, ch chan<- anthropicCaptured, opts ...AnthropicOption) *AnthropicChatAdapter { t.Helper() srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch) return NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: deepSeekModel}, opts...) } // TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens 断言抬高 max_tokens // 只留在 Anthropic 端点:DeepSeek 的 /anthropic 忽略 budget_tokens,所以在 // 那里抬高限额会错误反映提供商采用的预算。 func TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) log, buf := captureLog() ad := newDeepSeekAnthropicAdapter(t, ch, WithAnthropicLogger(log)) const budget = 8192 if _, err := ad.Chat(context.Background(), Request{ Model: deepSeekModel, Messages: []Message{TextMessage(RoleUser, "hi")}, MaxTokens: 1024, Thinking: &Thinking{BudgetTokens: budget}, }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } body := (<-ch).body if got := body["max_tokens"]; got != float64(1024) { t.Fatalf("body.max_tokens = %v, want the requested 1024 (DeepSeek ignores budget_tokens)", got) } think, ok := body["thinking"].(map[string]any) if !ok || think["type"] != thinkingTypeEnabled { t.Fatalf("body.thinking = %v, want an enabled directive", body["thinking"]) } rec := buf.String() for _, want := range []string{"level=WARN", "budget_tokens", "ignored", "deepseek"} { if !strings.Contains(rec, want) { t.Errorf("log %q missing %q", rec, want) } } if strings.Contains(rec, "max_tokens raised") { t.Errorf("log %q reports a max_tokens raise that must not happen on the DeepSeek endpoint", rec) } } // TestAnthropicOfficial_BudgetStillRaisesMaxTokens 是对位守护:在 // Anthropic 端点上,设计红线(「预算不得吃光答案」) // 依然成立。 func TestAnthropicOfficial_BudgetStillRaisesMaxTokens(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch) log, buf := captureLog() ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"}, WithAnthropicLogger(log)) if _, err := ad.Chat(context.Background(), Request{ Model: "claude-test", Messages: []Message{TextMessage(RoleUser, "hi")}, MaxTokens: 1024, Thinking: &Thinking{BudgetTokens: 4096}, }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } body := (<-ch).body maxTokens, _ := body["max_tokens"].(float64) think, _ := body["thinking"].(map[string]any) budget, _ := think["budget_tokens"].(float64) if maxTokens <= budget { t.Fatalf("body.max_tokens = %v, want > budget_tokens %v on the Anthropic endpoint", maxTokens, budget) } if !strings.Contains(buf.String(), "max_tokens raised") { t.Errorf("log %q, want the raise WARN", buf.String()) } } // TestAnthropicDeepSeek_OutputConfigEffort 断言 DeepSeek /anthropic 的 // 强度路径存在:effort 变成 output_config.effort,别名 // 被归一化为服务采用的档位。 func TestAnthropicDeepSeek_OutputConfigEffort(t *testing.T) { t.Parallel() cases := []struct{ sent, want string }{ {"low", "low"}, {"high", "high"}, {"max", "max"}, {"minimal", "low"}, {"medium", "high"}, {"xhigh", "high"}, {"ultra", "max"}, } for _, tc := range cases { t.Run(tc.sent, func(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) ad := newDeepSeekAnthropicAdapter(t, ch) if _, err := ad.Chat(context.Background(), Request{ Model: deepSeekModel, Messages: []Message{TextMessage(RoleUser, "hi")}, ReasoningEffort: tc.sent, }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } body := (<-ch).body output, ok := body["output_config"].(map[string]any) if !ok { t.Fatalf("body.output_config = %v, want an effort block", body["output_config"]) } if got := output["effort"]; got != tc.want { t.Fatalf("body.output_config.effort = %v, want %q (sent %q)", got, tc.want, tc.sent) } }) } } // TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped 断言无法识别的 // effort 会被丢弃并上报,而不是做近似处理。 func TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) log, buf := captureLog() ad := newDeepSeekAnthropicAdapter(t, ch, WithAnthropicLogger(log)) if _, err := ad.Chat(context.Background(), Request{ Model: deepSeekModel, Messages: []Message{TextMessage(RoleUser, "hi")}, ReasoningEffort: "foo", }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } body := (<-ch).body if _, present := body["output_config"]; present { t.Fatalf("body.output_config = %v, want the block dropped", body["output_config"]) } if _, present := body["reasoning_effort"]; present { t.Fatalf("body.reasoning_effort = %v, want the field dropped", body["reasoning_effort"]) } rec := buf.String() for _, want := range []string{"level=WARN", "reasoning_effort", "foo", "deepseek"} { if !strings.Contains(rec, want) { t.Errorf("log %q missing %q", rec, want) } } } // TestAnthropicDeepSeek_EffortNoneDisablesThinking 钉住 output_config // 无法承载的那一个 effort 取值的翻译:DeepSeek 的 Anthropic 格式 // 没有 "none" 档,所以关闭意图经 thinking 开关转达——把它映射 // 到某个档位会把「关闭思考」变成「开启思考」。 func TestAnthropicDeepSeek_EffortNoneDisablesThinking(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) log, buf := captureLog() ad := newDeepSeekAnthropicAdapter(t, ch, WithAnthropicLogger(log)) if _, err := ad.Chat(context.Background(), Request{ Model: deepSeekModel, Messages: []Message{TextMessage(RoleUser, "hi")}, ReasoningEffort: effortNone, }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } body := (<-ch).body think, ok := body["thinking"].(map[string]any) if !ok || think["type"] != thinkingTypeDisabled { t.Fatalf("body.thinking = %v, want %q", body["thinking"], thinkingTypeDisabled) } if _, present := body["output_config"]; present { t.Fatalf("body.output_config = %v, want no effort block", body["output_config"]) } if rec := buf.String(); !strings.Contains(rec, "none") || !strings.Contains(rec, "level=WARN") { t.Errorf("log %q, want a WARN explaining the translation", rec) } } // TestAnthropicDeepSeek_ThinkingSwitch 断言显式开关以 // Anthropic 格式的 thinking 块携带,且 disabled 时不带预算 // (反正 DeepSeek 会忽略它)。 func TestAnthropicDeepSeek_ThinkingSwitch(t *testing.T) { t.Parallel() cases := []struct { name string enable *bool wantType string absent bool }{ {name: "explicit_off", enable: boolPtr(false), wantType: thinkingTypeDisabled}, {name: "explicit_on", enable: boolPtr(true), wantType: thinkingTypeEnabled}, {name: "nil_leaves_the_default", enable: nil, absent: true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) ad := newDeepSeekAnthropicAdapter(t, ch) req := Request{Model: deepSeekModel, Messages: []Message{TextMessage(RoleUser, "hi")}} if tc.enable != nil { req.Thinking = &Thinking{Enable: tc.enable} } if _, err := ad.Chat(context.Background(), req, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } body := (<-ch).body think, present := body["thinking"].(map[string]any) if tc.absent { if present { t.Fatalf("body.thinking = %v, want the field omitted", body["thinking"]) } return } if !present || think["type"] != tc.wantType { t.Fatalf("body.thinking = %v, want type %q", body["thinking"], tc.wantType) } if _, hasBudget := think["budget_tokens"]; tc.wantType == thinkingTypeDisabled && hasBudget { t.Errorf("body.thinking = %v, want no budget_tokens for a disabled switch", think) } }) } } // TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled 在 Anthropic 路径 // 上也守住「关闭优先」规则:output_config.effort 不得重新打开思考。 func TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) log, buf := captureLog() ad := newDeepSeekAnthropicAdapter(t, ch, WithAnthropicLogger(log)) if _, err := ad.Chat(context.Background(), Request{ Model: deepSeekModel, Messages: []Message{TextMessage(RoleUser, "hi")}, Thinking: &Thinking{Enable: boolPtr(false)}, ReasoningEffort: "high", }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } body := (<-ch).body if _, present := body["output_config"]; present { t.Fatalf("body.output_config = %v, want it dropped while thinking is off", body["output_config"]) } if think, _ := body["thinking"].(map[string]any); think["type"] != thinkingTypeDisabled { t.Fatalf("body.thinking = %v, want %q", body["thinking"], thinkingTypeDisabled) } if rec := buf.String(); !strings.Contains(rec, "explicitly disabled") { t.Errorf("log %q, want a WARN explaining the dropped effort", rec) } } // TestAnthropicOfficial_ReasoningEffortReportedIgnored 断言落在 // Anthropic 端点上的 reasoning_effort 会被上报,而不是静默消失—— // Messages 协议没有这个字段。 func TestAnthropicOfficial_ReasoningEffortReportedIgnored(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch) log, buf := captureLog() ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"}, WithAnthropicLogger(log)) if _, err := ad.Chat(context.Background(), Request{ Model: "claude-test", Messages: []Message{TextMessage(RoleUser, "hi")}, ReasoningEffort: "high", }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } body := (<-ch).body for _, key := range []string{"reasoning_effort", "output_config"} { if _, present := body[key]; present { t.Errorf("body.%s = %v, want the Anthropic endpoint untouched", key, body[key]) } } rec := buf.String() for _, want := range []string{"level=WARN", "reasoning_effort", "high", "anthropic"} { if !strings.Contains(rec, want) { t.Errorf("log %q missing %q", rec, want) } } } // TestAnthropicDeepSeek_ForcedToolChoiceWithThinkingWarns 覆盖 Anthropic 路径 // 上的 DeepSeek 400 陷阱:适配器会警告但仍发送该指令—— // 它绝不自行关闭思考。 func TestAnthropicDeepSeek_ForcedToolChoiceWithThinkingWarns(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) log, buf := captureLog() ad := newDeepSeekAnthropicAdapter(t, ch, WithAnthropicLogger(log)) choice := map[string]any{"type": "tool", "name": "get_time"} if _, err := ad.Chat(context.Background(), Request{ Model: deepSeekModel, Messages: []Message{TextMessage(RoleUser, "hi")}, ToolChoice: choice, }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } if got := (<-ch).body["tool_choice"]; got == nil { t.Fatal("body.tool_choice = nil, want the directive passed through verbatim") } rec := buf.String() for _, want := range []string{"level=WARN", "tool_choice", "deepseek", deepSeekModel} { if !strings.Contains(rec, want) { t.Errorf("log %q missing %q", rec, want) } } }