diff --git a/internal/config/config.go b/internal/config/config.go index 630fac9b6..045da51b6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -273,6 +273,9 @@ func setProviderDefaults() { if apiKey := os.Getenv("XAI_API_KEY"); apiKey != "" { viper.SetDefault("providers.xai.apiKey", apiKey) } + if apiKey := os.Getenv("DEEPSEEK_API_KEY"); apiKey != "" { + viper.SetDefault("providers.deepseek.apiKey", apiKey) + } if apiKey := os.Getenv("AZURE_OPENAI_ENDPOINT"); apiKey != "" { // api-key may be empty when using Entra ID credentials – that's okay viper.SetDefault("providers.azure.apiKey", os.Getenv("AZURE_OPENAI_API_KEY")) @@ -358,6 +361,15 @@ func setProviderDefaults() { return } + // DeepSeek configuration + if key := viper.GetString("providers.deepseek.apiKey"); strings.TrimSpace(key) != "" { + viper.SetDefault("agents.coder.model", models.DeepSeekChat) + viper.SetDefault("agents.summarizer.model", models.DeepSeekChat) + viper.SetDefault("agents.task.model", models.DeepSeekChat) + viper.SetDefault("agents.title.model", models.DeepSeekChat) + return + } + // AWS Bedrock configuration if hasAWSCredentials() { viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet) @@ -663,6 +675,8 @@ func getProviderAPIKey(provider models.ModelProvider) string { if hasVertexAICredentials() { return "vertex-ai-credentials-available" } + case models.ProviderDeepSeek: + return os.Getenv("DEEPSEEK_API_KEY") } return "" } diff --git a/internal/llm/models/deepseek.go b/internal/llm/models/deepseek.go new file mode 100644 index 000000000..b85c24ab0 --- /dev/null +++ b/internal/llm/models/deepseek.go @@ -0,0 +1,38 @@ +package models + +const ( + ProviderDeepSeek ModelProvider = "deepseek" + + DeepSeekChat ModelID = "deepseek-chat" + DeepSeekReasoner ModelID = "deepseek-reasoner" +) + +var DeepSeekModels = map[ModelID]Model{ + DeepSeekChat: { + ID: DeepSeekChat, + Name: "DeepSeek Chat", + Provider: ProviderDeepSeek, + APIModel: "deepseek-chat", + CostPer1MIn: 0.14, + CostPer1MInCached: 0.014, + CostPer1MOut: 0.28, + CostPer1MOutCached: 0.028, + ContextWindow: 163_840, + DefaultMaxTokens: 8192, + SupportsAttachments: true, + }, + DeepSeekReasoner: { + ID: DeepSeekReasoner, + Name: "DeepSeek Reasoner", + Provider: ProviderDeepSeek, + APIModel: "deepseek-reasoner", + CostPer1MIn: 0.55, + CostPer1MInCached: 0.055, + CostPer1MOut: 2.19, + CostPer1MOutCached: 0.219, + ContextWindow: 163_840, + DefaultMaxTokens: 8192, + CanReason: true, + SupportsAttachments: true, // Reasoner supports tools! + }, +} \ No newline at end of file diff --git a/internal/llm/models/models.go b/internal/llm/models/models.go index 2bcb508e9..185aec3ba 100644 --- a/internal/llm/models/models.go +++ b/internal/llm/models/models.go @@ -45,6 +45,7 @@ var ProviderPopularity = map[ModelProvider]int{ ProviderBedrock: 7, ProviderAzure: 8, ProviderVertexAI: 9, + ProviderDeepSeek: 10, } var SupportedModels = map[ModelID]Model{ @@ -95,4 +96,5 @@ func init() { maps.Copy(SupportedModels, XAIModels) maps.Copy(SupportedModels, VertexAIGeminiModels) maps.Copy(SupportedModels, CopilotModels) + maps.Copy(SupportedModels, DeepSeekModels) } diff --git a/internal/llm/provider/deepseek.go b/internal/llm/provider/deepseek.go new file mode 100644 index 000000000..e5194dfe1 --- /dev/null +++ b/internal/llm/provider/deepseek.go @@ -0,0 +1,397 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/openai/openai-go" + "github.com/openai/openai-go/option" + "github.com/opencode-ai/opencode/internal/config" + "github.com/opencode-ai/opencode/internal/llm/models" + "github.com/opencode-ai/opencode/internal/llm/tools" + "github.com/opencode-ai/opencode/internal/logging" + "github.com/opencode-ai/opencode/internal/message" +) + +type deepSeekOptions struct { + baseURL string + extraHeaders map[string]string +} + +type DeepSeekOption func(*deepSeekOptions) + +type deepSeekClient struct { + providerOptions providerClientOptions + options deepSeekOptions + client openai.Client +} + +type DeepSeekClient ProviderClient + +func newDeepSeekClient(opts providerClientOptions) DeepSeekClient { + deepSeekOpts := deepSeekOptions{ + baseURL: "https://api.deepseek.com/v1", + } + for _, o := range opts.deepSeekOptions { + o(&deepSeekOpts) + } + + clientOptions := []option.RequestOption{} + if opts.apiKey != "" { + clientOptions = append(clientOptions, option.WithAPIKey(opts.apiKey)) + } + if deepSeekOpts.baseURL != "" { + clientOptions = append(clientOptions, option.WithBaseURL(deepSeekOpts.baseURL)) + } + + if deepSeekOpts.extraHeaders != nil { + for key, value := range deepSeekOpts.extraHeaders { + clientOptions = append(clientOptions, option.WithHeader(key, value)) + } + } + + client := openai.NewClient(clientOptions...) + return &deepSeekClient{ + providerOptions: opts, + options: deepSeekOpts, + client: client, + } +} + +func (d *deepSeekClient) convertMessages(messages []message.Message) (deepSeekMessages []openai.ChatCompletionMessageParamUnion) { + // Add system message first + deepSeekMessages = append(deepSeekMessages, openai.SystemMessage(d.providerOptions.systemMessage)) + + for _, msg := range messages { + switch msg.Role { + case message.User: + var content []openai.ChatCompletionContentPartUnionParam + textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()} + content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock}) + for _, binaryContent := range msg.BinaryContent() { + imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(models.ProviderDeepSeek)} + imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL} + content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock}) + } + deepSeekMessages = append(deepSeekMessages, openai.UserMessage(content)) + + case message.Assistant: + assistantMsg := openai.ChatCompletionAssistantMessageParam{ + Role: "assistant", + } + + if msg.Content().String() != "" { + assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{ + OfString: openai.String(msg.Content().String()), + } + } + + if len(msg.ToolCalls()) > 0 { + assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls())) + for i, call := range msg.ToolCalls() { + assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{ + ID: call.ID, + Type: "function", + Function: openai.ChatCompletionMessageToolCallFunctionParam{ + Name: call.Name, + Arguments: call.Input, + }, + } + } + } + + deepSeekMessages = append(deepSeekMessages, openai.ChatCompletionMessageParamUnion{ + OfAssistant: &assistantMsg, + }) + + case message.Tool: + for _, result := range msg.ToolResults() { + deepSeekMessages = append(deepSeekMessages, + openai.ToolMessage(result.Content, result.ToolCallID), + ) + } + } + } + + return +} + +// DeepSeek-specific tool conversion that handles empty tools properly +func (d *deepSeekClient) convertTools(tools []tools.BaseTool) []openai.ChatCompletionToolParam { + // DeepSeek API doesn't accept empty tools array - return nil instead + if len(tools) == 0 { + return nil + } + + deepSeekTools := make([]openai.ChatCompletionToolParam, len(tools)) + + for i, tool := range tools { + info := tool.Info() + deepSeekTools[i] = openai.ChatCompletionToolParam{ + Type: "function", + Function: openai.FunctionDefinitionParam{ + Name: info.Name, + Description: openai.String(info.Description), + Parameters: openai.FunctionParameters{ + "type": "object", + "properties": info.Parameters, + "required": info.Required, + }, + }, + } + } + + return deepSeekTools +} + +func (d *deepSeekClient) finishReason(reason string) message.FinishReason { + switch reason { + case "stop": + return message.FinishReasonEndTurn + case "length": + return message.FinishReasonMaxTokens + case "tool_calls": + return message.FinishReasonToolUse + default: + return message.FinishReasonUnknown + } +} + +func (d *deepSeekClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams { + params := openai.ChatCompletionNewParams{ + Model: openai.ChatModel(d.providerOptions.model.APIModel), + Messages: messages, + MaxTokens: openai.Int(d.providerOptions.maxTokens), + } + + // Only add tools if they exist (DeepSeek doesn't like empty tools array) + if len(tools) > 0 { + params.Tools = tools + } + + return params +} + +func (d *deepSeekClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) { + params := d.preparedParams(d.convertMessages(messages), d.convertTools(tools)) + cfg := config.Get() + if cfg.Debug { + jsonData, _ := json.Marshal(params) + logging.Debug("DeepSeek prepared messages", "messages", string(jsonData)) + } + + attempts := 0 + for { + attempts++ + deepSeekResponse, err := d.client.Chat.Completions.New(ctx, params) + + // If there is an error we are going to see if we can retry the call + if err != nil { + retry, after, retryErr := d.shouldRetry(attempts, err) + if retryErr != nil { + return nil, retryErr + } + if retry { + logging.WarnPersist(fmt.Sprintf("DeepSeek: Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), logging.PersistTimeArg, time.Millisecond*time.Duration(after+100)) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(after) * time.Millisecond): + continue + } + } + return nil, retryErr + } + + content := "" + if deepSeekResponse.Choices[0].Message.Content != "" { + content = deepSeekResponse.Choices[0].Message.Content + } + + toolCalls := d.toolCalls(*deepSeekResponse) + finishReason := d.finishReason(string(deepSeekResponse.Choices[0].FinishReason)) + + if len(toolCalls) > 0 { + finishReason = message.FinishReasonToolUse + } + + return &ProviderResponse{ + Content: content, + ToolCalls: toolCalls, + Usage: d.usage(*deepSeekResponse), + FinishReason: finishReason, + }, nil + } +} + +func (d *deepSeekClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent { + params := d.preparedParams(d.convertMessages(messages), d.convertTools(tools)) + params.StreamOptions = openai.ChatCompletionStreamOptionsParam{ + IncludeUsage: openai.Bool(true), + } + + cfg := config.Get() + if cfg.Debug { + jsonData, _ := json.Marshal(params) + logging.Debug("DeepSeek prepared messages", "messages", string(jsonData)) + } + + attempts := 0 + eventChan := make(chan ProviderEvent) + + go func() { + for { + attempts++ + deepSeekStream := d.client.Chat.Completions.NewStreaming(ctx, params) + + acc := openai.ChatCompletionAccumulator{} + currentContent := "" + toolCalls := make([]message.ToolCall, 0) + + for deepSeekStream.Next() { + chunk := deepSeekStream.Current() + acc.AddChunk(chunk) + + for _, choice := range chunk.Choices { + if choice.Delta.Content != "" { + eventChan <- ProviderEvent{ + Type: EventContentDelta, + Content: choice.Delta.Content, + } + currentContent += choice.Delta.Content + } + } + } + + err := deepSeekStream.Err() + if err == nil || errors.Is(err, io.EOF) { + // Stream completed successfully + finishReason := d.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason)) + if len(acc.ChatCompletion.Choices[0].Message.ToolCalls) > 0 { + toolCalls = append(toolCalls, d.toolCalls(acc.ChatCompletion)...) + } + if len(toolCalls) > 0 { + finishReason = message.FinishReasonToolUse + } + + eventChan <- ProviderEvent{ + Type: EventComplete, + Response: &ProviderResponse{ + Content: currentContent, + ToolCalls: toolCalls, + Usage: d.usage(acc.ChatCompletion), + FinishReason: finishReason, + }, + } + close(eventChan) + return + } + + // If there is an error we are going to see if we can retry the call + retry, after, retryErr := d.shouldRetry(attempts, err) + if retryErr != nil { + eventChan <- ProviderEvent{Type: EventError, Error: retryErr} + close(eventChan) + return + } + if retry { + logging.WarnPersist(fmt.Sprintf("DeepSeek: Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), logging.PersistTimeArg, time.Millisecond*time.Duration(after+100)) + select { + case <-ctx.Done(): + if ctx.Err() != nil { + eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()} + } + close(eventChan) + return + case <-time.After(time.Duration(after) * time.Millisecond): + continue + } + } + eventChan <- ProviderEvent{Type: EventError, Error: retryErr} + close(eventChan) + return + } + }() + + return eventChan +} + +func (d *deepSeekClient) shouldRetry(attempts int, err error) (bool, int64, error) { + var apierr *openai.Error + if !errors.As(err, &apierr) { + return false, 0, err + } + + // DeepSeek specific retry logic + if apierr.StatusCode != http.StatusTooManyRequests && apierr.StatusCode != http.StatusInternalServerError { + return false, 0, err + } + + if attempts > maxRetries { + return false, 0, fmt.Errorf("DeepSeek: maximum retry attempts reached: %d retries", maxRetries) + } + + retryMs := 0 + retryAfterValues := apierr.Response.Header.Values("Retry-After") + + backoffMs := 2000 * (1 << (attempts - 1)) + jitterMs := int(float64(backoffMs) * 0.2) + retryMs = backoffMs + jitterMs + if len(retryAfterValues) > 0 { + if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil { + retryMs = retryMs * 1000 + } + } + return true, int64(retryMs), nil +} + +func (d *deepSeekClient) toolCalls(completion openai.ChatCompletion) []message.ToolCall { + var toolCalls []message.ToolCall + + if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 { + for _, call := range completion.Choices[0].Message.ToolCalls { + toolCall := message.ToolCall{ + ID: call.ID, + Name: call.Function.Name, + Input: call.Function.Arguments, + Type: "function", + Finished: true, + } + toolCalls = append(toolCalls, toolCall) + } + } + + return toolCalls +} + +func (d *deepSeekClient) usage(completion openai.ChatCompletion) TokenUsage { + cachedTokens := int64(0) + if completion.Usage.PromptTokensDetails.CachedTokens != 0 { + cachedTokens = int64(completion.Usage.PromptTokensDetails.CachedTokens) + } + inputTokens := int64(completion.Usage.PromptTokens) - cachedTokens + + return TokenUsage{ + InputTokens: inputTokens, + OutputTokens: int64(completion.Usage.CompletionTokens), + CacheCreationTokens: 0, + CacheReadTokens: cachedTokens, + } +} + +func WithDeepSeekBaseURL(baseURL string) DeepSeekOption { + return func(options *deepSeekOptions) { + options.baseURL = baseURL + } +} + +func WithDeepSeekExtraHeaders(headers map[string]string) DeepSeekOption { + return func(options *deepSeekOptions) { + options.extraHeaders = headers + } +} \ No newline at end of file diff --git a/internal/llm/provider/provider.go b/internal/llm/provider/provider.go index d5be0ba0e..ce6b02d3b 100644 --- a/internal/llm/provider/provider.go +++ b/internal/llm/provider/provider.go @@ -69,6 +69,7 @@ type providerClientOptions struct { geminiOptions []GeminiOption bedrockOptions []BedrockOption copilotOptions []CopilotOption + deepSeekOptions []DeepSeekOption } type ProviderClientOption func(*providerClientOptions) @@ -160,6 +161,11 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption options: clientOptions, client: newOpenAIClient(clientOptions), }, nil + case models.ProviderDeepSeek: + return &baseProvider[DeepSeekClient]{ + options: clientOptions, + client: newDeepSeekClient(clientOptions), + }, nil case models.ProviderMock: // TODO: implement mock client for test panic("not implemented") @@ -245,3 +251,9 @@ func WithCopilotOptions(copilotOptions ...CopilotOption) ProviderClientOption { options.copilotOptions = copilotOptions } } + +func WithDeepSeekOptions(deepSeekOptions ...DeepSeekOption) ProviderClientOption { + return func(options *providerClientOptions) { + options.deepSeekOptions = deepSeekOptions + } +} diff --git a/internal/llm/tools/ls_test.go b/internal/llm/tools/ls_test.go index 508cb98d3..a3c0d8d8a 100644 --- a/internal/llm/tools/ls_test.go +++ b/internal/llm/tools/ls_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/opencode-ai/opencode/internal/config" ) func TestLsTool_Info(t *testing.T) { @@ -24,10 +25,14 @@ func TestLsTool_Info(t *testing.T) { } func TestLsTool_Run(t *testing.T) { - // Create a temporary directory for testing + // Initialize config for testing tempDir, err := os.MkdirTemp("", "ls_tool_test") require.NoError(t, err) defer os.RemoveAll(tempDir) + + // Load config with the temp directory as working directory + _, err = config.Load(tempDir, false) + require.NoError(t, err) // Create a test directory structure testDirs := []string{ @@ -183,21 +188,20 @@ func TestLsTool_Run(t *testing.T) { }) t.Run("handles relative path", func(t *testing.T) { - // Save original working directory - origWd, err := os.Getwd() + // Test with a relative path within the temp directory + // Create a subdirectory for this test + subdir := filepath.Join(tempDir, "testsubdir") + err := os.MkdirAll(subdir, 0755) require.NoError(t, err) - defer func() { - os.Chdir(origWd) - }() - // Change to a directory above the temp directory - parentDir := filepath.Dir(tempDir) - err = os.Chdir(parentDir) + // Create a file in the subdirectory + testFile := filepath.Join(subdir, "testfile.txt") + err = os.WriteFile(testFile, []byte("test content"), 0644) require.NoError(t, err) tool := NewLsTool() params := LSParams{ - Path: filepath.Base(tempDir), + Path: "testsubdir", // Relative to the config's working directory (tempDir) } paramsJSON, err := json.Marshal(params) @@ -211,9 +215,8 @@ func TestLsTool_Run(t *testing.T) { response, err := tool.Run(context.Background(), call) require.NoError(t, err) - // Should list the temp directory contents - assert.Contains(t, response.Content, "dir1") - assert.Contains(t, response.Content, "file1.txt") + // Should list the subdirectory contents + assert.Contains(t, response.Content, "testfile.txt") }) }