diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index d68dc49bed..43aefe7117 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -4,6 +4,7 @@ import { ApiHandler } from "../" import { ApiHandlerOptions, DeepSeekModelId, ModelInfo, deepSeekDefaultModelId, deepSeekModels } from "../../shared/api" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" +import { convertToR1Format } from "../transform/r1-format" export class DeepSeekHandler implements ApiHandler { private options: ApiHandlerOptions @@ -19,10 +20,22 @@ export class DeepSeekHandler implements ApiHandler { async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { const model = this.getModel() + + const isDeepseekReasoner = model.id.includes("deepseek-reasoner") + + let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages), + ] + + if (isDeepseekReasoner) { + openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) + } + const stream = await this.client.chat.completions.create({ model: model.id, max_completion_tokens: model.info.maxTokens, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], + messages: openAiMessages, stream: true, stream_options: { include_usage: true }, // Only set temperature for non-reasoner models @@ -38,6 +51,13 @@ export class DeepSeekHandler implements ApiHandler { } } + if ("reasoning_content" in delta && delta.reasoning_content) { + yield { + type: "reasoning", + reasoning: (delta.reasoning_content as string | undefined) || "", + } + } + if (chunk.usage) { yield { type: "usage", diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 58e4ba0250..e70273041f 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -4,6 +4,7 @@ import { ApiHandlerOptions, azureOpenAiDefaultApiVersion, ModelInfo, openAiModel import { ApiHandler } from "../index" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" +import { convertToR1Format } from "../transform/r1-format" export class OpenAiHandler implements ApiHandler { private options: ApiHandlerOptions @@ -27,12 +28,20 @@ export class OpenAiHandler implements ApiHandler { } async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { - const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + const modelId = this.options.openAiModelId ?? "" + const isDeepseekReasoner = modelId.includes("deepseek-reasoner") + + let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ { role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages), ] + + if (isDeepseekReasoner) { + openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) + } + const stream = await this.client.chat.completions.create({ - model: this.options.openAiModelId ?? "", + model: modelId, messages: openAiMessages, temperature: 0, stream: true, @@ -46,6 +55,14 @@ export class OpenAiHandler implements ApiHandler { text: delta.content, } } + + if ("reasoning_content" in delta && delta.reasoning_content) { + yield { + type: "reasoning", + reasoning: (delta.reasoning_content as string | undefined) || "", + } + } + if (chunk.usage) { yield { type: "usage", diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index e0bec2cf1c..34129c0d1a 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -1,11 +1,12 @@ import { Anthropic } from "@anthropic-ai/sdk" import axios from "axios" +import delay from "delay" import OpenAI from "openai" import { ApiHandler } from "../" import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" -import delay from "delay" +import { convertToR1Format } from "../transform/r1-format" export class OpenRouterHandler implements ApiHandler { private options: ApiHandlerOptions @@ -27,7 +28,7 @@ export class OpenRouterHandler implements ApiHandler { const model = this.getModel() // Convert Anthropic messages to OpenAI format - const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ { role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages), ] @@ -98,6 +99,18 @@ export class OpenRouterHandler implements ApiHandler { break } + let temperature = 0 + let topP: number | undefined = undefined + // Handle models based on deepseek-r1 + if (this.getModel().id.startsWith("deepseek/deepseek-r1") || this.getModel().id === "perplexity/sonar-reasoning") { + // Recommended temperature for DeepSeek reasoning models + temperature = 0.6 + // DeepSeek highly recommends using user instead of system role + openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) + // Some provider support topP and 0.95 is value that Deepseek used in their benchmarks + topP = 0.95 + } + // Removes messages in the middle when close to context window limit. Should not be applied to models that support prompt caching since it would continuously break the cache. let shouldApplyMiddleOutTransform = !model.info.supportsPromptCache // except for deepseek (which we set supportsPromptCache to true for), where because the context window is so small our truncation algo might miss and we should use openrouter's middle-out transform as a fallback to ensure we don't exceed the context window (FIXME: once we have a more robust token estimator we should not rely on this) @@ -105,14 +118,18 @@ export class OpenRouterHandler implements ApiHandler { shouldApplyMiddleOutTransform = true } + const isDeepSeekR1 = model.id === "deepseek/deepseek-r1" || model.id.startsWith("deepseek/deepseek-r1:") + // @ts-ignore-next-line const stream = await this.client.chat.completions.create({ model: model.id, max_tokens: maxTokens, - temperature: 0, + temperature: temperature, + top_p: topP, messages: openAiMessages, stream: true, transforms: shouldApplyMiddleOutTransform ? ["middle-out"] : undefined, + include_reasoning: true, }) let genId: string | undefined @@ -136,6 +153,37 @@ export class OpenRouterHandler implements ApiHandler { text: delta.content, } } + + // Reasoning tokens are returned separately from the content + if ("reasoning" in delta && delta.reasoning) { + // console.log("reasoning", delta.reasoning) + yield { + type: "reasoning", + // @ts-ignore-next-line + reasoning: delta.reasoning, + } + + // if (didStreamThinkTagInReasoning) { + // yield { + // type: "text", + // // @ts-ignore-next-line + // text: delta.reasoning, + // } + // } else { + // yield { + // type: "reasoning", + // // @ts-ignore-next-line + // text: delta.reasoning, + // } + + // // @ts-ignore-next-line + // reasoningResponse += delta.reasoning + // if (reasoningResponse.includes("")) { + // didStreamThinkTagInReasoning = true + // console.log("did hit think tag", reasoningResponse) + // } + // } + } // if (chunk.usage) { // yield { // type: "usage", @@ -178,9 +226,6 @@ export class OpenRouterHandler implements ApiHandler { if (modelId && modelInfo) { return { id: modelId, info: modelInfo } } - return { - id: openRouterDefaultModelId, - info: openRouterDefaultModelInfo, - } + return { id: openRouterDefaultModelId, info: openRouterDefaultModelInfo } } } diff --git a/src/api/transform/r1-format.ts b/src/api/transform/r1-format.ts new file mode 100644 index 0000000000..51a4b94dbc --- /dev/null +++ b/src/api/transform/r1-format.ts @@ -0,0 +1,98 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +type ContentPartText = OpenAI.Chat.ChatCompletionContentPartText +type ContentPartImage = OpenAI.Chat.ChatCompletionContentPartImage +type UserMessage = OpenAI.Chat.ChatCompletionUserMessageParam +type AssistantMessage = OpenAI.Chat.ChatCompletionAssistantMessageParam +type Message = OpenAI.Chat.ChatCompletionMessageParam +type AnthropicMessage = Anthropic.Messages.MessageParam + +/** + * Converts Anthropic messages to OpenAI format while merging consecutive messages with the same role. + * This is required for DeepSeek Reasoner which does not support successive messages with the same role. + * + * @param messages Array of Anthropic messages + * @returns Array of OpenAI messages where consecutive messages with the same role are combined + */ +export function convertToR1Format(messages: AnthropicMessage[]): Message[] { + return messages.reduce((merged, message) => { + const lastMessage = merged[merged.length - 1] + let messageContent: string | (ContentPartText | ContentPartImage)[] = "" + let hasImages = false + + // Convert content to appropriate format + if (Array.isArray(message.content)) { + const textParts: string[] = [] + const imageParts: ContentPartImage[] = [] + + message.content.forEach((part) => { + if (part.type === "text") { + textParts.push(part.text) + } + if (part.type === "image") { + hasImages = true + imageParts.push({ + type: "image_url", + image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, + }) + } + }) + + if (hasImages) { + const parts: (ContentPartText | ContentPartImage)[] = [] + if (textParts.length > 0) { + parts.push({ type: "text", text: textParts.join("\n") }) + } + parts.push(...imageParts) + messageContent = parts + } else { + messageContent = textParts.join("\n") + } + } else { + messageContent = message.content + } + + // If last message has same role, merge the content + if (lastMessage?.role === message.role) { + if (typeof lastMessage.content === "string" && typeof messageContent === "string") { + lastMessage.content += `\n${messageContent}` + } + // If either has image content, convert both to array format + else { + const lastContent = Array.isArray(lastMessage.content) + ? lastMessage.content + : [{ type: "text" as const, text: lastMessage.content || "" }] + + const newContent = Array.isArray(messageContent) + ? messageContent + : [{ type: "text" as const, text: messageContent }] + + if (message.role === "assistant") { + const mergedContent = [...lastContent, ...newContent] as AssistantMessage["content"] + lastMessage.content = mergedContent + } else { + const mergedContent = [...lastContent, ...newContent] as UserMessage["content"] + lastMessage.content = mergedContent + } + } + } else { + // Add as new message with the correct type based on role + if (message.role === "assistant") { + const newMessage: AssistantMessage = { + role: "assistant", + content: messageContent as AssistantMessage["content"], + } + merged.push(newMessage) + } else { + const newMessage: UserMessage = { + role: "user", + content: messageContent as UserMessage["content"], + } + merged.push(newMessage) + } + } + + return merged + }, []) +} diff --git a/src/api/transform/stream.ts b/src/api/transform/stream.ts index 0290201dad..712f839b49 100644 --- a/src/api/transform/stream.ts +++ b/src/api/transform/stream.ts @@ -1,11 +1,16 @@ export type ApiStream = AsyncGenerator -export type ApiStreamChunk = ApiStreamTextChunk | ApiStreamUsageChunk +export type ApiStreamChunk = ApiStreamTextChunk | ApiStreamReasoningChunk | ApiStreamUsageChunk export interface ApiStreamTextChunk { type: "text" text: string } +export interface ApiStreamReasoningChunk { + type: "reasoning" + reasoning: string +} + export interface ApiStreamUsageChunk { type: "usage" inputTokens: number diff --git a/src/core/Cline.ts b/src/core/Cline.ts index c9c3e3c066..ff462c61d0 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -2986,9 +2986,14 @@ export class Cline { const stream = this.attemptApiRequest(previousApiReqIndex) // yields only if the first chunk is successful, otherwise will allow the user to retry the request (most likely due to rate limit error, which gets thrown on the first chunk) let assistantMessage = "" + let reasoningMessage = "" this.isStreaming = true try { for await (const chunk of stream) { + if (!chunk) { + // Sometimes chunk is undefined, no idea that can cause it, but this workaround seems to fix it + continue + } switch (chunk.type) { case "usage": inputTokens += chunk.inputTokens @@ -2997,7 +3002,16 @@ export class Cline { cacheReadTokens += chunk.cacheReadTokens ?? 0 totalCost = chunk.totalCost break + case "reasoning": + // reasoning will always come before assistant message + reasoningMessage += chunk.reasoning + await this.say("reasoning", reasoningMessage, undefined, true) + break case "text": + if (reasoningMessage && assistantMessage.length === 0) { + // complete reasoning message + await this.say("reasoning", reasoningMessage, undefined, false) + } assistantMessage += chunk.text // parse raw assistant message into content blocks const prevLength = this.assistantMessageContent.length diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index e45c912ba7..5a93caf07b 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -75,6 +75,7 @@ export interface ClineMessage { ask?: ClineAsk say?: ClineSay text?: string + reasoning?: string images?: string[] partial?: boolean lastCheckpointHash?: string @@ -103,6 +104,7 @@ export type ClineSay = | "api_req_started" | "api_req_finished" | "text" + | "reasoning" | "completion_result" | "user_feedback" | "user_feedback_diff" diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index fed1bb0cf4..5c877207f2 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -842,6 +842,62 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi ) + case "reasoning": + return ( + <> + {message.text && ( +
+ {isExpanded ? ( +
+ + Reasoning + + + {message.text} +
+ ) : ( +
+ Reasoning: + + {message.text + "\u200E"} + + +
+ )} +
+ )} + + ) case "user_feedback": return (