Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 0039dc3

Browse files
committed
Handle DeepSeek thinking mode re-enable after disabled tool calls
1 parent 65e9230 commit 0039dc3

6 files changed

Lines changed: 153 additions & 10 deletions

File tree

src/api/providers/__tests__/deepseek.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,49 @@ describe("DeepSeekHandler", () => {
559559
expect(callArgs.reasoning_effort).toBeUndefined()
560560
})
561561

562+
it("should add empty reasoning_content to old tool calls when thinking is re-enabled", async () => {
563+
const v4Handler = new DeepSeekHandler({
564+
...mockOptions,
565+
apiModelId: "deepseek-v4-pro",
566+
enableReasoningEffort: true,
567+
reasoningEffort: "high",
568+
})
569+
570+
const priorMessages: Anthropic.Messages.MessageParam[] = [
571+
{
572+
role: "assistant",
573+
content: [
574+
{
575+
type: "tool_use",
576+
id: "call_without_reasoning",
577+
name: "read_file",
578+
input: { path: "memory.md" },
579+
},
580+
],
581+
},
582+
{
583+
role: "user",
584+
content: [
585+
{
586+
type: "tool_result",
587+
tool_use_id: "call_without_reasoning",
588+
content: "ok",
589+
},
590+
],
591+
},
592+
]
593+
594+
const stream = v4Handler.createMessage(systemPrompt, priorMessages)
595+
for await (const _chunk of stream) {
596+
// Consume the stream
597+
}
598+
599+
const callArgs = mockCreate.mock.calls[0][0]
600+
const assistantWithToolCall = callArgs.messages.find((message: any) => message.role === "assistant")
601+
expect(callArgs.thinking).toEqual({ type: "enabled" })
602+
expect(assistantWithToolCall.reasoning_content).toBe("")
603+
})
604+
562605
it("should handle tool calls with reasoning_content", async () => {
563606
const reasonerHandler = new DeepSeekHandler({
564607
...mockOptions,

src/api/providers/__tests__/openai.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,55 @@ describe("OpenAiHandler", () => {
463463
expect(callArgs.reasoning_effort).toBeUndefined()
464464
})
465465

466+
it("should add empty reasoning_content to old tool calls for OpenAI-compatible DeepSeek V4 when thinking is re-enabled", async () => {
467+
const deepSeekV4Options: ApiHandlerOptions = {
468+
...mockOptions,
469+
openAiBaseUrl: "https://api.deepseek.com",
470+
openAiModelId: "deepseek-v4-pro",
471+
enableReasoningEffort: true,
472+
reasoningEffort: "high",
473+
openAiCustomModelInfo: {
474+
contextWindow: 1_000_000,
475+
supportsPromptCache: true,
476+
supportsReasoningEffort: ["disable", "high", "xhigh"],
477+
reasoningEffort: "high",
478+
},
479+
}
480+
const priorMessages: Anthropic.Messages.MessageParam[] = [
481+
{
482+
role: "assistant",
483+
content: [
484+
{
485+
type: "tool_use",
486+
id: "call_without_reasoning",
487+
name: "read_file",
488+
input: { path: "memory.md" },
489+
},
490+
],
491+
},
492+
{
493+
role: "user",
494+
content: [
495+
{
496+
type: "tool_result",
497+
tool_use_id: "call_without_reasoning",
498+
content: "ok",
499+
},
500+
],
501+
},
502+
]
503+
504+
const deepSeekV4Handler = new OpenAiHandler(deepSeekV4Options)
505+
const stream = deepSeekV4Handler.createMessage(systemPrompt, priorMessages)
506+
for await (const _chunk of stream) {
507+
}
508+
509+
const callArgs = mockCreate.mock.calls[0][0]
510+
const assistantWithToolCall = callArgs.messages.find((message: any) => message.role === "assistant")
511+
expect(callArgs.thinking).toEqual({ type: "enabled" })
512+
expect(assistantWithToolCall.reasoning_content).toBe("")
513+
})
514+
466515
it("should preserve empty DeepSeek V4 reasoning_content chunks for OpenAI-compatible endpoints", async () => {
467516
mockCreate.mockImplementationOnce(async () => ({
468517
[Symbol.asyncIterator]: async function* () {

src/api/providers/deepseek.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export class DeepSeekHandler extends OpenAiHandler {
7474
// See: https://api-docs.deepseek.com/guides/thinking_mode
7575
const convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages], {
7676
mergeToolResultText: isThinkingModel,
77+
requireReasoningContentForToolCalls: isThinkingModel && thinkingType === "enabled",
7778
})
7879

7980
const requestOptions: DeepSeekChatCompletionParams = {

src/api/providers/openai.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
136136
let convertedMessages
137137

138138
if (deepseekReasoner) {
139-
convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
139+
convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages], {
140+
mergeToolResultText: usesDeepSeekThinkingParam && deepseekThinkingType === "enabled",
141+
requireReasoningContentForToolCalls:
142+
usesDeepSeekThinkingParam && deepseekThinkingType === "enabled",
143+
})
140144
} else {
141145
if (modelInfo.supportsPromptCache) {
142146
systemMessage = {
@@ -256,7 +260,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
256260
const requestOptions: OpenAiCompatibleChatCompletionParamsNonStreaming = {
257261
model: modelId,
258262
messages: deepseekReasoner
259-
? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
263+
? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages], {
264+
mergeToolResultText: usesDeepSeekThinkingParam && deepseekThinkingType === "enabled",
265+
requireReasoningContentForToolCalls:
266+
usesDeepSeekThinkingParam && deepseekThinkingType === "enabled",
267+
})
260268
: [systemMessage, ...convertToOpenAiMessages(messages)],
261269
...(usesDeepSeekThinkingParam && { thinking: { type: deepseekThinkingType } }),
262270
...(providerReasoning && providerReasoning),

src/api/transform/__tests__/r1-format.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,41 @@ describe("convertToR1Format", () => {
614614
// Most importantly: NO user message after tool message
615615
expect(result.filter((m) => m.role === "user")).toHaveLength(1)
616616
})
617+
618+
it("should add empty reasoning_content to prior tool calls when required", () => {
619+
const input = [
620+
{ role: "user" as const, content: "Start" },
621+
{
622+
role: "assistant" as const,
623+
content: [
624+
{
625+
type: "tool_use" as const,
626+
id: "call_123",
627+
name: "test_tool",
628+
input: {},
629+
},
630+
],
631+
},
632+
{
633+
role: "user" as const,
634+
content: [
635+
{
636+
type: "tool_result" as const,
637+
tool_use_id: "call_123",
638+
content: "Result",
639+
},
640+
],
641+
},
642+
]
643+
644+
const result = convertToR1Format(input as Anthropic.Messages.MessageParam[], {
645+
mergeToolResultText: true,
646+
requireReasoningContentForToolCalls: true,
647+
})
648+
649+
expect((result[1] as any).tool_calls).toBeDefined()
650+
expect((result[1] as any).reasoning_content).toBe("")
651+
})
617652
})
618653
})
619654
})

src/api/transform/r1-format.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,15 @@ export type DeepSeekAssistantMessage = AssistantMessage & {
3838
*/
3939
export function convertToR1Format(
4040
messages: AnthropicMessage[],
41-
options?: { mergeToolResultText?: boolean },
41+
options?: { mergeToolResultText?: boolean; requireReasoningContentForToolCalls?: boolean },
4242
): Message[] {
4343
const result: Message[] = []
4444

4545
for (const message of messages) {
4646
// Check if the message has reasoning_content (for DeepSeek interleaved thinking)
4747
const messageWithReasoning = message as AnthropicMessage & { reasoning_content?: string }
4848
const reasoningContent = messageWithReasoning.reasoning_content
49+
const hasReasoningContent = typeof reasoningContent === "string"
4950

5051
if (message.role === "user") {
5152
// Handle user messages - may contain tool_result blocks
@@ -187,15 +188,21 @@ export function convertToR1Format(
187188
}
188189
}
189190

190-
// Use reasoning from content blocks if not provided at top level
191-
const finalReasoning = reasoningContent || extractedReasoning
191+
// Use reasoning from content blocks if not provided at top level.
192+
// Empty reasoning_content is meaningful for DeepSeek thinking-mode tool call continuations.
193+
const finalReasoning = hasReasoningContent ? reasoningContent : extractedReasoning
194+
const needsEmptyToolCallReasoning =
195+
options?.requireReasoningContentForToolCalls === true &&
196+
toolCalls.length > 0 &&
197+
typeof finalReasoning !== "string"
198+
const shouldAttachReasoning = typeof finalReasoning === "string" || needsEmptyToolCallReasoning
192199

193200
const assistantMessage: DeepSeekAssistantMessage = {
194201
role: "assistant",
195202
content: textParts.length > 0 ? textParts.join("\n") : null,
196203
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
197204
// Preserve reasoning_content for DeepSeek interleaved thinking
198-
...(finalReasoning && { reasoning_content: finalReasoning }),
205+
...(shouldAttachReasoning && { reasoning_content: finalReasoning ?? "" }),
199206
}
200207

201208
// Check if we can merge with the last message (only if no tool calls)
@@ -209,8 +216,8 @@ export function convertToR1Format(
209216
lastMessage.content = `${lastContent}\n${assistantMessage.content}`
210217
}
211218
// Preserve reasoning_content from the new message if present
212-
if (finalReasoning) {
213-
;(lastMessage as DeepSeekAssistantMessage).reasoning_content = finalReasoning
219+
if (shouldAttachReasoning) {
220+
;(lastMessage as DeepSeekAssistantMessage).reasoning_content = finalReasoning ?? ""
214221
}
215222
} else {
216223
result.push(assistantMessage)
@@ -225,14 +232,14 @@ export function convertToR1Format(
225232
lastMessage.content = message.content
226233
}
227234
// Preserve reasoning_content from the new message if present
228-
if (reasoningContent) {
235+
if (hasReasoningContent) {
229236
;(lastMessage as DeepSeekAssistantMessage).reasoning_content = reasoningContent
230237
}
231238
} else {
232239
const assistantMessage: DeepSeekAssistantMessage = {
233240
role: "assistant",
234241
content: message.content,
235-
...(reasoningContent && { reasoning_content: reasoningContent }),
242+
...(hasReasoningContent && { reasoning_content: reasoningContent }),
236243
}
237244
result.push(assistantMessage)
238245
}

0 commit comments

Comments
 (0)