Skip to content

Commit 4939f94

Browse files
authored
feat(adapters): update DeepSeek to support thinking mode and v4 models (#3062)
1 parent 23532cf commit 4939f94

12 files changed

Lines changed: 706 additions & 447 deletions

lua/codecompanion/adapters/http/deepseek.lua

Lines changed: 219 additions & 114 deletions
Large diffs are not rendered by default.
Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1 @@
1-
{
2-
"id": "chatcmpl-ADx5bEkzrSB6WjrnB9ce1ofWcaOAq",
3-
"object": "chat.completion",
4-
"created": 1727888767,
5-
"model": "deepseek-chat",
6-
"choices": [
7-
{
8-
"index": 0,
9-
"message": {
10-
"role": "assistant",
11-
"content": "Elegant simplicity.",
12-
"refusal": null
13-
},
14-
"logprobs": null,
15-
"finish_reason": "stop"
16-
}
17-
],
18-
"usage": {
19-
"prompt_tokens": 343,
20-
"completion_tokens": 3,
21-
"total_tokens": 346,
22-
"prompt_tokens_details": {
23-
"cached_tokens": 0
24-
},
25-
"completion_tokens_details": {
26-
"reasoning_tokens": 0
27-
}
28-
},
29-
"system_fingerprint": "fp_5796ac6771"
30-
}
1+
{"id":"ca514397-36e9-4f61-93b9-71b99ecdc350","object":"chat.completion","created":1777189207,"model":"deepseek-v4-flash","choices":[{"index":0,"message":{"role":"assistant","content":"**Elegant syntax.**"},"logprobs":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":6,"total_tokens":15,"prompt_tokens_details":{"cached_tokens":0},"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":9},"system_fingerprint":"fp_058df29938_prod0820_fp8_kvcache_20260402"}

tests/adapters/http/stubs/deepseek_streaming.txt

Lines changed: 170 additions & 187 deletions
Large diffs are not rendered by default.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
data: {"id":"757709a8-9c82-4b7c-8b40-cbd2c70ac264","object":"chat.completion.chunk","created":1777188677,"model":"deepseek-v4-flash","system_fingerprint":"fp_058df29938_prod0820_fp8_kvcache_20260402","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}],"usage":null}
2+
data: {"id":"757709a8-9c82-4b7c-8b40-cbd2c70ac264","object":"chat.completion.chunk","created":1777188677,"model":"deepseek-v4-flash","system_fingerprint":"fp_058df29938_prod0820_fp8_kvcache_20260402","choices":[{"index":0,"delta":{"content":"E"},"logprobs":null,"finish_reason":null}],"usage":null}
3+
data: {"id":"757709a8-9c82-4b7c-8b40-cbd2c70ac264","object":"chat.completion.chunk","created":1777188677,"model":"deepseek-v4-flash","system_fingerprint":"fp_058df29938_prod0820_fp8_kvcache_20260402","choices":[{"index":0,"delta":{"content":"leg"},"logprobs":null,"finish_reason":null}],"usage":null}
4+
data: {"id":"757709a8-9c82-4b7c-8b40-cbd2c70ac264","object":"chat.completion.chunk","created":1777188677,"model":"deepseek-v4-flash","system_fingerprint":"fp_058df29938_prod0820_fp8_kvcache_20260402","choices":[{"index":0,"delta":{"content":"ant"},"logprobs":null,"finish_reason":null}],"usage":null}
5+
data: {"id":"757709a8-9c82-4b7c-8b40-cbd2c70ac264","object":"chat.completion.chunk","created":1777188677,"model":"deepseek-v4-flash","system_fingerprint":"fp_058df29938_prod0820_fp8_kvcache_20260402","choices":[{"index":0,"delta":{"content":" syntax"},"logprobs":null,"finish_reason":null}],"usage":null}
6+
data: {"id":"757709a8-9c82-4b7c-8b40-cbd2c70ac264","object":"chat.completion.chunk","created":1777188677,"model":"deepseek-v4-flash","system_fingerprint":"fp_058df29938_prod0820_fp8_kvcache_20260402","choices":[{"index":0,"delta":{"content":"."},"logprobs":null,"finish_reason":null}],"usage":null}
7+
data: {"id":"757709a8-9c82-4b7c-8b40-cbd2c70ac264","object":"chat.completion.chunk","created":1777188677,"model":"deepseek-v4-flash","system_fingerprint":"fp_058df29938_prod0820_fp8_kvcache_20260402","choices":[{"index":0,"delta":{"content":""},"logprobs":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":5,"total_tokens":14,"prompt_tokens_details":{"cached_tokens":0},"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":9}}
8+
data: [DONE]

tests/adapters/http/stubs/deepseek_tools_no_params_streaming.txt

Lines changed: 104 additions & 17 deletions
Large diffs are not rendered by default.

tests/adapters/http/stubs/deepseek_tools_streaming.txt

Lines changed: 73 additions & 30 deletions
Large diffs are not rendered by default.

tests/adapters/http/stubs/output/deepseek_tools.lua

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,34 +13,32 @@ return {
1313
role = "assistant",
1414
tool_calls = {
1515
{
16-
_index = 0,
1716
["function"] = {
18-
arguments = '{"location": "London", "units": "celsius"}',
17+
arguments = '{"location": "London, UK", "units": "celsius"}',
1918
name = "weather",
2019
},
21-
id = "call_0_bb2a2194-a723-44a6-a1f8-bd05e9829eea",
20+
id = "call_00_xzVVyar4M7TXmqAvwt5lz3v2",
2221
type = "function",
2322
},
2423
{
25-
_index = 1,
2624
["function"] = {
27-
arguments = '{"location": "Paris", "units": "celsius"}',
25+
arguments = '{"location": "Paris, France", "units": "celsius"}',
2826
name = "weather",
2927
},
30-
id = "call_1_a460d461-60a7-468c-a699-ef9e2dced125",
28+
id = "call_01_FiLq2fgCjbR43jdNrxI4OYGD",
3129
type = "function",
3230
},
3331
},
3432
},
3533
{
36-
content = "Ran the weather tool The weather in London is 15° celsius",
34+
content = "Ran the weather tool The weather in London, UK is 15° celsius",
3735
role = "tool",
38-
tool_call_id = "call_0_bb2a2194-a723-44a6-a1f8-bd05e9829eea",
36+
tool_call_id = "call_00_xzVVyar4M7TXmqAvwt5lz3v2",
3937
},
4038
{
41-
content = "Ran the weather tool The weather in Paris is 15° celsius",
39+
content = "Ran the weather tool The weather in Paris, France is 15° celsius",
4240
role = "tool",
43-
tool_call_id = "call_1_a460d461-60a7-468c-a699-ef9e2dced125",
41+
tool_call_id = "call_01_FiLq2fgCjbR43jdNrxI4OYGD",
4442
},
4543
},
4644
}

tests/adapters/http/stubs/output/deepseek_tools_no_params.lua

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,38 +9,36 @@ return {
99
role = "user",
1010
},
1111
{
12-
content = "",
12+
content = "Let me check the weather for both locations.",
1313
role = "assistant",
1414
tool_calls = {
1515
{
16-
_index = 0,
1716
["function"] = {
18-
arguments = "",
17+
arguments = "{}",
1918
name = "weather_with_default",
2019
},
21-
id = "call_0_bb2a2194-a723-44a6-a1f8-bd05e9829eea",
20+
id = "call_00_YOblREljHrrLmGtaHE72LNh3",
2221
type = "function",
2322
},
2423
{
25-
_index = 1,
2624
["function"] = {
27-
arguments = '{"location": "Paris", "units": "celsius"}',
25+
arguments = '{"location": "Paris, France", "units": "celsius"}',
2826
name = "weather_with_default",
2927
},
30-
id = "call_1_a460d461-60a7-468c-a699-ef9e2dced125",
28+
id = "call_01_bKIQfFOpGabMlK7midnRZaBQ",
3129
type = "function",
3230
},
3331
},
3432
},
3533
{
3634
content = "Ran the weather tool The weather in London, UK is 15° celsius",
3735
role = "tool",
38-
tool_call_id = "call_0_bb2a2194-a723-44a6-a1f8-bd05e9829eea",
36+
tool_call_id = "call_00_YOblREljHrrLmGtaHE72LNh3",
3937
},
4038
{
41-
content = "Ran the weather tool The weather in Paris is 15° celsius",
39+
content = "Ran the weather tool The weather in Paris, France is 15° celsius",
4240
role = "tool",
43-
tool_call_id = "call_1_a460d461-60a7-468c-a699-ef9e2dced125",
41+
tool_call_id = "call_01_bKIQfFOpGabMlK7midnRZaBQ",
4442
},
4543
},
4644
}

tests/adapters/http/test_deepseek.lua

Lines changed: 95 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,18 @@ T["DeepSeek adapter"] = new_set({
1313
},
1414
})
1515

16-
T["DeepSeek adapter"]["form_messages"] = new_set()
16+
T["DeepSeek adapter"]["build_messages"] = new_set()
1717

18-
T["DeepSeek adapter"]["form_messages"]["it can form messages to be sent to the API"] = function()
18+
T["DeepSeek adapter"]["build_messages"]["it can form messages to be sent to the API"] = function()
1919
local messages = { {
2020
content = "Explain Ruby in two words",
2121
role = "user",
2222
} }
2323

24-
h.eq({ messages = messages }, adapter.handlers.form_messages(adapter, messages))
24+
h.eq({ messages = messages }, adapter.handlers.request.build_messages(adapter, messages))
2525
end
2626

27-
T["DeepSeek adapter"]["form_messages"]["merges consecutive messages with the same role"] = function()
27+
T["DeepSeek adapter"]["build_messages"]["merges consecutive messages with the same role"] = function()
2828
local input = {
2929
{ role = "user", content = "A" },
3030
{ role = "user", content = "B" },
@@ -41,10 +41,10 @@ T["DeepSeek adapter"]["form_messages"]["merges consecutive messages with the sam
4141
},
4242
}
4343

44-
h.eq(expected, adapter.handlers.form_messages(adapter, input))
44+
h.eq(expected, adapter.handlers.request.build_messages(adapter, input))
4545
end
4646

47-
T["DeepSeek adapter"]["form_messages"]["merges system messages together at the start of the message chain"] = function()
47+
T["DeepSeek adapter"]["build_messages"]["merges system messages together at the start of the message chain"] = function()
4848
local input = {
4949
{ role = "system", content = "System Prompt 1" },
5050
{ role = "user", content = "User1" },
@@ -65,10 +65,10 @@ T["DeepSeek adapter"]["form_messages"]["merges system messages together at the s
6565
},
6666
}
6767

68-
h.eq(expected, adapter.handlers.form_messages(adapter, input))
68+
h.eq(expected, adapter.handlers.request.build_messages(adapter, input))
6969
end
7070

71-
T["DeepSeek adapter"]["form_messages"]["ensures message content is a string and not a list"] = function()
71+
T["DeepSeek adapter"]["build_messages"]["ensures message content is a string and not a list"] = function()
7272
-- Ref: https://github.com/BerriAI/litellm/issues/6642
7373
local input = {
7474
{ role = "user", content = "Describe Ruby in two words" },
@@ -88,10 +88,10 @@ T["DeepSeek adapter"]["form_messages"]["ensures message content is a string and
8888
},
8989
}
9090

91-
h.eq(expected, adapter.handlers.form_messages(adapter, input))
91+
h.eq(expected, adapter.handlers.request.build_messages(adapter, input))
9292
end
9393

94-
T["DeepSeek adapter"]["form_messages"]["it can form messages with tools"] = function()
94+
T["DeepSeek adapter"]["build_messages"]["it can form messages with tools"] = function()
9595
local input = {
9696
{ role = "system", content = "System Prompt 1" },
9797
{ role = "user", content = "User1" },
@@ -157,10 +157,10 @@ T["DeepSeek adapter"]["form_messages"]["it can form messages with tools"] = func
157157
},
158158
}
159159

160-
h.eq(expected, adapter.handlers.form_messages(adapter, input))
160+
h.eq(expected, adapter.handlers.request.build_messages(adapter, input))
161161
end
162162

163-
T["DeepSeek adapter"]["form_messages"]["it can form tools to be sent to the API"] = function()
163+
T["DeepSeek adapter"]["build_messages"]["it can form tools to be sent to the API"] = function()
164164
adapter = require("codecompanion.adapters").extend("deepseek", {
165165
schema = {
166166
model = {
@@ -172,7 +172,20 @@ T["DeepSeek adapter"]["form_messages"]["it can form tools to be sent to the API"
172172
local weather = require("tests.interactions.chat.tools.builtin.stubs.weather").schema
173173
local tools = { weather = { weather } }
174174

175-
h.eq({ tools = { weather } }, adapter.handlers.form_tools(adapter, tools))
175+
h.eq({ tools = { weather } }, adapter.handlers.request.build_tools(adapter, tools))
176+
end
177+
178+
T["DeepSeek adapter"]["build_messages"]["includes reasoning_content in messages"] = function()
179+
local input = {
180+
{ role = "user", content = "What is Ruby?" },
181+
{ role = "assistant", content = "", reasoning = "Let me think about Ruby..." },
182+
{ role = "user", content = "In two words" },
183+
}
184+
185+
local result = adapter.handlers.request.build_messages(adapter, input)
186+
187+
-- reasoning is normalized to a string by build_reasoning before message storage
188+
h.eq("Let me think about Ruby...", result.messages[2].reasoning_content)
176189
end
177190

178191
T["DeepSeek adapter"]["Streaming"] = new_set()
@@ -181,12 +194,12 @@ T["DeepSeek adapter"]["Streaming"]["can output streamed data into a format for t
181194
local lines = vim.fn.readfile("tests/adapters/http/stubs/deepseek_streaming.txt")
182195
local output = ""
183196
for _, line in ipairs(lines) do
184-
output = output .. (adapter.handlers.chat_output(adapter, line).output.content or "")
197+
local chat_output = adapter.handlers.response.parse_chat(adapter, line)
198+
if chat_output then
199+
output = output .. (chat_output.output.content or "")
200+
end
185201
end
186-
h.eq(
187-
"Dynamic. Expressive.\n\nNext, you might ask about Ruby's key features or how it compares to other languages.",
188-
output
189-
)
202+
h.eq("Elegant simplicity", output)
190203
end
191204

192205
T["DeepSeek adapter"]["Streaming"]["can handle reasoning content when streaming"] = function()
@@ -196,12 +209,11 @@ T["DeepSeek adapter"]["Streaming"]["can handle reasoning content when streaming"
196209
content = "",
197210
},
198211
}
199-
200212
local lines = vim.fn.readfile("tests/adapters/http/stubs/deepseek_streaming.txt")
201213
for _, line in ipairs(lines) do
202-
local chat_output = adapter.handlers.chat_output(adapter, line)
203-
if adapter.handlers.parse_message_meta and chat_output.extra then
204-
chat_output = adapter.handlers.parse_message_meta(adapter, chat_output)
214+
local chat_output = adapter.handlers.response.parse_chat(adapter, line)
215+
if chat_output and adapter.handlers.response.parse_meta and chat_output.extra then
216+
chat_output = adapter.handlers.response.parse_meta(adapter, chat_output)
205217
end
206218
if chat_output then
207219
if chat_output.output.reasoning and chat_output.output.reasoning.content then
@@ -212,34 +224,86 @@ T["DeepSeek adapter"]["Streaming"]["can handle reasoning content when streaming"
212224
end
213225
end
214226
end
227+
h.expect_starts_with("We need to explain Ruby in two words.", output.reasoning.content)
228+
end
215229

216-
h.expect_starts_with("Okay, the user wants me to explain Ruby in two words. ", output.reasoning.content)
230+
T["DeepSeek adapter"]["Streaming"]["can output streamed data without reasoning"] = function()
231+
local lines = vim.fn.readfile("tests/adapters/http/stubs/deepseek_streaming_reasoning_disabled.txt")
232+
local output = ""
233+
local reasoning = ""
234+
for _, line in ipairs(lines) do
235+
local chat_output = adapter.handlers.response.parse_chat(adapter, line)
236+
if chat_output then
237+
if chat_output.extra and adapter.handlers.response.parse_meta then
238+
chat_output = adapter.handlers.response.parse_meta(adapter, chat_output)
239+
end
240+
if chat_output.output.content then
241+
output = output .. chat_output.output.content
242+
end
243+
if chat_output.output.reasoning and chat_output.output.reasoning.content then
244+
reasoning = reasoning .. chat_output.output.reasoning.content
245+
end
246+
end
247+
end
248+
h.eq("Elegant syntax.", output)
249+
h.eq("", reasoning) -- No reasoning content when thinking is disabled
217250
end
218251

219252
T["DeepSeek adapter"]["Streaming"]["can process tools"] = function()
220253
local tools = {}
221254
local lines = vim.fn.readfile("tests/adapters/http/stubs/deepseek_tools_streaming.txt")
222255
for _, line in ipairs(lines) do
223-
adapter.handlers.chat_output(adapter, line, tools)
256+
adapter.handlers.response.parse_chat(adapter, line, tools)
224257
end
225258

226259
local tool_output = {
227260
{
228261
_index = 0,
229262
["function"] = {
230-
arguments = '{"location": "London", "units": "celsius"}',
263+
arguments = '{"location": "London, UK", "units": "celsius"}',
231264
name = "weather",
232265
},
233-
id = "call_0_bb2a2194-a723-44a6-a1f8-bd05e9829eea",
266+
id = "call_00_xzVVyar4M7TXmqAvwt5lz3v2",
234267
type = "function",
235268
},
236269
{
237270
_index = 1,
238271
["function"] = {
239-
arguments = '{"location": "Paris", "units": "celsius"}',
272+
arguments = '{"location": "Paris, France", "units": "celsius"}',
240273
name = "weather",
241274
},
242-
id = "call_1_a460d461-60a7-468c-a699-ef9e2dced125",
275+
id = "call_01_FiLq2fgCjbR43jdNrxI4OYGD",
276+
type = "function",
277+
},
278+
}
279+
280+
h.eq(tool_output, tools)
281+
end
282+
283+
T["DeepSeek adapter"]["Streaming"]["can process tools without params"] = function()
284+
local tools = {}
285+
local lines = vim.fn.readfile("tests/adapters/http/stubs/deepseek_tools_no_params_streaming.txt")
286+
for _, line in ipairs(lines) do
287+
adapter.handlers.response.parse_chat(adapter, line, tools)
288+
end
289+
290+
local tool_output = {
291+
{
292+
_index = 0,
293+
["function"] = {
294+
arguments = "{}",
295+
name = "weather_with_default",
296+
},
297+
id = "call_00_YOblREljHrrLmGtaHE72LNh3",
298+
type = "function",
299+
},
300+
{
301+
_index = 1,
302+
["function"] = {
303+
arguments = '{"location": "Paris, France", "units": "celsius"}',
304+
name = "weather_with_default",
305+
},
306+
id = "call_01_bKIQfFOpGabMlK7midnRZaBQ",
243307
type = "function",
244308
},
245309
}
@@ -265,7 +329,7 @@ T["DeepSeek adapter"]["No Streaming"]["can output for the chat buffer"] = functi
265329
local data = vim.fn.readfile("tests/adapters/http/stubs/deepseek_no_streaming.txt")
266330
data = table.concat(data, "\n")
267331

268-
h.eq("Elegant simplicity.", adapter.handlers.chat_output(adapter, data).output.content)
332+
h.eq("**Elegant syntax.**", adapter.handlers.response.parse_chat(adapter, data).output.content)
269333
end
270334

271335
T["DeepSeek adapter"]["No Streaming"]["can process tools"] = function()
@@ -276,7 +340,7 @@ T["DeepSeek adapter"]["No Streaming"]["can process tools"] = function()
276340

277341
-- Match the format of the actual request
278342
local json = { body = data }
279-
adapter.handlers.chat_output(adapter, json, tools)
343+
adapter.handlers.response.parse_chat(adapter, json, tools)
280344

281345
local tool_output = {
282346
{
@@ -309,7 +373,7 @@ T["DeepSeek adapter"]["No Streaming"]["can output for the inline assistant"] = f
309373
-- Match the format of the actual request
310374
local json = { body = data }
311375

312-
h.eq("Elegant simplicity.", adapter.handlers.inline_output(adapter, json).output)
376+
h.eq("**Elegant syntax.**", adapter.handlers.response.parse_inline(adapter, json).output)
313377
end
314378

315379
return T

0 commit comments

Comments
 (0)