mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-31 06:54:35 +00:00
Merge 65e92304ce into ad25634905
This commit is contained in:
commit
f9c6e94939
10 changed files with 549 additions and 82 deletions
5
.changeset/deepseek-v4-models.md
Normal file
5
.changeset/deepseek-v4-models.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"roo-code": patch
|
||||
---
|
||||
|
||||
Add DeepSeek V4 Flash and Pro model definitions with thinking mode controls and a chat quick selector for reasoning effort.
|
||||
|
|
@ -6,31 +6,36 @@ import type { ModelInfo } from "../model.js"
|
|||
// continuation within the same turn. See: https://api-docs.deepseek.com/guides/thinking_mode
|
||||
export type DeepSeekModelId = keyof typeof deepSeekModels
|
||||
|
||||
export const deepSeekDefaultModelId: DeepSeekModelId = "deepseek-chat"
|
||||
export const deepSeekDefaultModelId: DeepSeekModelId = "deepseek-v4-flash"
|
||||
|
||||
export const deepSeekModels = {
|
||||
"deepseek-chat": {
|
||||
maxTokens: 8192, // 8K max output
|
||||
contextWindow: 128_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.28, // $0.28 per million tokens (cache miss) - Updated Dec 9, 2025
|
||||
outputPrice: 0.42, // $0.42 per million tokens - Updated Dec 9, 2025
|
||||
cacheWritesPrice: 0.28, // $0.28 per million tokens (cache miss) - Updated Dec 9, 2025
|
||||
cacheReadsPrice: 0.028, // $0.028 per million tokens (cache hit) - Updated Dec 9, 2025
|
||||
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).`,
|
||||
},
|
||||
"deepseek-reasoner": {
|
||||
maxTokens: 8192, // 8K max output
|
||||
contextWindow: 128_000,
|
||||
"deepseek-v4-flash": {
|
||||
maxTokens: 384_000, // 384K max output
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
preserveReasoning: true,
|
||||
inputPrice: 0.28, // $0.28 per million tokens (cache miss) - Updated Dec 9, 2025
|
||||
outputPrice: 0.42, // $0.42 per million tokens - Updated Dec 9, 2025
|
||||
cacheWritesPrice: 0.28, // $0.28 per million tokens (cache miss) - Updated Dec 9, 2025
|
||||
cacheReadsPrice: 0.028, // $0.028 per million tokens (cache hit) - Updated Dec 9, 2025
|
||||
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).`,
|
||||
supportsReasoningEffort: ["disable", "high", "xhigh"],
|
||||
reasoningEffort: "high",
|
||||
inputPrice: 0.14, // $0.14 per million tokens (cache miss) - Updated Apr 30, 2026
|
||||
outputPrice: 0.28, // $0.28 per million tokens - Updated Apr 30, 2026
|
||||
cacheWritesPrice: 0.14, // $0.14 per million tokens (cache miss) - Updated Apr 30, 2026
|
||||
cacheReadsPrice: 0.0028, // $0.0028 per million tokens (cache hit) - Updated Apr 30, 2026
|
||||
description: `DeepSeek-V4 Flash supports 1M context, 384K max output, tool calls, and thinking/non-thinking modes.`,
|
||||
},
|
||||
"deepseek-v4-pro": {
|
||||
maxTokens: 384_000, // 384K max output
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
preserveReasoning: true,
|
||||
supportsReasoningEffort: ["disable", "high", "xhigh"],
|
||||
reasoningEffort: "high",
|
||||
inputPrice: 0.435, // $0.435 per million tokens (cache miss, 75% off until May 31, 2026) - Updated Apr 30, 2026
|
||||
outputPrice: 0.87, // $0.87 per million tokens (75% off until May 31, 2026) - Updated Apr 30, 2026
|
||||
cacheWritesPrice: 0.435, // $0.435 per million tokens (cache miss, 75% off until May 31, 2026) - Updated Apr 30, 2026
|
||||
cacheReadsPrice: 0.003625, // $0.003625 per million tokens (cache hit, 75% off until May 31, 2026) - Updated Apr 30, 2026
|
||||
description: `DeepSeek-V4 Pro supports 1M context, 384K max output, tool calls, and thinking/non-thinking modes.`,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ vi.mock("openai", () => {
|
|||
}
|
||||
|
||||
// Check if this is a reasoning_content test by looking at model
|
||||
const isReasonerModel = options.model?.includes("deepseek-reasoner")
|
||||
const isReasonerModel = options.model?.includes("deepseek-v4")
|
||||
const isToolCallTest = options.tools?.length > 0
|
||||
|
||||
// Return async iterator for streaming
|
||||
|
|
@ -122,7 +122,7 @@ vi.mock("openai", () => {
|
|||
import OpenAI from "openai"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE, type ModelInfo } from "@roo-code/types"
|
||||
import { deepSeekDefaultModelId, deepSeekModels, DEEP_SEEK_DEFAULT_TEMPERATURE, type ModelInfo } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
|
|
@ -135,7 +135,7 @@ describe("DeepSeekHandler", () => {
|
|||
beforeEach(() => {
|
||||
mockOptions = {
|
||||
deepSeekApiKey: "test-api-key",
|
||||
apiModelId: "deepseek-chat",
|
||||
apiModelId: "deepseek-v4-flash",
|
||||
deepSeekBaseUrl: "https://api.deepseek.com",
|
||||
}
|
||||
handler = new DeepSeekHandler(mockOptions)
|
||||
|
|
@ -206,47 +206,48 @@ describe("DeepSeekHandler", () => {
|
|||
const model = handler.getModel()
|
||||
expect(model.id).toBe(mockOptions.apiModelId)
|
||||
expect(model.info).toBeDefined()
|
||||
expect(model.info.maxTokens).toBe(8192) // deepseek-chat has 8K max
|
||||
expect(model.info.contextWindow).toBe(128_000)
|
||||
expect(model.info.supportsImages).toBe(false)
|
||||
expect(model.info.supportsPromptCache).toBe(true) // Should be true now
|
||||
})
|
||||
|
||||
it("should return correct model info for deepseek-reasoner", () => {
|
||||
const handlerWithReasoner = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-reasoner",
|
||||
})
|
||||
const model = handlerWithReasoner.getModel()
|
||||
expect(model.id).toBe("deepseek-reasoner")
|
||||
expect(model.info).toBeDefined()
|
||||
expect(model.info.maxTokens).toBe(8192) // deepseek-reasoner has 8K max
|
||||
expect(model.info.contextWindow).toBe(128_000)
|
||||
expect(model.info.maxTokens).toBe(384_000)
|
||||
expect(model.info.contextWindow).toBe(1_000_000)
|
||||
expect(model.info.supportsImages).toBe(false)
|
||||
expect(model.info.supportsPromptCache).toBe(true)
|
||||
})
|
||||
|
||||
it("should have preserveReasoning enabled for deepseek-reasoner to support interleaved thinking", () => {
|
||||
it.each([
|
||||
["deepseek-v4-flash", deepSeekModels["deepseek-v4-flash"]],
|
||||
["deepseek-v4-pro", deepSeekModels["deepseek-v4-pro"]],
|
||||
])("should return correct model info for %s", (modelId, expectedInfo) => {
|
||||
const handlerWithV4 = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: modelId,
|
||||
})
|
||||
|
||||
const model = handlerWithV4.getModel()
|
||||
const info = model.info as ModelInfo
|
||||
|
||||
expect(model.id).toBe(modelId)
|
||||
expect(info.maxTokens).toBe(384_000)
|
||||
expect(info.contextWindow).toBe(1_000_000)
|
||||
expect(info.supportsPromptCache).toBe(true)
|
||||
expect(info.preserveReasoning).toBe(true)
|
||||
expect(info.supportsReasoningEffort).toEqual(["disable", "high", "xhigh"])
|
||||
expect(info.reasoningEffort).toBe("high")
|
||||
expect(model.info).toBe(expectedInfo)
|
||||
})
|
||||
|
||||
it("should have preserveReasoning enabled for DeepSeek V4 models to support interleaved thinking", () => {
|
||||
// This is critical for DeepSeek's interleaved thinking mode with tool calls.
|
||||
// See: https://api-docs.deepseek.com/guides/thinking_mode
|
||||
// The reasoning_content needs to be passed back during tool call continuation
|
||||
// within the same turn for the model to continue reasoning properly.
|
||||
const handlerWithReasoner = new DeepSeekHandler({
|
||||
const handlerWithV4 = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-reasoner",
|
||||
apiModelId: "deepseek-v4-pro",
|
||||
})
|
||||
const model = handlerWithReasoner.getModel()
|
||||
const model = handlerWithV4.getModel()
|
||||
// Cast to ModelInfo to access preserveReasoning which is an optional property
|
||||
expect((model.info as ModelInfo).preserveReasoning).toBe(true)
|
||||
})
|
||||
|
||||
it("should NOT have preserveReasoning enabled for deepseek-chat", () => {
|
||||
// deepseek-chat doesn't use thinking mode, so no need to preserve reasoning
|
||||
const model = handler.getModel()
|
||||
// Cast to ModelInfo to access preserveReasoning which is an optional property
|
||||
expect((model.info as ModelInfo).preserveReasoning).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return provided model ID with default model info if model does not exist", () => {
|
||||
const handlerWithInvalidModel = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
|
|
@ -419,10 +420,10 @@ describe("DeepSeekHandler", () => {
|
|||
},
|
||||
]
|
||||
|
||||
it("should handle reasoning_content in streaming responses for deepseek-reasoner", async () => {
|
||||
it("should handle reasoning_content in streaming responses for DeepSeek V4", async () => {
|
||||
const reasonerHandler = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-reasoner",
|
||||
apiModelId: "deepseek-v4-pro",
|
||||
})
|
||||
|
||||
const stream = reasonerHandler.createMessage(systemPrompt, messages)
|
||||
|
|
@ -438,10 +439,55 @@ describe("DeepSeekHandler", () => {
|
|||
expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.")
|
||||
})
|
||||
|
||||
it("should pass thinking parameter for deepseek-reasoner model", async () => {
|
||||
it("should preserve empty reasoning_content chunks for DeepSeek V4", async () => {
|
||||
mockCreate.mockImplementationOnce(async () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { reasoning_content: "" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const reasonerHandler = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-reasoner",
|
||||
apiModelId: "deepseek-v4-pro",
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of reasonerHandler.createMessage(systemPrompt, messages)) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks.filter((chunk) => chunk.type === "reasoning")).toContainEqual({
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
})
|
||||
})
|
||||
|
||||
it("should pass thinking parameter for DeepSeek V4 models", async () => {
|
||||
const reasonerHandler = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-v4-pro",
|
||||
})
|
||||
|
||||
const stream = reasonerHandler.createMessage(systemPrompt, messages)
|
||||
|
|
@ -459,26 +505,64 @@ describe("DeepSeekHandler", () => {
|
|||
)
|
||||
})
|
||||
|
||||
it("should NOT pass thinking parameter for deepseek-chat model", async () => {
|
||||
const chatHandler = new DeepSeekHandler({
|
||||
it("should pass reasoning parameters for DeepSeek V4 models", async () => {
|
||||
const v4Handler = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-chat",
|
||||
apiModelId: "deepseek-v4-flash",
|
||||
enableReasoningEffort: true,
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
|
||||
const stream = chatHandler.createMessage(systemPrompt, messages)
|
||||
const stream = v4Handler.createMessage(systemPrompt, messages)
|
||||
for await (const _chunk of stream) {
|
||||
// Consume the stream
|
||||
}
|
||||
|
||||
// Verify that the thinking parameter was NOT passed to the API
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs.thinking).toBeUndefined()
|
||||
expect(callArgs.thinking).toEqual({ type: "enabled" })
|
||||
expect(callArgs.reasoning_effort).toBe("high")
|
||||
})
|
||||
|
||||
it("should map xhigh reasoning effort to DeepSeek max", async () => {
|
||||
const v4Handler = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-v4-pro",
|
||||
enableReasoningEffort: true,
|
||||
reasoningEffort: "xhigh",
|
||||
})
|
||||
|
||||
const stream = v4Handler.createMessage(systemPrompt, messages)
|
||||
for await (const _chunk of stream) {
|
||||
// Consume the stream
|
||||
}
|
||||
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs.thinking).toEqual({ type: "enabled" })
|
||||
expect(callArgs.reasoning_effort).toBe("max")
|
||||
})
|
||||
|
||||
it("should disable thinking for DeepSeek V4 when reasoning effort is disabled", async () => {
|
||||
const v4Handler = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-v4-flash",
|
||||
enableReasoningEffort: true,
|
||||
reasoningEffort: "disable",
|
||||
})
|
||||
|
||||
const stream = v4Handler.createMessage(systemPrompt, messages)
|
||||
for await (const _chunk of stream) {
|
||||
// Consume the stream
|
||||
}
|
||||
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs.thinking).toEqual({ type: "disabled" })
|
||||
expect(callArgs.reasoning_effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should handle tool calls with reasoning_content", async () => {
|
||||
const reasonerHandler = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-reasoner",
|
||||
apiModelId: "deepseek-v4-pro",
|
||||
})
|
||||
|
||||
const tools: any[] = [
|
||||
|
|
|
|||
|
|
@ -391,6 +391,129 @@ describe("OpenAiHandler", () => {
|
|||
expect(callArgs.reasoning_effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should include DeepSeek V4 thinking and high reasoning effort for OpenAI-compatible endpoints", async () => {
|
||||
const deepSeekV4Options: ApiHandlerOptions = {
|
||||
...mockOptions,
|
||||
openAiBaseUrl: "https://api.deepseek.com",
|
||||
openAiModelId: "deepseek-v4-flash",
|
||||
enableReasoningEffort: true,
|
||||
reasoningEffort: "high",
|
||||
openAiCustomModelInfo: {
|
||||
contextWindow: 1_000_000,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoningEffort: ["disable", "high", "xhigh"],
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
}
|
||||
const deepSeekV4Handler = new OpenAiHandler(deepSeekV4Options)
|
||||
const stream = deepSeekV4Handler.createMessage(systemPrompt, messages)
|
||||
for await (const _chunk of stream) {
|
||||
}
|
||||
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs.thinking).toEqual({ type: "enabled" })
|
||||
expect(callArgs.reasoning_effort).toBe("high")
|
||||
})
|
||||
|
||||
it("should map DeepSeek V4 xhigh reasoning effort to max for OpenAI-compatible endpoints", async () => {
|
||||
const deepSeekV4Options: ApiHandlerOptions = {
|
||||
...mockOptions,
|
||||
openAiBaseUrl: "https://api.deepseek.com",
|
||||
openAiModelId: "deepseek-v4-pro",
|
||||
enableReasoningEffort: true,
|
||||
reasoningEffort: "xhigh",
|
||||
openAiCustomModelInfo: {
|
||||
contextWindow: 1_000_000,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoningEffort: ["disable", "high", "xhigh"],
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
}
|
||||
const deepSeekV4Handler = new OpenAiHandler(deepSeekV4Options)
|
||||
const stream = deepSeekV4Handler.createMessage(systemPrompt, messages)
|
||||
for await (const _chunk of stream) {
|
||||
}
|
||||
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs.thinking).toEqual({ type: "enabled" })
|
||||
expect(callArgs.reasoning_effort).toBe("max")
|
||||
})
|
||||
|
||||
it("should disable DeepSeek V4 thinking for OpenAI-compatible endpoints", async () => {
|
||||
const deepSeekV4Options: ApiHandlerOptions = {
|
||||
...mockOptions,
|
||||
openAiBaseUrl: "https://api.deepseek.com",
|
||||
openAiModelId: "deepseek-v4-flash",
|
||||
enableReasoningEffort: true,
|
||||
reasoningEffort: "disable",
|
||||
openAiCustomModelInfo: {
|
||||
contextWindow: 1_000_000,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoningEffort: ["disable", "high", "xhigh"],
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
}
|
||||
const deepSeekV4Handler = new OpenAiHandler(deepSeekV4Options)
|
||||
const stream = deepSeekV4Handler.createMessage(systemPrompt, messages)
|
||||
for await (const _chunk of stream) {
|
||||
}
|
||||
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs.thinking).toEqual({ type: "disabled" })
|
||||
expect(callArgs.reasoning_effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should preserve empty DeepSeek V4 reasoning_content chunks for OpenAI-compatible endpoints", async () => {
|
||||
mockCreate.mockImplementationOnce(async () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { reasoning_content: "" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const deepSeekV4Handler = new OpenAiHandler({
|
||||
...mockOptions,
|
||||
openAiBaseUrl: "https://api.deepseek.com",
|
||||
openAiModelId: "deepseek-v4-flash",
|
||||
openAiCustomModelInfo: {
|
||||
contextWindow: 1_000_000,
|
||||
supportsPromptCache: true,
|
||||
preserveReasoning: true,
|
||||
},
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of deepSeekV4Handler.createMessage(systemPrompt, messages)) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks.filter((chunk) => chunk.type === "reasoning")).toContainEqual({
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
})
|
||||
})
|
||||
|
||||
it("should include max_tokens when includeMaxTokens is true", async () => {
|
||||
const optionsWithMaxTokens: ApiHandlerOptions = {
|
||||
...mockOptions,
|
||||
|
|
|
|||
|
|
@ -53,16 +53,24 @@ export class DeepSeekHandler extends OpenAiHandler {
|
|||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const modelId = this.options.apiModelId ?? deepSeekDefaultModelId
|
||||
const { info: modelInfo } = this.getModel()
|
||||
const { info: modelInfo, reasoning } = this.getModel()
|
||||
|
||||
// Check if this is a thinking-enabled model (deepseek-reasoner)
|
||||
const isThinkingModel = modelId.includes("deepseek-reasoner")
|
||||
const isDeepSeekV4Model = modelId.includes("deepseek-v4")
|
||||
const isThinkingModel = isDeepSeekV4Model
|
||||
const thinkingType =
|
||||
isDeepSeekV4Model &&
|
||||
(this.options.reasoningEffort === "disable" || this.options.enableReasoningEffort === false)
|
||||
? "disabled"
|
||||
: "enabled"
|
||||
const selectedReasoningEffort = (reasoning as { reasoning_effort?: string } | undefined)?.reasoning_effort
|
||||
const reasoningEffort =
|
||||
selectedReasoningEffort === "xhigh" ? "max" : selectedReasoningEffort === "high" ? "high" : undefined
|
||||
|
||||
// Convert messages to R1 format (merges consecutive same-role messages)
|
||||
// This is required for DeepSeek which does not support successive messages with the same role
|
||||
// For thinking models (deepseek-reasoner), enable mergeToolResultText to preserve reasoning_content
|
||||
// during tool call sequences. Without this, environment_details text after tool_results would
|
||||
// create user messages that cause DeepSeek to drop all previous reasoning_content.
|
||||
// For thinking models, enable mergeToolResultText to preserve reasoning_content during tool call
|
||||
// sequences. Without this, environment_details text after tool_results would create user messages
|
||||
// that cause DeepSeek to drop all previous reasoning_content.
|
||||
// See: https://api-docs.deepseek.com/guides/thinking_mode
|
||||
const convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages], {
|
||||
mergeToolResultText: isThinkingModel,
|
||||
|
|
@ -74,8 +82,12 @@ export class DeepSeekHandler extends OpenAiHandler {
|
|||
messages: convertedMessages,
|
||||
stream: true as const,
|
||||
stream_options: { include_usage: true },
|
||||
// Enable thinking mode for deepseek-reasoner or when tools are used with thinking model
|
||||
...(isThinkingModel && { thinking: { type: "enabled" } }),
|
||||
// Enable thinking mode for DeepSeek reasoning models. DeepSeek V4 also supports explicit disabling.
|
||||
...(isThinkingModel && { thinking: { type: thinkingType } }),
|
||||
...(reasoningEffort && {
|
||||
// DeepSeek accepts "max"; the OpenAI SDK type has not caught up yet.
|
||||
reasoning_effort: reasoningEffort as OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"],
|
||||
}),
|
||||
tools: this.convertToolsForOpenAI(metadata?.tools),
|
||||
tool_choice: metadata?.tool_choice,
|
||||
parallel_tool_calls: metadata?.parallelToolCalls ?? true,
|
||||
|
|
@ -113,10 +125,10 @@ export class DeepSeekHandler extends OpenAiHandler {
|
|||
|
||||
// Handle reasoning_content from DeepSeek's interleaved thinking
|
||||
// This is the proper way DeepSeek sends thinking content in streaming
|
||||
if ("reasoning_content" in delta && delta.reasoning_content) {
|
||||
if ("reasoning_content" in delta && typeof delta.reasoning_content === "string") {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: (delta.reasoning_content as string) || "",
|
||||
text: delta.reasoning_content,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,22 @@ import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from ".
|
|||
import { getApiRequestTimeout } from "./utils/timeout-config"
|
||||
import { handleOpenAIError } from "./utils/openai-error-handler"
|
||||
|
||||
type OpenAiCompatibleChatCompletionParamsStreaming = Omit<
|
||||
OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming,
|
||||
"reasoning_effort"
|
||||
> & {
|
||||
thinking?: { type: "enabled" | "disabled" }
|
||||
reasoning_effort?: OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"]
|
||||
}
|
||||
|
||||
type OpenAiCompatibleChatCompletionParamsNonStreaming = Omit<
|
||||
OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming,
|
||||
"reasoning_effort"
|
||||
> & {
|
||||
thinking?: { type: "enabled" | "disabled" }
|
||||
reasoning_effort?: OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"]
|
||||
}
|
||||
|
||||
// TODO: Rename this to OpenAICompatibleHandler. Also, I think the
|
||||
// `OpenAINativeHandler` can subclass from this, since it's obviously
|
||||
// compatible with the OpenAI API. We can also rename it to `OpenAIHandler`.
|
||||
|
|
@ -89,7 +105,22 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
const modelId = this.options.openAiModelId ?? ""
|
||||
const enabledR1Format = this.options.openAiR1FormatEnabled ?? false
|
||||
const isAzureAiInference = this._isAzureAiInference(modelUrl)
|
||||
const deepseekReasoner = modelId.includes("deepseek-reasoner") || enabledR1Format
|
||||
const isDeepSeekV4Model = modelId.includes("deepseek-v4")
|
||||
const usesDeepSeekThinkingParam = isDeepSeekV4Model
|
||||
const deepseekReasoner = usesDeepSeekThinkingParam || enabledR1Format
|
||||
const deepseekThinkingType =
|
||||
isDeepSeekV4Model &&
|
||||
(this.options.reasoningEffort === "disable" || this.options.enableReasoningEffort === false)
|
||||
? "disabled"
|
||||
: "enabled"
|
||||
const selectedReasoningEffort = (reasoning as { reasoning_effort?: string } | undefined)?.reasoning_effort
|
||||
const providerReasoning =
|
||||
isDeepSeekV4Model && selectedReasoningEffort === "xhigh"
|
||||
? {
|
||||
// DeepSeek accepts "max"; the OpenAI SDK type has not caught up yet.
|
||||
reasoning_effort: "max" as OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"],
|
||||
}
|
||||
: reasoning
|
||||
|
||||
if (modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4")) {
|
||||
yield* this.handleO3FamilyMessage(modelId, systemPrompt, messages, metadata)
|
||||
|
|
@ -152,13 +183,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
|
||||
const isGrokXAI = this._isGrokXAI(this.options.openAiBaseUrl)
|
||||
|
||||
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
|
||||
const requestOptions: OpenAiCompatibleChatCompletionParamsStreaming = {
|
||||
model: modelId,
|
||||
temperature: this.options.modelTemperature ?? (deepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
|
||||
messages: convertedMessages,
|
||||
stream: true as const,
|
||||
...(isGrokXAI ? {} : { stream_options: { include_usage: true } }),
|
||||
...(reasoning && reasoning),
|
||||
...(usesDeepSeekThinkingParam && { thinking: { type: deepseekThinkingType } }),
|
||||
...(providerReasoning && providerReasoning),
|
||||
tools: this.convertToolsForOpenAI(metadata?.tools),
|
||||
tool_choice: metadata?.tool_choice,
|
||||
parallel_tool_calls: metadata?.parallelToolCalls ?? true,
|
||||
|
|
@ -199,10 +231,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
}
|
||||
}
|
||||
|
||||
if ("reasoning_content" in delta && delta.reasoning_content) {
|
||||
if ("reasoning_content" in delta && typeof delta.reasoning_content === "string") {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: (delta.reasoning_content as string | undefined) || "",
|
||||
text: delta.reasoning_content,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -221,11 +253,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
yield this.processUsageMetrics(lastUsage, modelInfo)
|
||||
}
|
||||
} else {
|
||||
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
|
||||
const requestOptions: OpenAiCompatibleChatCompletionParamsNonStreaming = {
|
||||
model: modelId,
|
||||
messages: deepseekReasoner
|
||||
? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
: [systemMessage, ...convertToOpenAiMessages(messages)],
|
||||
...(usesDeepSeekThinkingParam && { thinking: { type: deepseekThinkingType } }),
|
||||
...(providerReasoning && providerReasoning),
|
||||
// Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS)
|
||||
tools: this.convertToolsForOpenAI(metadata?.tools),
|
||||
tool_choice: metadata?.tool_choice,
|
||||
|
|
|
|||
|
|
@ -2197,7 +2197,7 @@ describe("importExport", () => {
|
|||
{
|
||||
testCase: "supportsReasoningBudget is false",
|
||||
providerName: "deepseek-provider",
|
||||
modelId: "deepseek-chat",
|
||||
modelId: "deepseek-v4-flash",
|
||||
providerId: "deepseek-id",
|
||||
},
|
||||
{
|
||||
|
|
@ -2209,7 +2209,7 @@ describe("importExport", () => {
|
|||
{
|
||||
testCase: "both supportsReasoningBudget and requiredReasoningBudget are false",
|
||||
providerName: "deepseek-provider-3",
|
||||
modelId: "deepseek-reasoner",
|
||||
modelId: "deepseek-v4-pro",
|
||||
providerId: "deepseek-id-3",
|
||||
},
|
||||
])(
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export const shouldUseReasoningEffort = ({
|
|||
| "low"
|
||||
| "medium"
|
||||
| "high"
|
||||
| "xhigh"
|
||||
| undefined
|
||||
|
||||
// "disable" explicitly omits reasoning
|
||||
|
|
@ -92,6 +93,7 @@ export const shouldUseReasoningEffort = ({
|
|||
| "low"
|
||||
| "medium"
|
||||
| "high"
|
||||
| "xhigh"
|
||||
| undefined
|
||||
return !!modelDefaultEffort
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
|
||||
import { useEvent } from "react-use"
|
||||
import DynamicTextArea from "react-textarea-autosize"
|
||||
import { VolumeX, Image, WandSparkles, SendHorizontal, X, ListEnd, Square } from "lucide-react"
|
||||
import { VolumeX, Image, WandSparkles, SendHorizontal, X, ListEnd, Square, Brain } from "lucide-react"
|
||||
|
||||
import type { ExtensionMessage } from "@roo-code/types"
|
||||
import { reasoningEfforts, type ExtensionMessage, type ModelInfo, type ProviderSettings } from "@roo-code/types"
|
||||
|
||||
import { mentionRegex, mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "@roo/context-mentions"
|
||||
import { WebviewMessage } from "@roo/WebviewMessage"
|
||||
|
|
@ -22,7 +22,8 @@ import {
|
|||
} from "@src/utils/context-mentions"
|
||||
import { cn } from "@src/lib/utils"
|
||||
import { convertToMentionPath } from "@src/utils/path-mentions"
|
||||
import { StandardTooltip } from "@src/components/ui"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, StandardTooltip } from "@src/components/ui"
|
||||
import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel"
|
||||
|
||||
import Thumbnails from "../common/Thumbnails"
|
||||
import { ModeSelector } from "./ModeSelector"
|
||||
|
|
@ -34,6 +35,146 @@ import { IndexingStatusBadge } from "./IndexingStatusBadge"
|
|||
import { usePromptHistory } from "./hooks/usePromptHistory"
|
||||
import { CloudAccountSwitcher } from "../cloud/CloudAccountSwitcher"
|
||||
|
||||
type ReasoningEffortOption = "disable" | "none" | "minimal" | "low" | "medium" | "high" | "xhigh"
|
||||
|
||||
interface ReasoningEffortSelectorProps {
|
||||
apiConfiguration?: ProviderSettings
|
||||
currentApiConfigName?: string
|
||||
modelId?: string
|
||||
modelInfo?: ModelInfo
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ReasoningEffortSelector = ({
|
||||
apiConfiguration,
|
||||
currentApiConfigName,
|
||||
modelId,
|
||||
modelInfo,
|
||||
disabled,
|
||||
}: ReasoningEffortSelectorProps) => {
|
||||
const { t } = useAppTranslation()
|
||||
|
||||
const supports = modelInfo?.supportsReasoningEffort
|
||||
const isSupported = !!supports
|
||||
|
||||
const availableOptions = useMemo<ReadonlyArray<ReasoningEffortOption>>(() => {
|
||||
if (!isSupported) {
|
||||
return []
|
||||
}
|
||||
|
||||
const baseOptions =
|
||||
supports === true
|
||||
? (reasoningEfforts as readonly ReasoningEffortOption[])
|
||||
: Array.isArray(supports)
|
||||
? (supports as ReadonlyArray<ReasoningEffortOption>)
|
||||
: (reasoningEfforts as readonly ReasoningEffortOption[])
|
||||
|
||||
if (!modelInfo?.requiredReasoningEffort && supports === true && !baseOptions.includes("disable")) {
|
||||
return ["disable", ...baseOptions]
|
||||
}
|
||||
|
||||
return baseOptions
|
||||
}, [isSupported, modelInfo?.requiredReasoningEffort, supports])
|
||||
|
||||
const currentReasoningEffort = useMemo<ReasoningEffortOption>(() => {
|
||||
const storedReasoningEffort = apiConfiguration?.reasoningEffort as ReasoningEffortOption | undefined
|
||||
const defaultReasoningEffort = modelInfo?.requiredReasoningEffort
|
||||
? ((modelInfo.reasoningEffort as ReasoningEffortOption | undefined) ?? "medium")
|
||||
: "disable"
|
||||
|
||||
if (storedReasoningEffort && availableOptions.includes(storedReasoningEffort)) {
|
||||
return storedReasoningEffort
|
||||
}
|
||||
|
||||
if (defaultReasoningEffort && availableOptions.includes(defaultReasoningEffort)) {
|
||||
return defaultReasoningEffort
|
||||
}
|
||||
|
||||
return availableOptions[0] ?? "disable"
|
||||
}, [
|
||||
apiConfiguration?.reasoningEffort,
|
||||
availableOptions,
|
||||
modelInfo?.reasoningEffort,
|
||||
modelInfo?.requiredReasoningEffort,
|
||||
])
|
||||
|
||||
const configuredModelId = apiConfiguration?.apiModelId ?? apiConfiguration?.openAiModelId ?? modelId
|
||||
const isDeepSeekV4Model = configuredModelId?.startsWith("deepseek-v4-") ?? false
|
||||
|
||||
const getOptionLabel = useCallback(
|
||||
(value: ReasoningEffortOption) => {
|
||||
if (isDeepSeekV4Model) {
|
||||
if (value === "disable" || value === "none") {
|
||||
return "off"
|
||||
}
|
||||
|
||||
if (value === "xhigh") {
|
||||
return "max"
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
return value === "none" || value === "disable"
|
||||
? t("settings:providers.reasoningEffort.none")
|
||||
: t(`settings:providers.reasoningEffort.${value}`)
|
||||
},
|
||||
[isDeepSeekV4Model, t],
|
||||
)
|
||||
|
||||
const handleReasoningEffortChange = useCallback(
|
||||
(value: ReasoningEffortOption) => {
|
||||
if (!apiConfiguration || !currentApiConfigName) {
|
||||
return
|
||||
}
|
||||
|
||||
const updatedConfiguration: ProviderSettings = {
|
||||
...apiConfiguration,
|
||||
enableReasoningEffort: value !== "disable",
|
||||
reasoningEffort: value,
|
||||
}
|
||||
|
||||
vscode.postMessage({
|
||||
type: "upsertApiConfiguration",
|
||||
text: currentApiConfigName,
|
||||
apiConfiguration: updatedConfiguration,
|
||||
})
|
||||
},
|
||||
[apiConfiguration, currentApiConfigName],
|
||||
)
|
||||
|
||||
if (!isSupported || availableOptions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0" data-testid="quick-reasoning-effort-selector">
|
||||
<Select
|
||||
value={currentReasoningEffort}
|
||||
onValueChange={(value) => handleReasoningEffortChange(value as ReasoningEffortOption)}
|
||||
disabled={disabled}>
|
||||
<SelectTrigger
|
||||
aria-label={t("settings:providers.reasoningEffort.label")}
|
||||
className={cn(
|
||||
"h-[24px] min-w-[56px] max-w-[150px] rounded-md px-1.5 py-1 text-xs gap-1 bg-transparent",
|
||||
"border-[rgba(255,255,255,0.08)] hover:bg-[rgba(255,255,255,0.03)]",
|
||||
"[&_svg]:size-3.5",
|
||||
)}>
|
||||
<Brain className="size-3.5 flex-shrink-0 opacity-70" />
|
||||
<SelectValue>{getOptionLabel(currentReasoningEffort)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={4} className="min-w-[150px]">
|
||||
{availableOptions.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{getOptionLabel(value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ChatTextAreaProps {
|
||||
inputValue: string
|
||||
setInputValue: (value: string) => void
|
||||
|
|
@ -99,7 +240,9 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
cloudUserInfo,
|
||||
enterBehavior,
|
||||
lockApiConfigAcrossModes,
|
||||
apiConfiguration,
|
||||
} = useExtensionState()
|
||||
const { id: selectedModelId, info: selectedModelInfo } = useSelectedModel(apiConfiguration)
|
||||
|
||||
// Find the ID and display text for the currently selected API configuration.
|
||||
const { currentConfigId, displayName } = useMemo(() => {
|
||||
|
|
@ -1320,6 +1463,13 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
lockApiConfigAcrossModes={!!lockApiConfigAcrossModes}
|
||||
onToggleLockApiConfig={handleToggleLockApiConfig}
|
||||
/>
|
||||
<ReasoningEffortSelector
|
||||
apiConfiguration={apiConfiguration}
|
||||
currentApiConfigName={currentApiConfigName}
|
||||
modelId={selectedModelId}
|
||||
modelInfo={selectedModelInfo}
|
||||
disabled={selectApiConfigDisabled}
|
||||
/>
|
||||
<AutoApproveDropdown triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink" />
|
||||
</div>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { render, fireEvent, screen } from "@src/utils/test-utils"
|
|||
import { useExtensionState } from "@src/context/ExtensionStateContext"
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
import * as pathMentions from "@src/utils/path-mentions"
|
||||
import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel"
|
||||
|
||||
import { ChatTextArea } from "../ChatTextArea"
|
||||
|
||||
|
|
@ -32,6 +33,13 @@ const mockConvertToMentionPath = pathMentions.convertToMentionPath as ReturnType
|
|||
|
||||
// Mock ExtensionStateContext
|
||||
vi.mock("@src/context/ExtensionStateContext")
|
||||
vi.mock("@src/components/ui/hooks/useSelectedModel")
|
||||
|
||||
beforeAll(() => {
|
||||
if (!HTMLElement.prototype.hasPointerCapture) {
|
||||
HTMLElement.prototype.hasPointerCapture = vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
// Custom query function to get the enhance prompt button
|
||||
const getEnhancePromptButton = () => {
|
||||
|
|
@ -73,6 +81,7 @@ describe("ChatTextArea", () => {
|
|||
taskHistory: [],
|
||||
cwd: "/test/workspace",
|
||||
})
|
||||
;(useSelectedModel as ReturnType<typeof vi.fn>).mockReturnValue({ id: "claude-test", info: undefined })
|
||||
})
|
||||
|
||||
describe("enhance prompt button", () => {
|
||||
|
|
@ -182,6 +191,49 @@ describe("ChatTextArea", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("quick reasoning effort selector", () => {
|
||||
it("does not render when the selected model does not support reasoning effort", () => {
|
||||
render(<ChatTextArea {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByTestId("quick-reasoning-effort-selector")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders when the selected model supports reasoning effort", () => {
|
||||
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
filePaths: [],
|
||||
openedTabs: [],
|
||||
apiConfiguration: {
|
||||
apiProvider: "deepseek",
|
||||
apiModelId: "deepseek-v4-pro",
|
||||
reasoningEffort: "xhigh",
|
||||
enableReasoningEffort: true,
|
||||
},
|
||||
currentApiConfigName: "DeepSeek",
|
||||
listApiConfigMeta: [{ id: "deepseek", name: "DeepSeek", modelId: "deepseek-v4-pro" }],
|
||||
taskHistory: [],
|
||||
cwd: "/test/workspace",
|
||||
})
|
||||
;(useSelectedModel as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
id: "deepseek-v4-pro",
|
||||
info: {
|
||||
contextWindow: 1_000_000,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoningEffort: ["disable", "high", "xhigh"],
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
})
|
||||
|
||||
render(<ChatTextArea {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId("quick-reasoning-effort-selector")).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "settings:providers.reasoningEffort.label" }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText("max")).toBeInTheDocument()
|
||||
expect(screen.queryByText("settings:providers.reasoningEffort.xhigh")).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("enhanced prompt response", () => {
|
||||
it("should update input value using native browser methods when receiving enhanced prompt", () => {
|
||||
const setInputValue = vi.fn()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue