Add better support for r1 + show reasoning tokens

This commit is contained in:
Saoud Rizwan 2025-01-31 02:35:06 -08:00
parent 57bb43bb6f
commit 68ac266463
8 changed files with 268 additions and 11 deletions

View file

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

View file

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

View file

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

View 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
}, [])
}

View file

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

View file

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

View file

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

View file

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