diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 29c2b32ea..08cf71d79 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -605,6 +605,43 @@ func TestProcessMessage_PassesExplicitThinkingOffToProviderWithoutThinkingCapabi } } +func TestProcessMessage_PassesDeepSeekThinkingLevelToThinkingCapableProvider(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "deepseek-v4-flash", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{{ + ModelName: "deepseek-v4-flash", + Provider: "deepseek", + Model: "deepseek-v4-flash", + ThinkingLevel: "xhigh", + }}, + } + + provider := &thinkingRecordingProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "pico", + ChatID: "chat-1", + Content: "hello", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if got := provider.lastOptions["thinking_level"]; got != "xhigh" { + t.Fatalf("thinking_level option = %#v, want %q", got, "xhigh") + } +} + func TestProcessMessage_SuppressesReasoningWhenThinkingOff(t *testing.T) { cfg := &config.Config{ Agents: config.AgentsConfig{ diff --git a/pkg/agent/pipeline_setup.go b/pkg/agent/pipeline_setup.go index 9d5033cef..ea05968ca 100644 --- a/pkg/agent/pipeline_setup.go +++ b/pkg/agent/pipeline_setup.go @@ -71,7 +71,14 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution history, messages, fit = trimHistoryToFitContextWindow( history, func(trimmedHistory []providers.Message) []providers.Message { - rebuildPromptReq := promptBuildRequestForTurn(ts, trimmedHistory, summary, ts.userMessage, ts.media, cfg) + rebuildPromptReq := promptBuildRequestForTurn( + ts, + trimmedHistory, + summary, + ts.userMessage, + ts.media, + cfg, + ) rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...) rebuilt := ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq) return resolveMediaRefs(rebuilt, p.MediaStore, maxMediaSize) diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 2b7340070..0b3dd791b 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -171,6 +171,30 @@ func TestCreateProviderFromConfig_UsesExplicitProvider(t *testing.T) { } } +func TestCreateProviderFromConfig_DeepSeekSupportsThinking(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "deepseek-v4-flash", + Provider: "deepseek", + Model: "deepseek-v4-flash", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if modelID != "deepseek-v4-flash" { + t.Fatalf("modelID = %q, want %q", modelID, "deepseek-v4-flash") + } + tc, ok := provider.(ThinkingCapable) + if !ok { + t.Fatalf("provider %T should implement ThinkingCapable for DeepSeek", provider) + } + if !tc.SupportsThinking() { + t.Fatalf("DeepSeek provider SupportsThinking() = false, want true") + } +} + func TestCreateProviderFromConfig_PreservesExplicitProviderPrefixedModel(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-openai", diff --git a/pkg/providers/httpapi/http_provider.go b/pkg/providers/httpapi/http_provider.go index af8cc8abf..4d6fbec9e 100644 --- a/pkg/providers/httpapi/http_provider.go +++ b/pkg/providers/httpapi/http_provider.go @@ -89,6 +89,13 @@ func (p *HTTPProvider) SupportsNativeSearch() bool { return p.delegate.SupportsNativeSearch() } +func (p *HTTPProvider) SupportsThinking() bool { + if p == nil || p.delegate == nil { + return false + } + return p.delegate.SupportsThinking() +} + func (p *HTTPProvider) SetProviderName(providerName string) { if p == nil || p.delegate == nil { return diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 5990f58e9..ff30d49c6 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/messageutil" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" @@ -204,7 +205,16 @@ func (p *Provider) buildRequestBody( func (p *Provider) applyThinkingControl(requestBody map[string]any, model string, options map[string]any) { level, ok := normalizedThinkingLevel(options) - if !ok || level != "off" { + if !ok { + return + } + + if p.SupportsThinking() { + p.applyDeepSeekThinkingControl(requestBody, level) + return + } + + if level != "off" { return } @@ -216,6 +226,28 @@ func (p *Provider) applyThinkingControl(requestBody map[string]any, model string } } +func (p *Provider) applyDeepSeekThinkingControl(requestBody map[string]any, level string) { + switch level { + case "off": + requestBody["thinking"] = map[string]any{"type": "disabled"} + case "low", "medium", "high": + requestBody["thinking"] = map[string]any{"type": "enabled"} + requestBody["reasoning_effort"] = "high" + case "xhigh": + requestBody["thinking"] = map[string]any{"type": "enabled"} + requestBody["reasoning_effort"] = "max" + case "adaptive": + logger.WarnCF("provider.openai_compat", + `DeepSeek does not support thinking_level="adaptive"; using provider default thinking behavior`, + map[string]any{ + "provider": p.providerName, + "api_base": p.apiBase, + "thinking_level": level, + }, + ) + } +} + func normalizedThinkingLevel(options map[string]any) (string, bool) { raw, ok := options["thinking_level"].(string) if !ok { @@ -290,6 +322,10 @@ func (p *Provider) SetProviderName(providerName string) { p.providerName = strings.ToLower(strings.TrimSpace(providerName)) } +func (p *Provider) SupportsThinking() bool { + return strings.EqualFold(strings.TrimSpace(p.providerName), "deepseek") || isDeepSeekHost(p.apiBase) +} + func (p *Provider) prepareMessagesForRequest(messages []Message) []Message { if len(messages) == 0 { return nil diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index e8396171f..bc7e12de6 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -8,11 +8,13 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "strings" "sync" "testing" "time" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -125,6 +127,146 @@ func TestBuildRequestBody_PreservesDoubaoRequestWhenThinkingLevelIsNotOff(t *tes } } +func TestBuildRequestBody_MapsDeepSeekThinkingLevels(t *testing.T) { + p := NewProvider("key", "https://api.deepseek.com/v1", "") + p.SetProviderName("deepseek") + + tests := []struct { + name string + level string + wantThinkingType string + wantEffort any + }{ + {name: "off", level: "off", wantThinkingType: "disabled"}, + {name: "low", level: "low", wantThinkingType: "enabled", wantEffort: "high"}, + {name: "medium", level: "medium", wantThinkingType: "enabled", wantEffort: "high"}, + {name: "high", level: "high", wantThinkingType: "enabled", wantEffort: "high"}, + {name: "xhigh", level: "xhigh", wantThinkingType: "enabled", wantEffort: "max"}, + {name: "adaptive", level: "adaptive"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := p.buildRequestBody( + []Message{{Role: "user", Content: "hi"}}, + nil, + "deepseek-v4-pro", + map[string]any{"thinking_level": tt.level}, + ) + + if tt.wantThinkingType == "" { + if _, ok := body["thinking"]; ok { + t.Fatalf("thinking should be omitted for %q, got %#v", tt.level, body["thinking"]) + } + } else { + thinking, ok := body["thinking"].(map[string]any) + if !ok { + t.Fatalf("thinking = %#v, want map", body["thinking"]) + } + if got := thinking["type"]; got != tt.wantThinkingType { + t.Fatalf("thinking.type = %#v, want %q", got, tt.wantThinkingType) + } + } + + if tt.wantEffort == nil { + if _, ok := body["reasoning_effort"]; ok { + t.Fatalf("reasoning_effort should be omitted for %q, got %#v", tt.level, body["reasoning_effort"]) + } + } else if got := body["reasoning_effort"]; got != tt.wantEffort { + t.Fatalf("reasoning_effort = %#v, want %#v", got, tt.wantEffort) + } + }) + } +} + +func TestBuildRequestBody_MapsDeepSeekThinkingLevelsByHost(t *testing.T) { + p := NewProvider("key", "https://api.deepseek.com/v1", "") + + body := p.buildRequestBody( + []Message{{Role: "user", Content: "hi"}}, + nil, + "deepseek-v4-flash", + map[string]any{"thinking_level": "xhigh"}, + ) + + thinking, ok := body["thinking"].(map[string]any) + if !ok { + t.Fatalf("thinking = %#v, want map", body["thinking"]) + } + if got := thinking["type"]; got != "enabled" { + t.Fatalf("thinking.type = %#v, want enabled", got) + } + if got := body["reasoning_effort"]; got != "max" { + t.Fatalf("reasoning_effort = %#v, want max", got) + } +} + +func TestBuildRequestBody_DeepSeekExtraBodyStillOverridesThinkingFields(t *testing.T) { + extraBody := map[string]any{ + "thinking": map[string]any{"type": "disabled"}, + "reasoning_effort": "max", + } + p := NewProvider("key", "https://api.deepseek.com/v1", "", WithExtraBody(extraBody)) + p.SetProviderName("deepseek") + + body := p.buildRequestBody( + []Message{{Role: "user", Content: "hi"}}, + nil, + "deepseek-v4-pro", + map[string]any{"thinking_level": "high"}, + ) + + thinking, ok := body["thinking"].(map[string]any) + if !ok { + t.Fatalf("thinking = %#v, want map", body["thinking"]) + } + if got := thinking["type"]; got != "disabled" { + t.Fatalf("thinking.type = %#v, want disabled from extra_body override", got) + } + if got := body["reasoning_effort"]; got != "max" { + t.Fatalf("reasoning_effort = %#v, want max from extra_body override", got) + } +} + +func TestBuildRequestBody_WarnsForUnsupportedDeepSeekAdaptiveThinkingLevel(t *testing.T) { + logFile := t.TempDir() + "/deepseek-adaptive-warning.log" + prevLevel := logger.GetLevel() + logger.SetLevel(logger.WARN) + if err := logger.EnableFileLogging(logFile); err != nil { + t.Fatalf("EnableFileLogging() error = %v", err) + } + defer func() { + logger.DisableFileLogging() + logger.SetLevel(prevLevel) + }() + + p := NewProvider("key", "https://api.deepseek.com/v1", "") + p.SetProviderName("deepseek") + + body := p.buildRequestBody( + []Message{{Role: "user", Content: "hi"}}, + nil, + "deepseek-v4-pro", + map[string]any{"thinking_level": "adaptive"}, + ) + + if _, ok := body["thinking"]; ok { + t.Fatalf("thinking should be omitted for adaptive, got %#v", body["thinking"]) + } + if _, ok := body["reasoning_effort"]; ok { + t.Fatalf("reasoning_effort should be omitted for adaptive, got %#v", body["reasoning_effort"]) + } + + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", logFile, err) + } + logs := string(data) + if !strings.Contains(logs, `thinking_level=\"adaptive\"`) { + t.Fatalf("warning log = %q, want adaptive warning message", logs) + } +} + func TestProviderChat_ParsesToolCalls(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{