fix(openai_compat): detect DeepSeek models behind proxies for reasoning_content replay
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.
This commit is contained in:
parent
6e1fab80e2
commit
45b665ab79
2 changed files with 141 additions and 5 deletions
|
|
@ -144,7 +144,7 @@ func (p *Provider) buildRequestBody(
|
||||||
|
|
||||||
requestBody := map[string]any{
|
requestBody := map[string]any{
|
||||||
"model": model,
|
"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.
|
// 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))
|
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 {
|
if len(messages) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.isDeepSeekReasoningProvider() {
|
if p.isDeepSeekReasoningProvider(model) {
|
||||||
return filterDeepSeekReasoningMessages(messages)
|
return filterDeepSeekReasoningMessages(messages)
|
||||||
}
|
}
|
||||||
return stripReasoningMessages(messages)
|
return stripReasoningMessages(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Provider) isDeepSeekReasoningProvider() bool {
|
func (p *Provider) isDeepSeekReasoningProvider(model string) bool {
|
||||||
return p.providerName == "deepseek" || isDeepSeekHost(p.apiBase)
|
return p.providerName == "deepseek" || isDeepSeekHost(p.apiBase) || isDeepSeekModel(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isDeepSeekHost(apiBase string) bool {
|
func isDeepSeekHost(apiBase string) bool {
|
||||||
|
|
@ -232,6 +232,10 @@ func isDeepSeekHost(apiBase string) bool {
|
||||||
return host == "deepseek.com" || strings.HasSuffix(host, ".deepseek.com")
|
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 {
|
func filterDeepSeekReasoningMessages(messages []Message) []Message {
|
||||||
out := make([]Message, 0, len(messages))
|
out := make([]Message, 0, len(messages))
|
||||||
start := 0
|
start := 0
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestProviderChat_HTTPError(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue