diff --git a/.changeset/modern-carrots-applaud.md b/.changeset/modern-carrots-applaud.md new file mode 100644 index 0000000..fd0da56 --- /dev/null +++ b/.changeset/modern-carrots-applaud.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Add the DeepSeek provider diff --git a/README.md b/README.md index 8a8a7c5..2a191a7 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f - Includes current time in the system prompt - Uses a file system watcher to more reliably watch for file system changes - Language selection for Cline's communication (English, Japanese, Spanish, French, German, and more) +- Support for DeepSeek V3 - Support for Meta 3, 3.1, and 3.2 models via AWS Bedrock - Support for listing models from OpenAI-compatible providers - Per-tool MCP auto-approval diff --git a/src/api/index.ts b/src/api/index.ts index 3fee5c4..06983de 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -9,6 +9,7 @@ import { OllamaHandler } from "./providers/ollama" import { LmStudioHandler } from "./providers/lmstudio" import { GeminiHandler } from "./providers/gemini" import { OpenAiNativeHandler } from "./providers/openai-native" +import { DeepSeekHandler } from "./providers/deepseek" import { ApiStream } from "./transform/stream" export interface SingleCompletionHandler { @@ -41,6 +42,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler { return new GeminiHandler(options) case "openai-native": return new OpenAiNativeHandler(options) + case "deepseek": + return new DeepSeekHandler(options) default: return new AnthropicHandler(options) } diff --git a/src/api/providers/__tests__/deepseek.test.ts b/src/api/providers/__tests__/deepseek.test.ts new file mode 100644 index 0000000..85918be --- /dev/null +++ b/src/api/providers/__tests__/deepseek.test.ts @@ -0,0 +1,167 @@ +import { DeepSeekHandler } from '../deepseek' +import { ApiHandlerOptions } from '../../../shared/api' +import OpenAI from 'openai' +import { Anthropic } from '@anthropic-ai/sdk' + +// Mock dependencies +jest.mock('openai') + +describe('DeepSeekHandler', () => { + + const mockOptions: ApiHandlerOptions = { + deepSeekApiKey: 'test-key', + deepSeekModelId: 'deepseek-chat', + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + test('constructor initializes with correct options', () => { + const handler = new DeepSeekHandler(mockOptions) + expect(handler).toBeInstanceOf(DeepSeekHandler) + expect(OpenAI).toHaveBeenCalledWith({ + baseURL: 'https://api.deepseek.com/v1', + apiKey: mockOptions.deepSeekApiKey, + }) + }) + + test('getModel returns correct model info', () => { + const handler = new DeepSeekHandler(mockOptions) + const result = handler.getModel() + + expect(result).toEqual({ + id: mockOptions.deepSeekModelId, + info: expect.objectContaining({ + maxTokens: 8192, + contextWindow: 64000, + supportsPromptCache: false, + supportsImages: false, + inputPrice: 0.014, + outputPrice: 0.28, + }) + }) + }) + + test('getModel returns default model info when no model specified', () => { + const handler = new DeepSeekHandler({ deepSeekApiKey: 'test-key' }) + const result = handler.getModel() + + expect(result.id).toBe('deepseek-chat') + expect(result.info.maxTokens).toBe(8192) + }) + + test('createMessage handles string content correctly', async () => { + const handler = new DeepSeekHandler(mockOptions) + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + choices: [{ + delta: { + content: 'test response' + } + }] + } + } + } + + const mockCreate = jest.fn().mockResolvedValue(mockStream) + ;(OpenAI as jest.MockedClass).prototype.chat = { + completions: { create: mockCreate } + } as any + + const systemPrompt = 'test system prompt' + const messages: Anthropic.Messages.MessageParam[] = [ + { role: 'user', content: 'test message' } + ] + + const generator = handler.createMessage(systemPrompt, messages) + const chunks = [] + + for await (const chunk of generator) { + chunks.push(chunk) + } + + expect(chunks).toHaveLength(1) + expect(chunks[0]).toEqual({ + type: 'text', + text: 'test response' + }) + + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ + model: mockOptions.deepSeekModelId, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: 'test message' } + ], + temperature: 0, + stream: true, + max_tokens: 8192, + stream_options: { include_usage: true } + })) + }) + + test('createMessage handles complex content correctly', async () => { + const handler = new DeepSeekHandler(mockOptions) + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + choices: [{ + delta: { + content: 'test response' + } + }] + } + } + } + + const mockCreate = jest.fn().mockResolvedValue(mockStream) + ;(OpenAI as jest.MockedClass).prototype.chat = { + completions: { create: mockCreate } + } as any + + const systemPrompt = 'test system prompt' + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'part 1' }, + { type: 'text', text: 'part 2' } + ] + } + ] + + const generator = handler.createMessage(systemPrompt, messages) + await generator.next() + + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ + messages: [ + { role: 'system', content: systemPrompt }, + { + role: 'user', + content: [ + { type: 'text', text: 'part 1' }, + { type: 'text', text: 'part 2' } + ] + } + ] + })) + }) + + test('createMessage handles API errors', async () => { + const handler = new DeepSeekHandler(mockOptions) + const mockStream = { + async *[Symbol.asyncIterator]() { + throw new Error('API Error') + } + } + + const mockCreate = jest.fn().mockResolvedValue(mockStream) + ;(OpenAI as jest.MockedClass).prototype.chat = { + completions: { create: mockCreate } + } as any + + const generator = handler.createMessage('test', []) + await expect(generator.next()).rejects.toThrow('API Error') + }) +}) \ No newline at end of file diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts new file mode 100644 index 0000000..de23d70 --- /dev/null +++ b/src/api/providers/deepseek.ts @@ -0,0 +1,26 @@ +import { OpenAiHandler } from "./openai" +import { ApiHandlerOptions, ModelInfo } from "../../shared/api" +import { deepSeekModels, deepSeekDefaultModelId } from "../../shared/api" + +export class DeepSeekHandler extends OpenAiHandler { + constructor(options: ApiHandlerOptions) { + if (!options.deepSeekApiKey) { + throw new Error("DeepSeek API key is required. Please provide it in the settings.") + } + super({ + ...options, + openAiApiKey: options.deepSeekApiKey, + openAiModelId: options.deepSeekModelId ?? deepSeekDefaultModelId, + openAiBaseUrl: options.deepSeekBaseUrl ?? "https://api.deepseek.com/v1", + includeMaxTokens: true + }) + } + + override getModel(): { id: string; info: ModelInfo } { + const modelId = this.options.deepSeekModelId ?? deepSeekDefaultModelId + return { + id: modelId, + info: deepSeekModels[modelId as keyof typeof deepSeekModels] || deepSeekModels[deepSeekDefaultModelId] + } + } +} diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 71308ed..071df8d 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -11,7 +11,7 @@ import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" export class OpenAiHandler implements ApiHandler { - private options: ApiHandlerOptions + protected options: ApiHandlerOptions private client: OpenAI constructor(options: ApiHandlerOptions) { @@ -38,12 +38,16 @@ export class OpenAiHandler implements ApiHandler { { role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages), ] + const modelInfo = this.getModel().info const requestOptions: OpenAI.Chat.ChatCompletionCreateParams = { model: this.options.openAiModelId ?? "", messages: openAiMessages, temperature: 0, stream: true, } + if (this.options.includeMaxTokens) { + requestOptions.max_tokens = modelInfo.maxTokens + } if (this.options.includeStreamOptions ?? true) { requestOptions.stream_options = { include_usage: true } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 8cc878b..d27ed9b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -40,6 +40,7 @@ type SecretKey = | "openAiApiKey" | "geminiApiKey" | "openAiNativeApiKey" + | "deepSeekApiKey" type GlobalStateKey = | "apiProvider" | "apiModelId" @@ -443,6 +444,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl) await this.storeSecret("geminiApiKey", geminiApiKey) await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey) + await this.storeSecret("deepSeekApiKey", message.apiConfiguration.deepSeekApiKey) await this.updateGlobalState("azureApiVersion", azureApiVersion) await this.updateGlobalState("openRouterModelId", openRouterModelId) await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo) @@ -1121,6 +1123,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { anthropicBaseUrl, geminiApiKey, openAiNativeApiKey, + deepSeekApiKey, azureApiVersion, openRouterModelId, openRouterModelInfo, @@ -1163,6 +1166,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("anthropicBaseUrl") as Promise, this.getSecret("geminiApiKey") as Promise, this.getSecret("openAiNativeApiKey") as Promise, + this.getSecret("deepSeekApiKey") as Promise, this.getGlobalState("azureApiVersion") as Promise, this.getGlobalState("openRouterModelId") as Promise, this.getGlobalState("openRouterModelInfo") as Promise, @@ -1222,6 +1226,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { anthropicBaseUrl, geminiApiKey, openAiNativeApiKey, + deepSeekApiKey, azureApiVersion, openRouterModelId, openRouterModelInfo, @@ -1344,6 +1349,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { "openAiApiKey", "geminiApiKey", "openAiNativeApiKey", + "deepSeekApiKey", ] for (const key of secretKeys) { await this.storeSecret(key, undefined) diff --git a/src/shared/api.ts b/src/shared/api.ts index b5107f4..2759a26 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -8,6 +8,7 @@ export type ApiProvider = | "lmstudio" | "gemini" | "openai-native" + | "deepseek" export interface ApiHandlerOptions { apiModelId?: string @@ -38,6 +39,10 @@ export interface ApiHandlerOptions { openRouterUseMiddleOutTransform?: boolean includeStreamOptions?: boolean setAzureApiVersion?: boolean + deepSeekBaseUrl?: string + deepSeekApiKey?: string + deepSeekModelId?: string + includeMaxTokens?: boolean } export type ApiConfiguration = ApiHandlerOptions & { @@ -489,6 +494,22 @@ export const openAiNativeModels = { }, } as const satisfies Record +// DeepSeek +// https://platform.deepseek.com/docs/api +export type DeepSeekModelId = keyof typeof deepSeekModels +export const deepSeekDefaultModelId: DeepSeekModelId = "deepseek-chat" +export const deepSeekModels = { + "deepseek-chat": { + maxTokens: 8192, + contextWindow: 64_000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.014, // $0.014 per million tokens + outputPrice: 0.28, // $0.28 per million tokens + description: `DeepSeek-V3 achieves a significant breakthrough in inference speed over previous models. It tops the leaderboard among open-source models and rivals the most advanced closed-source models globally.`, + }, +} as const satisfies Record + // Azure OpenAI // https://learn.microsoft.com/en-us/azure/ai-services/openai/api-version-deprecation // https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#api-specs diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index ae4a37c..c72342e 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -17,6 +17,8 @@ import { azureOpenAiDefaultApiVersion, bedrockDefaultModelId, bedrockModels, + deepSeekDefaultModelId, + deepSeekModels, geminiDefaultModelId, geminiModels, openAiModelInfoSaneDefaults, @@ -130,10 +132,11 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: OpenRouter Anthropic Google Gemini - GCP Vertex AI - AWS Bedrock + DeepSeek OpenAI OpenAI Compatible + GCP Vertex AI + AWS Bedrock LM Studio Ollama @@ -560,6 +563,34 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: )} + {selectedProvider === "deepseek" && ( +
+ + DeepSeek API Key + +

+ This key is stored locally and only used to make API requests from this extension. + {!apiConfiguration?.deepSeekApiKey && ( + + You can get a DeepSeek API key by signing up here. + + )} +

+
+ )} + {selectedProvider === "ollama" && (