mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat(deepseek): implement interleaved thinking mode for deepseek-reasoner (#9969)
This commit is contained in:
parent
f414ba41a6
commit
d274812332
6 changed files with 704 additions and 80 deletions
|
|
@ -1,6 +1,9 @@
|
|||
import type { ModelInfo } from "../model.js"
|
||||
|
||||
// https://platform.deepseek.com/docs/api
|
||||
// preserveReasoning enables interleaved thinking mode for tool calls:
|
||||
// DeepSeek requires reasoning_content to be passed back during tool call
|
||||
// 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"
|
||||
|
|
@ -26,6 +29,7 @@ export const deepSeekModels = {
|
|||
supportsPromptCache: true,
|
||||
supportsNativeTools: true,
|
||||
defaultToolProtocol: "native",
|
||||
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
|
||||
|
|
@ -35,4 +39,4 @@ export const deepSeekModels = {
|
|||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// https://api-docs.deepseek.com/quick_start/parameter_settings
|
||||
export const DEEP_SEEK_DEFAULT_TEMPERATURE = 0
|
||||
export const DEEP_SEEK_DEFAULT_TEMPERATURE = 0.3
|
||||
|
|
|
|||
|
|
@ -29,23 +29,75 @@ vi.mock("openai", () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Check if this is a reasoning_content test by looking at model
|
||||
const isReasonerModel = options.model?.includes("deepseek-reasoner")
|
||||
const isToolCallTest = options.tools?.length > 0
|
||||
|
||||
// Return async iterator for streaming
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "Test response" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
// For reasoner models, emit reasoning_content first
|
||||
if (isReasonerModel) {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { reasoning_content: "Let me think about this..." },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { reasoning_content: " I'll analyze step by step." },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
}
|
||||
|
||||
// For tool call tests with reasoner, emit tool call
|
||||
if (isReasonerModel && isToolCallTest) {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_123",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"location":"SF"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
} else {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "Test response" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
finish_reason: isToolCallTest ? "tool_calls" : "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
|
|
@ -70,7 +122,7 @@ vi.mock("openai", () => {
|
|||
import OpenAI from "openai"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { deepSeekDefaultModelId } from "@roo-code/types"
|
||||
import { deepSeekDefaultModelId, type ModelInfo } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
|
|
@ -174,6 +226,27 @@ describe("DeepSeekHandler", () => {
|
|||
expect(model.info.supportsPromptCache).toBe(true)
|
||||
})
|
||||
|
||||
it("should have preserveReasoning enabled for deepseek-reasoner 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({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-reasoner",
|
||||
})
|
||||
const model = handlerWithReasoner.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,
|
||||
|
|
@ -317,4 +390,108 @@ describe("DeepSeekHandler", () => {
|
|||
expect(result.cacheReadTokens).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("interleaved thinking mode", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Hello!",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle reasoning_content in streaming responses for deepseek-reasoner", async () => {
|
||||
const reasonerHandler = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-reasoner",
|
||||
})
|
||||
|
||||
const stream = reasonerHandler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Should have reasoning chunks
|
||||
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
|
||||
expect(reasoningChunks.length).toBeGreaterThan(0)
|
||||
expect(reasoningChunks[0].text).toBe("Let me think about this...")
|
||||
expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.")
|
||||
})
|
||||
|
||||
it("should pass thinking parameter for deepseek-reasoner model", async () => {
|
||||
const reasonerHandler = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-reasoner",
|
||||
})
|
||||
|
||||
const stream = reasonerHandler.createMessage(systemPrompt, messages)
|
||||
for await (const _chunk of stream) {
|
||||
// Consume the stream
|
||||
}
|
||||
|
||||
// Verify that the thinking parameter was passed to the API
|
||||
// Note: mockCreate receives two arguments - request options and path options
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thinking: { type: "enabled" },
|
||||
}),
|
||||
{}, // Empty path options for non-Azure URLs
|
||||
)
|
||||
})
|
||||
|
||||
it("should NOT pass thinking parameter for deepseek-chat model", async () => {
|
||||
const chatHandler = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-chat",
|
||||
})
|
||||
|
||||
const stream = chatHandler.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()
|
||||
})
|
||||
|
||||
it("should handle tool calls with reasoning_content", async () => {
|
||||
const reasonerHandler = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-reasoner",
|
||||
})
|
||||
|
||||
const tools: any[] = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get weather",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const stream = reasonerHandler.createMessage(systemPrompt, messages, { taskId: "test", tools })
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Should have reasoning chunks
|
||||
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
|
||||
expect(reasoningChunks.length).toBeGreaterThan(0)
|
||||
|
||||
// Should have tool call chunks
|
||||
const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
|
||||
expect(toolCallChunks.length).toBeGreaterThan(0)
|
||||
expect(toolCallChunks[0].name).toBe("get_weather")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,26 @@
|
|||
import { deepSeekModels, deepSeekDefaultModelId } from "@roo-code/types"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
|
||||
import {
|
||||
deepSeekModels,
|
||||
deepSeekDefaultModelId,
|
||||
DEEP_SEEK_DEFAULT_TEMPERATURE,
|
||||
OPENAI_AZURE_AI_INFERENCE_PATH,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
|
||||
import type { ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
|
||||
import { OpenAiHandler } from "./openai"
|
||||
import type { ApiHandlerCreateMessageMetadata } from "../index"
|
||||
|
||||
// Custom interface for DeepSeek params to support thinking mode
|
||||
type DeepSeekChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParamsStreaming & {
|
||||
thinking?: { type: "enabled" | "disabled" }
|
||||
}
|
||||
|
||||
export class DeepSeekHandler extends OpenAiHandler {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
|
|
@ -26,8 +41,100 @@ export class DeepSeekHandler extends OpenAiHandler {
|
|||
return { id, info, ...params }
|
||||
}
|
||||
|
||||
override async *createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const modelId = this.options.apiModelId ?? deepSeekDefaultModelId
|
||||
const { info: modelInfo } = this.getModel()
|
||||
|
||||
// Check if this is a thinking-enabled model (deepseek-reasoner)
|
||||
const isThinkingModel = modelId.includes("deepseek-reasoner")
|
||||
|
||||
// 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
|
||||
const convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
|
||||
const requestOptions: DeepSeekChatCompletionParams = {
|
||||
model: modelId,
|
||||
temperature: this.options.modelTemperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE,
|
||||
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" } }),
|
||||
...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
|
||||
...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
|
||||
...(metadata?.toolProtocol === "native" && {
|
||||
parallel_tool_calls: metadata.parallelToolCalls ?? false,
|
||||
}),
|
||||
}
|
||||
|
||||
// Add max_tokens if needed
|
||||
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
|
||||
|
||||
// Check if base URL is Azure AI Inference (for DeepSeek via Azure)
|
||||
const isAzureAiInference = this._isAzureAiInference(this.options.deepSeekBaseUrl)
|
||||
|
||||
let stream
|
||||
try {
|
||||
stream = await this.client.chat.completions.create(
|
||||
requestOptions,
|
||||
isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
|
||||
)
|
||||
} catch (error) {
|
||||
const { handleOpenAIError } = await import("./utils/openai-error-handler")
|
||||
throw handleOpenAIError(error, "DeepSeek")
|
||||
}
|
||||
|
||||
let lastUsage
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta ?? {}
|
||||
|
||||
// Handle regular text content
|
||||
if (delta.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: (delta.reasoning_content as string) || "",
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool calls
|
||||
if (delta.tool_calls) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index: toolCall.index,
|
||||
id: toolCall.id,
|
||||
name: toolCall.function?.name,
|
||||
arguments: toolCall.function?.arguments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
lastUsage = chunk.usage
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUsage) {
|
||||
yield this.processUsageMetrics(lastUsage, modelInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// Override to handle DeepSeek's usage metrics, including caching.
|
||||
protected override processUsageMetrics(usage: any): ApiStreamUsageChunk {
|
||||
protected override processUsageMetrics(usage: any, _modelInfo?: any): ApiStreamUsageChunk {
|
||||
return {
|
||||
type: "usage",
|
||||
inputTokens: usage?.prompt_tokens || 0,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import { handleOpenAIError } from "./utils/openai-error-handler"
|
|||
// compatible with the OpenAI API. We can also rename it to `OpenAIHandler`.
|
||||
export class OpenAiHandler extends BaseProvider implements SingleCompletionHandler {
|
||||
protected options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
protected client: OpenAI
|
||||
private readonly providerName = "OpenAI"
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
|
|
@ -478,7 +478,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
}
|
||||
}
|
||||
|
||||
private _getUrlHost(baseUrl?: string): string {
|
||||
protected _getUrlHost(baseUrl?: string): string {
|
||||
try {
|
||||
return new URL(baseUrl ?? "").host
|
||||
} catch (error) {
|
||||
|
|
@ -491,7 +491,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
return urlHost.includes("x.ai")
|
||||
}
|
||||
|
||||
private _isAzureAiInference(baseUrl?: string): boolean {
|
||||
protected _isAzureAiInference(baseUrl?: string): boolean {
|
||||
const urlHost = this._getUrlHost(baseUrl)
|
||||
return urlHost.endsWith(".services.ai.azure.com")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,4 +179,220 @@ describe("convertToR1Format", () => {
|
|||
|
||||
expect(convertToR1Format(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
describe("tool calls support for DeepSeek interleaved thinking", () => {
|
||||
it("should convert assistant messages with tool_use to OpenAI format", () => {
|
||||
const input: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "What's the weather?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Let me check the weather for you." },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "call_123",
|
||||
name: "get_weather",
|
||||
input: { location: "San Francisco" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToR1Format(input)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({ role: "user", content: "What's the weather?" })
|
||||
expect(result[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Let me check the weather for you.",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_123",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"location":"San Francisco"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("should convert user messages with tool_result to OpenAI tool messages", () => {
|
||||
const input: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "What's the weather?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "call_123",
|
||||
name: "get_weather",
|
||||
input: { location: "San Francisco" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call_123",
|
||||
content: "72°F and sunny",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToR1Format(input)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0]).toEqual({ role: "user", content: "What's the weather?" })
|
||||
expect(result[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: expect.any(Array),
|
||||
})
|
||||
expect(result[2]).toEqual({
|
||||
role: "tool",
|
||||
tool_call_id: "call_123",
|
||||
content: "72°F and sunny",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle tool_result with array content", () => {
|
||||
const input: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call_456",
|
||||
content: [
|
||||
{ type: "text", text: "Line 1" },
|
||||
{ type: "text", text: "Line 2" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToR1Format(input)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({
|
||||
role: "tool",
|
||||
tool_call_id: "call_456",
|
||||
content: "Line 1\nLine 2",
|
||||
})
|
||||
})
|
||||
|
||||
it("should preserve reasoning_content on assistant messages", () => {
|
||||
const input = [
|
||||
{ role: "user" as const, content: "Think about this" },
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: "Here's my answer",
|
||||
reasoning_content: "Let me analyze step by step...",
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToR1Format(input as Anthropic.Messages.MessageParam[])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect((result[1] as any).reasoning_content).toBe("Let me analyze step by step...")
|
||||
})
|
||||
|
||||
it("should handle mixed tool_result and text in user message", () => {
|
||||
const input: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call_789",
|
||||
content: "Tool result",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Please continue",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToR1Format(input)
|
||||
|
||||
// Should produce two messages: tool message first, then user message
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({
|
||||
role: "tool",
|
||||
tool_call_id: "call_789",
|
||||
content: "Tool result",
|
||||
})
|
||||
expect(result[1]).toEqual({
|
||||
role: "user",
|
||||
content: "Please continue",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle multiple tool calls in single assistant message", () => {
|
||||
const input: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "call_1",
|
||||
name: "tool_a",
|
||||
input: { param: "a" },
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "call_2",
|
||||
name: "tool_b",
|
||||
input: { param: "b" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToR1Format(input)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect((result[0] as any).tool_calls).toHaveLength(2)
|
||||
expect((result[0] as any).tool_calls[0].id).toBe("call_1")
|
||||
expect((result[0] as any).tool_calls[1].id).toBe("call_2")
|
||||
})
|
||||
|
||||
it("should not merge assistant messages that have tool calls", () => {
|
||||
const input: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "call_1",
|
||||
name: "tool_a",
|
||||
input: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Follow up response",
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToR1Format(input)
|
||||
|
||||
// Should NOT merge because first message has tool calls
|
||||
expect(result).toHaveLength(2)
|
||||
expect((result[0] as any).tool_calls).toBeDefined()
|
||||
expect(result[1]).toEqual({
|
||||
role: "assistant",
|
||||
content: "Follow up response",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,94 +5,214 @@ type ContentPartText = OpenAI.Chat.ChatCompletionContentPartText
|
|||
type ContentPartImage = OpenAI.Chat.ChatCompletionContentPartImage
|
||||
type UserMessage = OpenAI.Chat.ChatCompletionUserMessageParam
|
||||
type AssistantMessage = OpenAI.Chat.ChatCompletionAssistantMessageParam
|
||||
type ToolMessage = OpenAI.Chat.ChatCompletionToolMessageParam
|
||||
type Message = OpenAI.Chat.ChatCompletionMessageParam
|
||||
type AnthropicMessage = Anthropic.Messages.MessageParam
|
||||
|
||||
/**
|
||||
* Extended assistant message type to support DeepSeek's interleaved thinking.
|
||||
* DeepSeek's API returns reasoning_content alongside content and tool_calls,
|
||||
* and requires it to be passed back in subsequent requests within the same turn.
|
||||
*/
|
||||
export type DeepSeekAssistantMessage = AssistantMessage & {
|
||||
reasoning_content?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* For DeepSeek's interleaved thinking mode:
|
||||
* - Preserves reasoning_content on assistant messages for tool call continuations
|
||||
* - Tool result messages are converted to OpenAI tool messages
|
||||
* - reasoning_content from previous assistant messages is preserved until a new user turn
|
||||
*
|
||||
* @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
|
||||
const result: Message[] = []
|
||||
|
||||
// Convert content to appropriate format
|
||||
if (Array.isArray(message.content)) {
|
||||
const textParts: string[] = []
|
||||
const imageParts: ContentPartImage[] = []
|
||||
for (const message of messages) {
|
||||
// Check if the message has reasoning_content (for DeepSeek interleaved thinking)
|
||||
const messageWithReasoning = message as AnthropicMessage & { reasoning_content?: string }
|
||||
const reasoningContent = messageWithReasoning.reasoning_content
|
||||
|
||||
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 (message.role === "user") {
|
||||
// Handle user messages - may contain tool_result blocks
|
||||
if (Array.isArray(message.content)) {
|
||||
const textParts: string[] = []
|
||||
const imageParts: ContentPartImage[] = []
|
||||
const toolResults: { tool_use_id: string; content: string }[] = []
|
||||
|
||||
if (hasImages) {
|
||||
const parts: (ContentPartText | ContentPartImage)[] = []
|
||||
if (textParts.length > 0) {
|
||||
parts.push({ type: "text", text: textParts.join("\n") })
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
textParts.push(part.text)
|
||||
} else if (part.type === "image") {
|
||||
imageParts.push({
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
|
||||
})
|
||||
} else if (part.type === "tool_result") {
|
||||
// Convert tool_result to OpenAI tool message format
|
||||
let content: string
|
||||
if (typeof part.content === "string") {
|
||||
content = part.content
|
||||
} else if (Array.isArray(part.content)) {
|
||||
content =
|
||||
part.content
|
||||
?.map((c) => {
|
||||
if (c.type === "text") return c.text
|
||||
if (c.type === "image") return "(image)"
|
||||
return ""
|
||||
})
|
||||
.join("\n") ?? ""
|
||||
} else {
|
||||
content = ""
|
||||
}
|
||||
toolResults.push({
|
||||
tool_use_id: part.tool_use_id,
|
||||
content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add tool messages first (they must follow assistant tool_use)
|
||||
for (const toolResult of toolResults) {
|
||||
const toolMessage: ToolMessage = {
|
||||
role: "tool",
|
||||
tool_call_id: toolResult.tool_use_id,
|
||||
content: toolResult.content,
|
||||
}
|
||||
result.push(toolMessage)
|
||||
}
|
||||
|
||||
// Then add user message with text/image content if any
|
||||
if (textParts.length > 0 || imageParts.length > 0) {
|
||||
let content: UserMessage["content"]
|
||||
if (imageParts.length > 0) {
|
||||
const parts: (ContentPartText | ContentPartImage)[] = []
|
||||
if (textParts.length > 0) {
|
||||
parts.push({ type: "text", text: textParts.join("\n") })
|
||||
}
|
||||
parts.push(...imageParts)
|
||||
content = parts
|
||||
} else {
|
||||
content = textParts.join("\n")
|
||||
}
|
||||
|
||||
// Check if we can merge with the last message
|
||||
const lastMessage = result[result.length - 1]
|
||||
if (lastMessage?.role === "user") {
|
||||
// Merge with existing user message
|
||||
if (typeof lastMessage.content === "string" && typeof content === "string") {
|
||||
lastMessage.content += `\n${content}`
|
||||
} else {
|
||||
const lastContent = Array.isArray(lastMessage.content)
|
||||
? lastMessage.content
|
||||
: [{ type: "text" as const, text: lastMessage.content || "" }]
|
||||
const newContent = Array.isArray(content)
|
||||
? content
|
||||
: [{ type: "text" as const, text: content }]
|
||||
lastMessage.content = [...lastContent, ...newContent] as UserMessage["content"]
|
||||
}
|
||||
} else {
|
||||
result.push({ role: "user", content })
|
||||
}
|
||||
}
|
||||
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
|
||||
// Simple string content
|
||||
const lastMessage = result[result.length - 1]
|
||||
if (lastMessage?.role === "user") {
|
||||
if (typeof lastMessage.content === "string") {
|
||||
lastMessage.content += `\n${message.content}`
|
||||
} else {
|
||||
;(lastMessage.content as (ContentPartText | ContentPartImage)[]).push({
|
||||
type: "text",
|
||||
text: message.content,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const mergedContent = [...lastContent, ...newContent] as UserMessage["content"]
|
||||
lastMessage.content = mergedContent
|
||||
result.push({ role: "user", content: message.content })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Add as new message with the correct type based on role
|
||||
if (message.role === "assistant") {
|
||||
const newMessage: AssistantMessage = {
|
||||
} else if (message.role === "assistant") {
|
||||
// Handle assistant messages - may contain tool_use blocks and reasoning blocks
|
||||
if (Array.isArray(message.content)) {
|
||||
const textParts: string[] = []
|
||||
const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = []
|
||||
let extractedReasoning: string | undefined
|
||||
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
textParts.push(part.text)
|
||||
} else if (part.type === "tool_use") {
|
||||
toolCalls.push({
|
||||
id: part.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: part.name,
|
||||
arguments: JSON.stringify(part.input),
|
||||
},
|
||||
})
|
||||
} else if ((part as any).type === "reasoning" && (part as any).text) {
|
||||
// Extract reasoning from content blocks (Task stores it this way)
|
||||
extractedReasoning = (part as any).text
|
||||
}
|
||||
}
|
||||
|
||||
// Use reasoning from content blocks if not provided at top level
|
||||
const finalReasoning = reasoningContent || extractedReasoning
|
||||
|
||||
const assistantMessage: DeepSeekAssistantMessage = {
|
||||
role: "assistant",
|
||||
content: messageContent as AssistantMessage["content"],
|
||||
content: textParts.length > 0 ? textParts.join("\n") : null,
|
||||
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
|
||||
// Preserve reasoning_content for DeepSeek interleaved thinking
|
||||
...(finalReasoning && { reasoning_content: finalReasoning }),
|
||||
}
|
||||
|
||||
// Check if we can merge with the last message (only if no tool calls)
|
||||
const lastMessage = result[result.length - 1]
|
||||
if (lastMessage?.role === "assistant" && !toolCalls.length && !(lastMessage as any).tool_calls) {
|
||||
// Merge text content
|
||||
if (typeof lastMessage.content === "string" && typeof assistantMessage.content === "string") {
|
||||
lastMessage.content += `\n${assistantMessage.content}`
|
||||
} else if (assistantMessage.content) {
|
||||
const lastContent = lastMessage.content || ""
|
||||
lastMessage.content = `${lastContent}\n${assistantMessage.content}`
|
||||
}
|
||||
// Preserve reasoning_content from the new message if present
|
||||
if (finalReasoning) {
|
||||
;(lastMessage as DeepSeekAssistantMessage).reasoning_content = finalReasoning
|
||||
}
|
||||
} else {
|
||||
result.push(assistantMessage)
|
||||
}
|
||||
merged.push(newMessage)
|
||||
} else {
|
||||
const newMessage: UserMessage = {
|
||||
role: "user",
|
||||
content: messageContent as UserMessage["content"],
|
||||
// Simple string content
|
||||
const lastMessage = result[result.length - 1]
|
||||
if (lastMessage?.role === "assistant" && !(lastMessage as any).tool_calls) {
|
||||
if (typeof lastMessage.content === "string") {
|
||||
lastMessage.content += `\n${message.content}`
|
||||
} else {
|
||||
lastMessage.content = message.content
|
||||
}
|
||||
// Preserve reasoning_content from the new message if present
|
||||
if (reasoningContent) {
|
||||
;(lastMessage as DeepSeekAssistantMessage).reasoning_content = reasoningContent
|
||||
}
|
||||
} else {
|
||||
const assistantMessage: DeepSeekAssistantMessage = {
|
||||
role: "assistant",
|
||||
content: message.content,
|
||||
...(reasoningContent && { reasoning_content: reasoningContent }),
|
||||
}
|
||||
result.push(assistantMessage)
|
||||
}
|
||||
merged.push(newMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}, [])
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue