diff --git a/internal/openai/claude_bridge.go b/internal/openai/claude_bridge.go index 195d0e4a..530e9f9b 100644 --- a/internal/openai/claude_bridge.go +++ b/internal/openai/claude_bridge.go @@ -893,7 +893,7 @@ func NewEinoHTTPClient(cfg *config.OpenAIConfig, base *http.Client) *http.Client if transport == nil { transport = http.DefaultTransport } - transport = &reasoningToolChoiceCompatRoundTripper{base: transport} + transport = &reasoningToolChoiceCompatRoundTripper{base: transport, cfg: cfg} if isClaudeProvider(cfg) { transport = &claudeRoundTripper{ base: transport, diff --git a/internal/openai/reasoning_payload.go b/internal/openai/reasoning_payload.go index dfb827ce..2110c90d 100644 --- a/internal/openai/reasoning_payload.go +++ b/internal/openai/reasoning_payload.go @@ -1,6 +1,8 @@ package openai import ( + "strings" + "github.com/bytedance/sonic" ) @@ -52,6 +54,28 @@ func StripReasoningIfForcedToolChoice(rawBody []byte) ([]byte, error) { return out, nil } +// StripToolChoiceForThinkingMode removes tool_choice while preserving tools and +// thinking fields. DeepSeek thinking mode can use tools, but rejects the +// tool_choice parameter itself on some agent requests. +func StripToolChoiceForThinkingMode(rawBody []byte) ([]byte, error) { + var payload map[string]any + if err := sonic.Unmarshal(rawBody, &payload); err != nil { + return rawBody, nil + } + if !thinkingModeEnabledByPayload(payload) { + return rawBody, nil + } + if _, ok := payload["tool_choice"]; !ok { + return rawBody, nil + } + delete(payload, "tool_choice") + out, err := sonic.Marshal(payload) + if err != nil { + return rawBody, err + } + return out, nil +} + func stripReasoningFields(payload map[string]any) bool { changed := false for _, key := range reasoningPayloadKeys { @@ -77,3 +101,17 @@ func forcedToolChoiceIncompatibleWithThinking(payload map[string]any) bool { return false } } + +func thinkingModeEnabledByPayload(payload map[string]any) bool { + thinking, ok := payload["thinking"] + if !ok || thinking == nil { + // DeepSeek enables thinking by default unless explicitly disabled. + return true + } + if m, ok := thinking.(map[string]any); ok { + if typ, ok := m["type"].(string); ok && strings.EqualFold(strings.TrimSpace(typ), "disabled") { + return false + } + } + return true +} diff --git a/internal/openai/reasoning_payload_test.go b/internal/openai/reasoning_payload_test.go index fddb7e66..4bede21e 100644 --- a/internal/openai/reasoning_payload_test.go +++ b/internal/openai/reasoning_payload_test.go @@ -5,6 +5,8 @@ import ( "net/http" "strings" "testing" + + "cyberstrike-ai/internal/config" ) func TestStripReasoningFromChatCompletionBody(t *testing.T) { @@ -82,6 +84,58 @@ func TestStripReasoningIfForcedToolChoice(t *testing.T) { } } +func TestStripToolChoiceForThinkingMode(t *testing.T) { + cases := []struct { + name string + in string + wantToolChoice bool + wantThinking bool + }{ + { + name: "enabled thinking removes tool_choice", + in: `{"model":"deepseek-v4","messages":[],"thinking":{"type":"enabled"},"tool_choice":"required","tools":[{"type":"function","function":{"name":"scan"}}]}`, + wantToolChoice: false, + wantThinking: true, + }, + { + name: "default thinking removes tool_choice", + in: `{"model":"deepseek-v4","messages":[],"tool_choice":"auto","tools":[]}`, + wantToolChoice: false, + wantThinking: false, + }, + { + name: "disabled thinking keeps tool_choice", + in: `{"model":"deepseek-v4","messages":[],"thinking":{"type":"disabled"},"tool_choice":"required","tools":[]}`, + wantToolChoice: true, + wantThinking: true, + }, + { + name: "no tool_choice unchanged", + in: `{"model":"deepseek-v4","messages":[],"thinking":{"type":"enabled"},"tools":[]}`, + wantToolChoice: false, + wantThinking: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := StripToolChoiceForThinkingMode([]byte(tc.in)) + if err != nil { + t.Fatal(err) + } + s := string(out) + if strings.Contains(s, "tool_choice") != tc.wantToolChoice { + t.Fatalf("tool_choice presence mismatch, got %s", s) + } + if strings.Contains(s, "thinking") != tc.wantThinking { + t.Fatalf("thinking presence mismatch, got %s", s) + } + if !strings.Contains(s, "tools") { + t.Fatalf("expected tools preserved, got %s", s) + } + }) + } +} + func TestReasoningToolChoiceCompatRoundTripper(t *testing.T) { var gotBody string rt := &reasoningToolChoiceCompatRoundTripper{ @@ -113,6 +167,44 @@ func TestReasoningToolChoiceCompatRoundTripper(t *testing.T) { } } +func TestReasoningToolChoiceCompatRoundTripperDeepSeek(t *testing.T) { + var gotBody string + rt := &reasoningToolChoiceCompatRoundTripper{ + cfg: &config.OpenAIConfig{ + BaseURL: "https://api.deepseek.com/v1", + Model: "deepseek-v4", + }, + base: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + b, _ := io.ReadAll(req.Body) + gotBody = string(b) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + } + req, err := http.NewRequest(http.MethodPost, "https://api.deepseek.com/v1/chat/completions", strings.NewReader( + `{"model":"deepseek-v4","thinking":{"type":"enabled"},"tool_choice":"required","tools":[],"messages":[]}`, + )) + if err != nil { + t.Fatal(err) + } + _, err = rt.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + if strings.Contains(gotBody, "tool_choice") { + t.Fatalf("expected DeepSeek tool_choice stripped in transit, got %s", gotBody) + } + if !strings.Contains(gotBody, "thinking") { + t.Fatalf("expected thinking preserved for DeepSeek, got %s", gotBody) + } + if !strings.Contains(gotBody, "tools") { + t.Fatalf("expected tools preserved for DeepSeek, got %s", gotBody) + } +} + type roundTripperFunc func(*http.Request) (*http.Response, error) func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/internal/openai/reasoning_tool_choice_compat.go b/internal/openai/reasoning_tool_choice_compat.go index ca4250c9..26841590 100644 --- a/internal/openai/reasoning_tool_choice_compat.go +++ b/internal/openai/reasoning_tool_choice_compat.go @@ -6,6 +6,8 @@ import ( "net/http" "strconv" "strings" + + "cyberstrike-ai/internal/config" ) // reasoningToolChoiceCompatRoundTripper strips thinking/reasoning fields from @@ -13,6 +15,7 @@ import ( // when thinking mode is enabled on the same request. type reasoningToolChoiceCompatRoundTripper struct { base http.RoundTripper + cfg *config.OpenAIConfig } func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { @@ -32,7 +35,13 @@ func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (* return nil, err } - patched, perr := StripReasoningIfForcedToolChoice(body) + patched := body + var perr error + if isDeepSeekToolChoiceCompatProfile(rt.cfg) { + patched, perr = StripToolChoiceForThinkingMode(body) + } else { + patched, perr = StripReasoningIfForcedToolChoice(body) + } if perr != nil { patched = body } @@ -41,3 +50,19 @@ func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (* req.Header.Set("Content-Length", strconv.Itoa(len(patched))) return rt.base.RoundTrip(req) } + +func isDeepSeekToolChoiceCompatProfile(cfg *config.OpenAIConfig) bool { + if cfg == nil { + return false + } + profile := strings.ToLower(strings.TrimSpace(cfg.Reasoning.ProfileEffective())) + if profile == "deepseek" || profile == "deepseek_compat" { + return true + } + if profile != "" && profile != "auto" { + return false + } + baseURL := strings.ToLower(cfg.BaseURL) + model := strings.ToLower(cfg.Model) + return strings.Contains(baseURL, "deepseek") || strings.Contains(model, "deepseek") +}