diff --git a/internal/config/config.go b/internal/config/config.go index 2dae2cc0..86f523d0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -846,7 +846,8 @@ func (c OpenAIConfig) MaxCompletionTokensEffective() int { // OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。 type OpenAIReasoningConfig struct { - // Mode: auto(默认)| on | off | default(与 auto 相同)。off 时不向模型附加推理扩展字段。 + // Mode: auto(默认)| on | off | default(与 auto 相同)。 + // off 在 OpenAI/Claude profile 下省略推理字段;DeepSeek profile 下发送 thinking.type=disabled(其默认开启思考)。 Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` // Effort: low | medium | high | max | xhigh;max/xhigh 为不同网关最高档命名,原样下发、不互转。空表示不单独指定强度。 Effort string `yaml:"effort,omitempty" json:"effort,omitempty"` @@ -855,6 +856,7 @@ type OpenAIReasoningConfig struct { // Profile: auto | deepseek_compat | openai_compat | output_config_effort Profile string `yaml:"profile,omitempty" json:"profile,omitempty"` // ExtraRequestFields 合并进 Chat Completions 根 JSON(管理员用;与自动字段同名时后者覆盖)。 + // Mode=off 时会移除其中的推理控制字段,但保留其他扩展字段;DeepSeek profile 随后补充显式关闭开关。 ExtraRequestFields map[string]interface{} `yaml:"extra_request_fields,omitempty" json:"extra_request_fields,omitempty"` } diff --git a/internal/reasoning/eino.go b/internal/reasoning/eino.go index d8fcd86f..8cdd4909 100644 --- a/internal/reasoning/eino.go +++ b/internal/reasoning/eino.go @@ -34,12 +34,13 @@ func ApplyPlanExecutePlannerModelConfig(cfg *einoopenai.ChatModelConfig, oa *con if cfg == nil || oa == nil { return } - offOA := *oa - offReasoning := oa.Reasoning - offReasoning.Mode = "off" - offOA.Reasoning = offReasoning - ApplyToEinoChatModelConfig(cfg, &offOA, nil) + mergeExtraRequestFields(cfg, oa.Reasoning.ExtraRequestFields) clearReasoningFromChatModelConfig(cfg) + if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek { + // DeepSeek enables thinking by default, so omission would not actually + // disable it for the planner's forced tool-choice requests. + applyThinkingDisabled(cfg) + } } func clearReasoningFromChatModelConfig(cfg *einoopenai.ChatModelConfig) { @@ -51,8 +52,22 @@ func clearReasoningFromChatModelConfig(cfg *einoopenai.ChatModelConfig) { for _, key := range []string{"thinking", "reasoning_effort", "output_config", "reasoning"} { delete(cfg.ExtraFields, key) } + if len(cfg.ExtraFields) == 0 { + cfg.ExtraFields = nil + } + } +} + +func mergeExtraRequestFields(cfg *einoopenai.ChatModelConfig, fields map[string]interface{}) { + if cfg == nil || len(fields) == 0 { + return + } + if cfg.ExtraFields == nil { + cfg.ExtraFields = make(map[string]any, len(fields)) + } + for k, v := range fields { + cfg.ExtraFields[k] = v } - applyThinkingDisabled(cfg) } // ApplyToEinoChatModelConfig merges reasoning-related options into cfg. @@ -65,42 +80,32 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open allowClient := sr.AllowClientReasoningEffective() mode := effectiveMode(sr, client, allowClient) + // Admin-defined root fields are independent of the selected reasoning wire + // profile. Merge them first so mode=off can remove only reasoning controls + // while preserving unrelated gateway options. + mergeExtraRequestFields(cfg, sr.ExtraRequestFields) + if mode == "off" { + clearReasoningFromChatModelConfig(cfg) + // Strict OpenAI endpoints reject unknown `thinking` fields, whereas the + // DeepSeek API enables thinking by default and requires an explicit + // thinking.type=disabled switch. Keep that wire difference profile-scoped. + if resolveWireProfile(oa, sr) == wireDeepseek { + applyThinkingDisabled(cfg) + } + return + } + // Claude (Anthropic): merge admin extras first; optional extended thinking maps to top-level `thinking` // (see internal/openai convertOpenAIToClaude). DeepSeek/OpenAI-style fields are not sent. if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") || strings.EqualFold(strings.TrimSpace(oa.Provider), "anthropic") { - if len(sr.ExtraRequestFields) > 0 { - if cfg.ExtraFields == nil { - cfg.ExtraFields = make(map[string]any) - } - for k, v := range sr.ExtraRequestFields { - cfg.ExtraFields[k] = v - } - } - if mode == "off" { - return - } applyClaudeExtendedThinking(cfg, mode, effectiveEffort(sr, client, allowClient), oa.Model) return } - if mode == "off" { - applyThinkingDisabled(cfg) - return - } effort := effectiveEffort(sr, client, allowClient) prof := resolveWireProfile(oa, sr) - // Admin-defined extra root fields (merged first; automatic keys may follow). - if len(sr.ExtraRequestFields) > 0 { - if cfg.ExtraFields == nil { - cfg.ExtraFields = make(map[string]any) - } - for k, v := range sr.ExtraRequestFields { - cfg.ExtraFields[k] = v - } - } - switch prof { case wireClaude, wireNone: return @@ -222,7 +227,8 @@ func usesExtraFieldsReasoningEffort(e string) bool { } func resolveWireProfile(oa *config.OpenAIConfig, sr *config.OpenAIReasoningConfig) wireProfile { - if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") { + provider := strings.TrimSpace(oa.Provider) + if strings.EqualFold(provider, "claude") || strings.EqualFold(provider, "anthropic") { return wireClaude } p := strings.ToLower(strings.TrimSpace(sr.ProfileEffective())) @@ -252,9 +258,6 @@ func applyThinkingDisabled(cfg *einoopenai.ChatModelConfig) { if cfg.ExtraFields == nil { cfg.ExtraFields = make(map[string]any) } - if _, exists := cfg.ExtraFields["thinking"]; exists { - return - } cfg.ExtraFields["thinking"] = map[string]any{"type": "disabled"} } diff --git a/internal/reasoning/eino_test.go b/internal/reasoning/eino_test.go index 8db3121e..50dee5be 100644 --- a/internal/reasoning/eino_test.go +++ b/internal/reasoning/eino_test.go @@ -1,13 +1,33 @@ package reasoning import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" "testing" "cyberstrike-ai/internal/config" einoopenai "github.com/cloudwego/eino-ext/components/model/openai" + "github.com/cloudwego/eino/schema" ) +var reasoningPayloadKeysForTest = []string{"thinking", "reasoning_effort", "output_config", "reasoning"} + +func assertNoReasoningFields(t *testing.T, cfg *einoopenai.ChatModelConfig) { + t.Helper() + if cfg.ReasoningEffort != "" { + t.Fatalf("expected ReasoningEffort omitted, got %q", cfg.ReasoningEffort) + } + for _, key := range reasoningPayloadKeysForTest { + if _, ok := cfg.ExtraFields[key]; ok { + t.Fatalf("expected %q omitted, got %#v", key, cfg.ExtraFields) + } + } +} + func TestEffortStringForAPI_passthrough(t *testing.T) { cases := map[string]string{ "max": "max", @@ -50,7 +70,11 @@ func TestApplyOpenAICompat_xhighExtraField(t *testing.T) { } func TestApplyPlanExecutePlannerModelConfig_stripsReasoningWhenGlobalOn(t *testing.T) { - cfg := &einoopenai.ChatModelConfig{} + cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{ + "thinking": map[string]any{"type": "enabled"}, + "reasoning_effort": "high", + "vendor_option": true, + }} oa := &config.OpenAIConfig{ BaseURL: "https://antchat.example.com/v1", Model: "minimax-m3", @@ -61,31 +85,119 @@ func TestApplyPlanExecutePlannerModelConfig_stripsReasoningWhenGlobalOn(t *testi }, } ApplyPlanExecutePlannerModelConfig(cfg, oa) - if cfg.ReasoningEffort != "" { - t.Fatalf("expected ReasoningEffort cleared, got %q", cfg.ReasoningEffort) - } - th, ok := cfg.ExtraFields["thinking"].(map[string]any) - if !ok || th["type"] != "disabled" { - t.Fatalf("expected thinking disabled, got %#v", cfg.ExtraFields) - } - if _, ok := cfg.ExtraFields["reasoning_effort"]; ok { - t.Fatalf("expected reasoning_effort stripped, got %#v", cfg.ExtraFields) + assertNoReasoningFields(t, cfg) + if cfg.ExtraFields["vendor_option"] != true { + t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields) } } -func TestApplyReasoningOff_disablesThinking(t *testing.T) { - cfg := &einoopenai.ChatModelConfig{} +func TestApplyReasoningOff_omitsAllReasoningFields(t *testing.T) { + cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{ + "thinking": map[string]any{"type": "enabled"}, + "output_config": map[string]any{"effort": "high"}, + }} oa := &config.OpenAIConfig{ BaseURL: "https://api.openai.com/v1", - Model: "gpt-4o", + Model: "gpt-4o-mini", Reasoning: config.OpenAIReasoningConfig{ - Mode: "off", + Mode: "off", + Effort: "high", + Profile: "openai_compat", + ExtraRequestFields: map[string]interface{}{ + "thinking": map[string]any{"type": "disabled"}, + "reasoning": map[string]any{"effort": "high"}, + "vendor_option": true, + }, }, } ApplyToEinoChatModelConfig(cfg, oa, nil) - th, ok := cfg.ExtraFields["thinking"].(map[string]any) - if !ok || th["type"] != "disabled" { - t.Fatalf("expected thinking disabled, got %#v", cfg.ExtraFields) + assertNoReasoningFields(t, cfg) + if cfg.ExtraFields["vendor_option"] != true { + t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields) + } +} + +func TestApplyReasoningOff_clientOverrideOmit(t *testing.T) { + cfg := &einoopenai.ChatModelConfig{} + oa := &config.OpenAIConfig{Reasoning: config.OpenAIReasoningConfig{ + Mode: "on", Effort: "high", Profile: "openai_compat", + }} + ApplyToEinoChatModelConfig(cfg, oa, &ClientIntent{Mode: "off", Effort: "high"}) + assertNoReasoningFields(t, cfg) +} + +func TestApplyReasoningOff_deepseekExplicitlyDisablesDefaultThinking(t *testing.T) { + for _, profile := range []string{"deepseek_compat", "auto"} { + t.Run(profile, func(t *testing.T) { + cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{ + "reasoning_effort": "high", + "vendor_option": true, + }} + oa := &config.OpenAIConfig{ + BaseURL: "https://api.deepseek.com", + Model: "deepseek-v4-pro", + Reasoning: config.OpenAIReasoningConfig{ + Mode: "off", Effort: "high", Profile: profile, + }, + } + ApplyToEinoChatModelConfig(cfg, oa, nil) + if cfg.ReasoningEffort != "" { + t.Fatalf("expected ReasoningEffort omitted, got %q", cfg.ReasoningEffort) + } + if _, ok := cfg.ExtraFields["reasoning_effort"]; ok { + t.Fatalf("expected reasoning_effort omitted, got %#v", cfg.ExtraFields) + } + thinking, ok := cfg.ExtraFields["thinking"].(map[string]any) + if !ok || thinking["type"] != "disabled" { + t.Fatalf("expected DeepSeek thinking disabled, got %#v", cfg.ExtraFields) + } + if cfg.ExtraFields["vendor_option"] != true { + t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields) + } + }) + } +} + +func TestApplyReasoningOff_wirePayloadOmitsThinking(t *testing.T) { + var requestBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request body: %v", err) + } + if err := json.Unmarshal(body, &requestBody); err != nil { + t.Errorf("decode request body: %v; body=%s", err, body) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"chatcmpl-test","object":"chat.completion","created":1,"model":"gpt-4o-mini","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) + })) + defer srv.Close() + + cfg := &einoopenai.ChatModelConfig{ + APIKey: "test-key", + BaseURL: srv.URL, + Model: "gpt-4o-mini", + } + oa := &config.OpenAIConfig{ + BaseURL: "https://api.openai.com/v1", + Model: "gpt-4o-mini", + Reasoning: config.OpenAIReasoningConfig{ + Mode: "off", Effort: "high", Profile: "openai_compat", + }, + } + ApplyToEinoChatModelConfig(cfg, oa, nil) + model, err := einoopenai.NewChatModel(context.Background(), cfg) + if err != nil { + t.Fatalf("new chat model: %v", err) + } + if _, err := model.Generate(context.Background(), []*schema.Message{schema.UserMessage("hello")}); err != nil { + t.Fatalf("generate: %v", err) + } + for _, key := range reasoningPayloadKeysForTest { + if _, ok := requestBody[key]; ok { + t.Fatalf("wire payload unexpectedly contains %q: %#v", key, requestBody) + } } }