Skip to content

Commit de23b1f

Browse files
authored
Merge pull request #6 from doctarock/deepseek-v4-support
DeepSeek V4 Support
2 parents 81afb6c + 221dfc9 commit de23b1f

12 files changed

Lines changed: 349 additions & 29 deletions

File tree

packages/types/src/provider-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const dynamicProviders = [
4242
"roo",
4343
"unbound",
4444
"poe",
45+
"deepseek",
4546
] as const
4647

4748
export type DynamicProvider = (typeof dynamicProviders)[number]

packages/types/src/providers/deepseek.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,39 @@ import type { ModelInfo } from "../model.js"
66
// continuation within the same turn. See: https://api-docs.deepseek.com/guides/thinking_mode
77
export type DeepSeekModelId = keyof typeof deepSeekModels
88

9-
export const deepSeekDefaultModelId: DeepSeekModelId = "deepseek-chat"
9+
export const deepSeekDefaultModelId: DeepSeekModelId = "deepseek-v4-flash"
1010

1111
export const deepSeekModels = {
12+
"deepseek-v4-flash": {
13+
maxTokens: 384_000,
14+
contextWindow: 1_000_000,
15+
supportsImages: false,
16+
supportsPromptCache: true,
17+
supportsReasoningEffort: ["disable", "low", "medium", "high", "xhigh"],
18+
preserveReasoning: true,
19+
reasoningEffort: "high",
20+
inputPrice: 0.14, // $0.14 per million tokens (cache miss) - Updated Apr 29, 2026
21+
outputPrice: 0.28, // $0.28 per million tokens - Updated Apr 29, 2026
22+
cacheWritesPrice: 0.14, // $0.14 per million tokens (cache miss) - Updated Apr 29, 2026
23+
cacheReadsPrice: 0.0028, // $0.0028 per million tokens (cache hit) - Updated Apr 29, 2026
24+
description: `DeepSeek-V4-Flash is DeepSeek's fast, cost-efficient V4 model. It supports thinking and non-thinking modes, JSON output, tool calls, chat prefix completion (beta), and FIM completion (beta) in non-thinking mode.`,
25+
},
26+
"deepseek-v4-pro": {
27+
maxTokens: 384_000,
28+
contextWindow: 1_000_000,
29+
supportsImages: false,
30+
supportsPromptCache: true,
31+
supportsReasoningEffort: ["disable", "low", "medium", "high", "xhigh"],
32+
preserveReasoning: true,
33+
reasoningEffort: "high",
34+
// TODO(deepseek): Re-check V4 Pro discounted prices after DeepSeek's 2026-05-31 discount end date.
35+
inputPrice: 0.435, // $0.435 per million tokens (cache miss, discounted) - Updated Apr 29, 2026
36+
outputPrice: 0.87, // $0.87 per million tokens (discounted) - Updated Apr 29, 2026
37+
cacheWritesPrice: 0.435, // $0.435 per million tokens (cache miss, discounted) - Updated Apr 29, 2026
38+
cacheReadsPrice: 0.003625, // $0.003625 per million tokens (cache hit, discounted) - Updated Apr 29, 2026
39+
description: `DeepSeek-V4-Pro is DeepSeek's strongest V4 model for reasoning, coding, long-context, and agentic workloads. It supports thinking and non-thinking modes, JSON output, tool calls, chat prefix completion (beta), and FIM completion (beta) in non-thinking mode.`,
40+
},
41+
// TODO(deepseek): Remove this compatibility alias after DeepSeek's 2026-07-24 retirement date.
1242
"deepseek-chat": {
1343
maxTokens: 8192, // 8K max output
1444
contextWindow: 128_000,
@@ -18,8 +48,9 @@ export const deepSeekModels = {
1848
outputPrice: 0.42, // $0.42 per million tokens - Updated Dec 9, 2025
1949
cacheWritesPrice: 0.28, // $0.28 per million tokens (cache miss) - Updated Dec 9, 2025
2050
cacheReadsPrice: 0.028, // $0.028 per million tokens (cache hit) - Updated Dec 9, 2025
21-
description: `DeepSeek-V3.2 (Non-thinking Mode) 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. Supports JSON output, tool calls, chat prefix completion (beta), and FIM completion (beta).`,
51+
description: `Legacy compatibility alias for the non-thinking mode of deepseek-v4-flash. DeepSeek plans to deprecate this model name on 2026-07-24.`,
2252
},
53+
// TODO(deepseek): Remove this compatibility alias after DeepSeek's 2026-07-24 retirement date.
2354
"deepseek-reasoner": {
2455
maxTokens: 8192, // 8K max output
2556
contextWindow: 128_000,
@@ -30,7 +61,7 @@ export const deepSeekModels = {
3061
outputPrice: 0.42, // $0.42 per million tokens - Updated Dec 9, 2025
3162
cacheWritesPrice: 0.28, // $0.28 per million tokens (cache miss) - Updated Dec 9, 2025
3263
cacheReadsPrice: 0.028, // $0.028 per million tokens (cache hit) - Updated Dec 9, 2025
33-
description: `DeepSeek-V3.2 (Thinking Mode) achieves performance comparable to OpenAI-o1 across math, code, and reasoning tasks. Supports Chain of Thought reasoning with up to 8K output tokens. Supports JSON output, tool calls, and chat prefix completion (beta).`,
64+
description: `Legacy compatibility alias for the thinking mode of deepseek-v4-flash. DeepSeek plans to deprecate this model name on 2026-07-24.`,
3465
},
3566
} as const satisfies Record<string, ModelInfo>
3667

src/api/providers/__tests__/deepseek.spec.ts

Lines changed: 141 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@ vi.mock("openai", () => {
2929
}
3030
}
3131

32-
// Check if this is a reasoning_content test by looking at model
33-
const isReasonerModel = options.model?.includes("deepseek-reasoner")
32+
// Check if this is a reasoning_content test by looking at thinking mode
33+
const isThinkingModel = options.thinking?.type === "enabled"
3434
const isToolCallTest = options.tools?.length > 0
3535

3636
// Return async iterator for streaming
3737
return {
3838
[Symbol.asyncIterator]: async function* () {
39-
// For reasoner models, emit reasoning_content first
40-
if (isReasonerModel) {
39+
// For thinking models, emit reasoning_content first
40+
if (isThinkingModel) {
4141
yield {
4242
choices: [
4343
{
@@ -58,8 +58,8 @@ vi.mock("openai", () => {
5858
}
5959
}
6060

61-
// For tool call tests with reasoner, emit tool call
62-
if (isReasonerModel && isToolCallTest) {
61+
// For tool call tests with thinking mode, emit tool call
62+
if (isThinkingModel && isToolCallTest) {
6363
yield {
6464
choices: [
6565
{
@@ -206,10 +206,24 @@ describe("DeepSeekHandler", () => {
206206
const model = handler.getModel()
207207
expect(model.id).toBe(mockOptions.apiModelId)
208208
expect(model.info).toBeDefined()
209-
expect(model.info.maxTokens).toBe(8192) // deepseek-chat has 8K max
209+
expect(model.info.maxTokens).toBe(8192) // deepseek-chat legacy alias has 8K max
210210
expect(model.info.contextWindow).toBe(128_000)
211211
expect(model.info.supportsImages).toBe(false)
212212
expect(model.info.supportsPromptCache).toBe(true) // Should be true now
213+
expect((model.info as ModelInfo).preserveReasoning).toBeUndefined()
214+
})
215+
216+
it("should use deepseek-v4-flash as the default model ID for new configs", () => {
217+
const handlerWithoutModel = new DeepSeekHandler({
218+
...mockOptions,
219+
apiModelId: undefined,
220+
})
221+
const model = handlerWithoutModel.getModel()
222+
expect(model.id).toBe(deepSeekDefaultModelId)
223+
expect(model.id).toBe("deepseek-v4-flash")
224+
expect(model.info.maxTokens).toBe(384_000)
225+
expect(model.info.contextWindow).toBe(1_000_000)
226+
expect((model.info as ModelInfo).supportsReasoningEffort).toContain("xhigh")
213227
})
214228

215229
it("should return correct model info for deepseek-reasoner", () => {
@@ -226,6 +240,21 @@ describe("DeepSeekHandler", () => {
226240
expect(model.info.supportsPromptCache).toBe(true)
227241
})
228242

243+
it("should return correct model info for deepseek-v4-pro", () => {
244+
const handlerWithV4Pro = new DeepSeekHandler({
245+
...mockOptions,
246+
apiModelId: "deepseek-v4-pro",
247+
})
248+
const model = handlerWithV4Pro.getModel()
249+
expect(model.id).toBe("deepseek-v4-pro")
250+
expect(model.info).toBeDefined()
251+
expect(model.info.maxTokens).toBe(384_000)
252+
expect(model.info.contextWindow).toBe(1_000_000)
253+
expect(model.info.supportsPromptCache).toBe(true)
254+
expect((model.info as ModelInfo).preserveReasoning).toBe(true)
255+
expect((model.info as ModelInfo).reasoningEffort).toBe("high")
256+
})
257+
229258
it("should have preserveReasoning enabled for deepseek-reasoner to support interleaved thinking", () => {
230259
// This is critical for DeepSeek's interleaved thinking mode with tool calls.
231260
// See: https://api-docs.deepseek.com/guides/thinking_mode
@@ -242,7 +271,11 @@ describe("DeepSeekHandler", () => {
242271

243272
it("should NOT have preserveReasoning enabled for deepseek-chat", () => {
244273
// deepseek-chat doesn't use thinking mode, so no need to preserve reasoning
245-
const model = handler.getModel()
274+
const chatHandler = new DeepSeekHandler({
275+
...mockOptions,
276+
apiModelId: "deepseek-chat",
277+
})
278+
const model = chatHandler.getModel()
246279
// Cast to ModelInfo to access preserveReasoning which is an optional property
247280
expect((model.info as ModelInfo).preserveReasoning).toBeUndefined()
248281
})
@@ -252,13 +285,17 @@ describe("DeepSeekHandler", () => {
252285
...mockOptions,
253286
apiModelId: "invalid-model",
254287
})
288+
const defaultHandler = new DeepSeekHandler({
289+
...mockOptions,
290+
apiModelId: undefined,
291+
})
255292
const model = handlerWithInvalidModel.getModel()
256293
expect(model.id).toBe("invalid-model") // Returns provided ID
257294
expect(model.info).toBeDefined()
258295
// With the current implementation, it's the same object reference when using default model info
259-
expect(model.info).toBe(handler.getModel().info)
296+
expect(model.info).toBe(defaultHandler.getModel().info)
260297
// Should have the same base properties
261-
expect(model.info.contextWindow).toBe(handler.getModel().info.contextWindow)
298+
expect(model.info.contextWindow).toBe(defaultHandler.getModel().info.contextWindow)
262299
// And should have supportsPromptCache set to true
263300
expect(model.info.supportsPromptCache).toBe(true)
264301
})
@@ -457,6 +494,100 @@ describe("DeepSeekHandler", () => {
457494
}),
458495
{}, // Empty path options for non-Azure URLs
459496
)
497+
const callArgs = mockCreate.mock.calls[0][0]
498+
expect(callArgs.reasoning_effort).toBeUndefined()
499+
})
500+
501+
it("should enable thinking by default for deepseek-v4-flash", async () => {
502+
const v4Handler = new DeepSeekHandler({
503+
...mockOptions,
504+
apiModelId: "deepseek-v4-flash",
505+
})
506+
507+
const stream = v4Handler.createMessage(systemPrompt, messages)
508+
for await (const _chunk of stream) {
509+
// Consume the stream
510+
}
511+
512+
expect(mockCreate).toHaveBeenCalledWith(
513+
expect.objectContaining({
514+
thinking: { type: "enabled" },
515+
reasoning_effort: "high",
516+
max_completion_tokens: 200_000,
517+
}),
518+
{},
519+
)
520+
})
521+
522+
it("should respect user max token override for deepseek-v4 models", async () => {
523+
const v4Handler = new DeepSeekHandler({
524+
...mockOptions,
525+
apiModelId: "deepseek-v4-flash",
526+
modelMaxTokens: 32_000,
527+
})
528+
529+
const stream = v4Handler.createMessage(systemPrompt, messages)
530+
for await (const _chunk of stream) {
531+
// Consume the stream
532+
}
533+
534+
const callArgs = mockCreate.mock.calls[0][0]
535+
expect(callArgs.max_completion_tokens).toBe(32_000)
536+
})
537+
538+
it("should map xhigh reasoning effort to DeepSeek max effort", async () => {
539+
const v4Handler = new DeepSeekHandler({
540+
...mockOptions,
541+
apiModelId: "deepseek-v4-pro",
542+
reasoningEffort: "xhigh",
543+
})
544+
545+
const stream = v4Handler.createMessage(systemPrompt, messages)
546+
for await (const _chunk of stream) {
547+
// Consume the stream
548+
}
549+
550+
expect(mockCreate).toHaveBeenCalledWith(
551+
expect.objectContaining({
552+
thinking: { type: "enabled" },
553+
reasoning_effort: "max",
554+
}),
555+
{},
556+
)
557+
})
558+
559+
it("should disable thinking for deepseek-v4 models when reasoning is disabled", async () => {
560+
const v4Handler = new DeepSeekHandler({
561+
...mockOptions,
562+
apiModelId: "deepseek-v4-pro",
563+
enableReasoningEffort: false,
564+
})
565+
566+
const stream = v4Handler.createMessage(systemPrompt, messages)
567+
for await (const _chunk of stream) {
568+
// Consume the stream
569+
}
570+
571+
const callArgs = mockCreate.mock.calls[0][0]
572+
expect(callArgs.thinking).toEqual({ type: "disabled" })
573+
expect(callArgs.reasoning_effort).toBeUndefined()
574+
})
575+
576+
it("should not send V4 thinking parameters for unknown model IDs", async () => {
577+
const customHandler = new DeepSeekHandler({
578+
...mockOptions,
579+
apiModelId: "custom-deepseek-model",
580+
})
581+
582+
const stream = customHandler.createMessage(systemPrompt, messages)
583+
for await (const _chunk of stream) {
584+
// Consume the stream
585+
}
586+
587+
const callArgs = mockCreate.mock.calls[0][0]
588+
expect(callArgs.thinking).toBeUndefined()
589+
expect(callArgs.reasoning_effort).toBeUndefined()
590+
expect(callArgs.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE)
460591
})
461592

462593
it("should NOT pass thinking parameter for deepseek-chat model", async () => {

src/api/providers/deepseek.ts

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
deepSeekDefaultModelId,
77
DEEP_SEEK_DEFAULT_TEMPERATURE,
88
OPENAI_AZURE_AI_INFERENCE_PATH,
9+
type ModelInfo,
910
} from "@roo-code/types"
1011

1112
import type { ApiHandlerOptions } from "../../shared/api"
@@ -18,8 +19,45 @@ import { OpenAiHandler } from "./openai"
1819
import type { ApiHandlerCreateMessageMetadata } from "../index"
1920

2021
// Custom interface for DeepSeek params to support thinking mode
21-
type DeepSeekChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParamsStreaming & {
22+
type DeepSeekChatCompletionParams = Omit<OpenAI.Chat.ChatCompletionCreateParamsStreaming, "reasoning_effort"> & {
2223
thinking?: { type: "enabled" | "disabled" }
24+
reasoning_effort?: "high" | "max"
25+
}
26+
27+
const deepSeekV4ThinkingModels = new Set(["deepseek-v4-flash", "deepseek-v4-pro"])
28+
const supportsDeepSeekThinkingToggle = (modelId: string) => deepSeekV4ThinkingModels.has(modelId)
29+
30+
// Only known V4 models and the legacy reasoner alias support DeepSeek's
31+
// thinking fields. Custom model IDs still fall back to default metadata, but
32+
// should not receive V4-only request parameters.
33+
const isDeepSeekThinkingEnabled = (modelId: string, options: ApiHandlerOptions) => {
34+
if (options.enableReasoningEffort === false || options.reasoningEffort === "disable") {
35+
return false
36+
}
37+
38+
return modelId === "deepseek-reasoner" || supportsDeepSeekThinkingToggle(modelId)
39+
}
40+
41+
const normalizeDeepSeekReasoningEffort = (reasoningEffort?: string): "high" | "max" | undefined => {
42+
if (!reasoningEffort || reasoningEffort === "disable") {
43+
return undefined
44+
}
45+
46+
// DeepSeek currently maps low/medium to high and xhigh to max in thinking mode.
47+
return reasoningEffort === "xhigh" ? "max" : "high"
48+
}
49+
50+
// Use the computed maxTokens from getModelParams rather than raw model metadata.
51+
// V4 advertises a 384K maximum output, but the project convention caps most
52+
// models to 20% of context unless the user explicitly overrides modelMaxTokens.
53+
const addDeepSeekMaxTokensIfNeeded = (
54+
requestOptions: DeepSeekChatCompletionParams,
55+
options: ApiHandlerOptions,
56+
computedMaxTokens?: number,
57+
) => {
58+
if (options.includeMaxTokens === true) {
59+
requestOptions.max_completion_tokens = options.modelMaxTokens || computedMaxTokens
60+
}
2361
}
2462

2563
export class DeepSeekHandler extends OpenAiHandler {
@@ -53,14 +91,19 @@ export class DeepSeekHandler extends OpenAiHandler {
5391
metadata?: ApiHandlerCreateMessageMetadata,
5492
): ApiStream {
5593
const modelId = this.options.apiModelId ?? deepSeekDefaultModelId
56-
const { info: modelInfo } = this.getModel()
94+
const { info: modelInfo, temperature, reasoningEffort, maxTokens } = this.getModel()
5795

58-
// Check if this is a thinking-enabled model (deepseek-reasoner)
59-
const isThinkingModel = modelId.includes("deepseek-reasoner")
96+
const isThinkingModel = isDeepSeekThinkingEnabled(modelId, this.options)
97+
const thinking = supportsDeepSeekThinkingToggle(modelId)
98+
? ({ type: isThinkingModel ? "enabled" : "disabled" } as const)
99+
: isThinkingModel
100+
? ({ type: "enabled" } as const)
101+
: undefined
102+
const deepSeekReasoningEffort = isThinkingModel ? normalizeDeepSeekReasoningEffort(reasoningEffort) : undefined
60103

61104
// Convert messages to R1 format (merges consecutive same-role messages)
62105
// This is required for DeepSeek which does not support successive messages with the same role
63-
// For thinking models (deepseek-reasoner), enable mergeToolResultText to preserve reasoning_content
106+
// For thinking models, enable mergeToolResultText to preserve reasoning_content
64107
// during tool call sequences. Without this, environment_details text after tool_results would
65108
// create user messages that cause DeepSeek to drop all previous reasoning_content.
66109
// See: https://api-docs.deepseek.com/guides/thinking_mode
@@ -70,27 +113,26 @@ export class DeepSeekHandler extends OpenAiHandler {
70113

71114
const requestOptions: DeepSeekChatCompletionParams = {
72115
model: modelId,
73-
temperature: this.options.modelTemperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE,
116+
...(!isThinkingModel && { temperature: temperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE }),
74117
messages: convertedMessages,
75118
stream: true as const,
76119
stream_options: { include_usage: true },
77-
// Enable thinking mode for deepseek-reasoner or when tools are used with thinking model
78-
...(isThinkingModel && { thinking: { type: "enabled" } }),
120+
...(thinking && { thinking }),
121+
...(deepSeekReasoningEffort && { reasoning_effort: deepSeekReasoningEffort }),
79122
tools: this.convertToolsForOpenAI(metadata?.tools),
80123
tool_choice: metadata?.tool_choice,
81124
parallel_tool_calls: metadata?.parallelToolCalls ?? true,
82125
}
83126

84-
// Add max_tokens if needed
85-
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
127+
addDeepSeekMaxTokensIfNeeded(requestOptions, this.options, maxTokens)
86128

87129
// Check if base URL is Azure AI Inference (for DeepSeek via Azure)
88130
const isAzureAiInference = this._isAzureAiInference(this.options.deepSeekBaseUrl)
89131

90132
let stream
91133
try {
92134
stream = await this.client.chat.completions.create(
93-
requestOptions,
135+
requestOptions as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming,
94136
isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
95137
)
96138
} catch (error) {

0 commit comments

Comments
 (0)