mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Add better support for r1 + show reasoning tokens
This commit is contained in:
parent
57bb43bb6f
commit
68ac266463
8 changed files with 268 additions and 11 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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("</think>")) {
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
98
src/api/transform/r1-format.ts
Normal file
98
src/api/transform/r1-format.ts
Normal file
|
|
@ -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<Message[]>((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
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
export type ApiStream = AsyncGenerator<ApiStreamChunk>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -842,6 +842,62 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
|||
<Markdown markdown={message.text} />
|
||||
</div>
|
||||
)
|
||||
case "reasoning":
|
||||
return (
|
||||
<>
|
||||
{message.text && (
|
||||
<div
|
||||
onClick={onToggleExpand}
|
||||
style={{
|
||||
// marginBottom: 15,
|
||||
cursor: "pointer",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
|
||||
fontStyle: "italic",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
{isExpanded ? (
|
||||
<div style={{ marginTop: -3 }}>
|
||||
<span style={{ fontWeight: "bold", display: "block", marginBottom: "4px" }}>
|
||||
Reasoning
|
||||
<span
|
||||
className="codicon codicon-chevron-down"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
transform: "translateY(3px)",
|
||||
marginLeft: "1.5px",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
{message.text}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<span style={{ fontWeight: "bold", marginRight: "4px" }}>Reasoning:</span>
|
||||
<span
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
direction: "rtl",
|
||||
textAlign: "left",
|
||||
flex: 1,
|
||||
}}>
|
||||
{message.text + "\u200E"}
|
||||
</span>
|
||||
<span
|
||||
className="codicon codicon-chevron-right"
|
||||
style={{
|
||||
marginLeft: "4px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
case "user_feedback":
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue