From 45b665ab79f8de4e5b0756162eb3497f04013134 Mon Sep 17 00:00:00 2001 From: cj Date: Fri, 1 May 2026 10:12:37 +0000 Subject: [PATCH] fix(openai_compat): detect DeepSeek models behind proxies for reasoning_content replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isDeepSeekReasoningProvider previously only detected DeepSeek via providerName == 'deepseek' or apiBase host == 'deepseek.com'. Users proxying DeepSeek models through third-party endpoints (e.g. opencode.ai) would not be detected, causing prepareMessagesForRequest to route into stripReasoningMessages, which unconditionally blanks all reasoning_content — including for tool-interaction turns where DeepSeek requires it. Fix: add model-based detection via isDeepSeekModel() which checks if the model name contains 'deepseek'. Also add prepareMessagesForRequest and isDeepSeekReasoningProvider to accept the model parameter. --- pkg/providers/openai_compat/provider.go | 14 +- pkg/providers/openai_compat/provider_test.go | 132 +++++++++++++++++++ 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index be3e77a43..fc7877de7 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -144,7 +144,7 @@ func (p *Provider) buildRequestBody( requestBody := map[string]any{ "model": model, - "messages": common.SerializeMessages(p.prepareMessagesForRequest(messages)), + "messages": common.SerializeMessages(p.prepareMessagesForRequest(messages, model)), } // When fallback uses a different provider (e.g. DeepSeek), that provider must not inject web_search_preview. @@ -208,19 +208,19 @@ func (p *Provider) SetProviderName(providerName string) { p.providerName = strings.ToLower(strings.TrimSpace(providerName)) } -func (p *Provider) prepareMessagesForRequest(messages []Message) []Message { +func (p *Provider) prepareMessagesForRequest(messages []Message, model string) []Message { if len(messages) == 0 { return nil } - if p.isDeepSeekReasoningProvider() { + if p.isDeepSeekReasoningProvider(model) { return filterDeepSeekReasoningMessages(messages) } return stripReasoningMessages(messages) } -func (p *Provider) isDeepSeekReasoningProvider() bool { - return p.providerName == "deepseek" || isDeepSeekHost(p.apiBase) +func (p *Provider) isDeepSeekReasoningProvider(model string) bool { + return p.providerName == "deepseek" || isDeepSeekHost(p.apiBase) || isDeepSeekModel(model) } func isDeepSeekHost(apiBase string) bool { @@ -232,6 +232,10 @@ func isDeepSeekHost(apiBase string) bool { return host == "deepseek.com" || strings.HasSuffix(host, ".deepseek.com") } +func isDeepSeekModel(model string) bool { + return strings.Contains(strings.ToLower(model), "deepseek") +} + func filterDeepSeekReasoningMessages(messages []Message) []Message { out := make([]Message, 0, len(messages)) start := 0 diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 4f68fb393..333127f1d 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -632,6 +632,138 @@ func TestProviderChat_DeepSeekDocsReplayRequirements(t *testing.T) { } } +func TestProviderChat_DeepSeekModelViaProxyPreservesReasoningForToolTurns(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + // Provider is "openai" (not "deepseek"), host is localhost (not deepseek.com), + // but model name contains "deepseek" — we should still route to the DeepSeek + // reasoning-content filter. + p.SetProviderName("openai") + p.httpClient = &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + r.URL, _ = url.Parse(server.URL + r.URL.Path) + return http.DefaultTransport.RoundTrip(r) + }), + } + + messages := []Message{ + {Role: "user", Content: "get date"}, + { + Role: "assistant", + Content: "", + ReasoningContent: "I need tomorrow's date before checking the weather.", + ToolCalls: []ToolCall{ + {ID: "call_date", Name: "get_date", Arguments: map[string]any{}}, + }, + }, + {Role: "tool", ToolCallID: "call_date", Content: "2026-04-29"}, + { + Role: "assistant", + Content: "Tomorrow is 2026-04-30.", + ReasoningContent: "Now I can share the weather.", + }, + {Role: "user", Content: "What about Guangzhou?"}, + } + + _, err := p.Chat(t.Context(), messages, nil, "deepseek-v4-flash", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + reqMessages, ok := requestBody["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any: %T", requestBody["messages"]) + } + + // Tool-interaction assistant (index 1) must preserve reasoning_content. + toolAssistant, ok := reqMessages[1].(map[string]any) + if !ok { + t.Fatalf("tool assistant message is not map[string]any: %T", reqMessages[1]) + } + if toolAssistant["reasoning_content"] != "I need tomorrow's date before checking the weather." { + t.Fatalf( + "tool assistant reasoning_content = %v, want preserved for DeepSeek model via proxy", + toolAssistant["reasoning_content"], + ) + } +} + +func TestIsDeepSeekReasoningProvider_ModelDetection(t *testing.T) { + tests := []struct { + name string + providerName string + apiBase string + model string + want bool + }{ + { + name: "provider name is deepseek", + providerName: "deepseek", + apiBase: "https://api.openai.com/v1", + model: "gpt-5.4", + want: true, + }, + { + name: "api base is deepseek.com", + providerName: "openai", + apiBase: "https://api.deepseek.com/v1", + model: "gpt-5.4", + want: true, + }, + { + name: "model contains deepseek via proxy", + providerName: "openai", + apiBase: "https://opencode.ai/zen/go/v1", + model: "deepseek-v4-flash", + want: true, + }, + { + name: "model contains deepseek-chat", + providerName: "openai", + apiBase: "https://api.openai.com/v1", + model: "deepseek-chat", + want: true, + }, + { + name: "no deepseek anywhere", + providerName: "openai", + apiBase: "https://api.openai.com/v1", + model: "gpt-5.4", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := NewProvider("key", tt.apiBase, "") + p.SetProviderName(tt.providerName) + got := p.isDeepSeekReasoningProvider(tt.model) + if got != tt.want { + t.Fatalf("isDeepSeekReasoningProvider(%q) = %v, want %v", tt.model, got, tt.want) + } + }) + } +} + func TestProviderChat_HTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request", http.StatusBadRequest)