Skip to content

Commit cdad3e7

Browse files
committed
feat(deepseek): implement interleaved thinking mode for deepseek-reasoner
- Add thinking parameter support for deepseek-reasoner model - Handle streaming reasoning_content from DeepSeek API - Add tool call conversion (tool_use -> tool_calls) for thinking mode - Add tool result conversion (tool_result -> tool messages) - Extract reasoning from content blocks for API continuations - Add getReasoningContent() method for accumulated reasoning - Add comprehensive tests for interleaved thinking mode
1 parent 24eb6ae commit cdad3e7

File tree

4 files changed

+749
-72
lines changed

4 files changed

+749
-72
lines changed

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

Lines changed: 211 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,23 +29,75 @@ vi.mock("openai", () => {
2929
}
3030
}
3131

32+
// Check if this is a reasoning_content test by looking at model
33+
const isReasonerModel = options.model?.includes("deepseek-reasoner")
34+
const isToolCallTest = options.tools?.length > 0
35+
3236
// Return async iterator for streaming
3337
return {
3438
[Symbol.asyncIterator]: async function* () {
35-
yield {
36-
choices: [
37-
{
38-
delta: { content: "Test response" },
39-
index: 0,
40-
},
41-
],
42-
usage: null,
39+
// For reasoner models, emit reasoning_content first
40+
if (isReasonerModel) {
41+
yield {
42+
choices: [
43+
{
44+
delta: { reasoning_content: "Let me think about this..." },
45+
index: 0,
46+
},
47+
],
48+
usage: null,
49+
}
50+
yield {
51+
choices: [
52+
{
53+
delta: { reasoning_content: " I'll analyze step by step." },
54+
index: 0,
55+
},
56+
],
57+
usage: null,
58+
}
59+
}
60+
61+
// For tool call tests with reasoner, emit tool call
62+
if (isReasonerModel && isToolCallTest) {
63+
yield {
64+
choices: [
65+
{
66+
delta: {
67+
tool_calls: [
68+
{
69+
index: 0,
70+
id: "call_123",
71+
function: {
72+
name: "get_weather",
73+
arguments: '{"location":"SF"}',
74+
},
75+
},
76+
],
77+
},
78+
index: 0,
79+
},
80+
],
81+
usage: null,
82+
}
83+
} else {
84+
yield {
85+
choices: [
86+
{
87+
delta: { content: "Test response" },
88+
index: 0,
89+
},
90+
],
91+
usage: null,
92+
}
4393
}
94+
4495
yield {
4596
choices: [
4697
{
4798
delta: {},
4899
index: 0,
100+
finish_reason: isToolCallTest ? "tool_calls" : "stop",
49101
},
50102
],
51103
usage: {
@@ -317,4 +369,155 @@ describe("DeepSeekHandler", () => {
317369
expect(result.cacheReadTokens).toBeUndefined()
318370
})
319371
})
372+
373+
describe("interleaved thinking mode", () => {
374+
const systemPrompt = "You are a helpful assistant."
375+
const messages: Anthropic.Messages.MessageParam[] = [
376+
{
377+
role: "user",
378+
content: [
379+
{
380+
type: "text" as const,
381+
text: "Hello!",
382+
},
383+
],
384+
},
385+
]
386+
387+
it("should handle reasoning_content in streaming responses for deepseek-reasoner", async () => {
388+
const reasonerHandler = new DeepSeekHandler({
389+
...mockOptions,
390+
apiModelId: "deepseek-reasoner",
391+
})
392+
393+
const stream = reasonerHandler.createMessage(systemPrompt, messages)
394+
const chunks: any[] = []
395+
for await (const chunk of stream) {
396+
chunks.push(chunk)
397+
}
398+
399+
// Should have reasoning chunks
400+
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
401+
expect(reasoningChunks.length).toBeGreaterThan(0)
402+
expect(reasoningChunks[0].text).toBe("Let me think about this...")
403+
expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.")
404+
})
405+
406+
it("should accumulate reasoning content via getReasoningContent()", async () => {
407+
const reasonerHandler = new DeepSeekHandler({
408+
...mockOptions,
409+
apiModelId: "deepseek-reasoner",
410+
})
411+
412+
// Before any API call, reasoning content should be undefined
413+
expect(reasonerHandler.getReasoningContent()).toBeUndefined()
414+
415+
const stream = reasonerHandler.createMessage(systemPrompt, messages)
416+
for await (const _chunk of stream) {
417+
// Consume the stream
418+
}
419+
420+
// After streaming, reasoning content should be accumulated
421+
const reasoningContent = reasonerHandler.getReasoningContent()
422+
expect(reasoningContent).toBe("Let me think about this... I'll analyze step by step.")
423+
})
424+
425+
it("should pass thinking parameter for deepseek-reasoner model", async () => {
426+
const reasonerHandler = new DeepSeekHandler({
427+
...mockOptions,
428+
apiModelId: "deepseek-reasoner",
429+
})
430+
431+
const stream = reasonerHandler.createMessage(systemPrompt, messages)
432+
for await (const _chunk of stream) {
433+
// Consume the stream
434+
}
435+
436+
// Verify that the thinking parameter was passed to the API
437+
expect(mockCreate).toHaveBeenCalledWith(
438+
expect.objectContaining({
439+
thinking: { type: "enabled" },
440+
}),
441+
)
442+
})
443+
444+
it("should NOT pass thinking parameter for deepseek-chat model", async () => {
445+
const chatHandler = new DeepSeekHandler({
446+
...mockOptions,
447+
apiModelId: "deepseek-chat",
448+
})
449+
450+
const stream = chatHandler.createMessage(systemPrompt, messages)
451+
for await (const _chunk of stream) {
452+
// Consume the stream
453+
}
454+
455+
// Verify that the thinking parameter was NOT passed to the API
456+
const callArgs = mockCreate.mock.calls[0][0]
457+
expect(callArgs.thinking).toBeUndefined()
458+
})
459+
460+
it("should handle tool calls with reasoning_content", async () => {
461+
const reasonerHandler = new DeepSeekHandler({
462+
...mockOptions,
463+
apiModelId: "deepseek-reasoner",
464+
})
465+
466+
const tools: any[] = [
467+
{
468+
type: "function",
469+
function: {
470+
name: "get_weather",
471+
description: "Get weather",
472+
parameters: { type: "object", properties: {} },
473+
},
474+
},
475+
]
476+
477+
const stream = reasonerHandler.createMessage(systemPrompt, messages, { taskId: "test", tools })
478+
const chunks: any[] = []
479+
for await (const chunk of stream) {
480+
chunks.push(chunk)
481+
}
482+
483+
// Should have reasoning chunks
484+
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
485+
expect(reasoningChunks.length).toBeGreaterThan(0)
486+
487+
// Should have tool call chunks
488+
const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
489+
expect(toolCallChunks.length).toBeGreaterThan(0)
490+
expect(toolCallChunks[0].name).toBe("get_weather")
491+
492+
// Reasoning content should be accumulated for potential continuation
493+
const reasoningContent = reasonerHandler.getReasoningContent()
494+
expect(reasoningContent).toBeDefined()
495+
})
496+
497+
it("should reset reasoning content for each new request", async () => {
498+
const reasonerHandler = new DeepSeekHandler({
499+
...mockOptions,
500+
apiModelId: "deepseek-reasoner",
501+
})
502+
503+
// First request
504+
const stream1 = reasonerHandler.createMessage(systemPrompt, messages)
505+
for await (const _chunk of stream1) {
506+
// Consume the stream
507+
}
508+
509+
const reasoningContent1 = reasonerHandler.getReasoningContent()
510+
expect(reasoningContent1).toBeDefined()
511+
512+
// Second request should reset the reasoning content
513+
const stream2 = reasonerHandler.createMessage(systemPrompt, messages)
514+
for await (const _chunk of stream2) {
515+
// Consume the stream
516+
}
517+
518+
// The reasoning content should be fresh from the second request
519+
const reasoningContent2 = reasonerHandler.getReasoningContent()
520+
expect(reasoningContent2).toBe("Let me think about this... I'll analyze step by step.")
521+
})
522+
})
320523
})

0 commit comments

Comments
 (0)