This commit is contained in:
avaritia 2026-05-01 06:14:59 +00:00 • committed by GitHub
commit f9c6e94939
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 549 additions and 82 deletions

View 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.

View file

@ -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>

View file

@ -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[] = [

View file

@ -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,

View file

@ -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,
}
}

View file

@ -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,

View file

@ -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",
},
])(

View file

@ -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
}

View file

@ -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

View file

@ -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()