Skip to content

Commit a3505ae

Browse files
authored
feat: Add DeepSeek thinking mode support for OpenAI compatibility layer (#206)
* feat: Add DeepSeek thinking mode support for OpenAI compatibility layer - Add DeepSeek reasoning models support (deepseek-reasoner and DeepSeek-V3.2) - Automatic thinking mode detection based on model name - Inject thinking parameters in request body (both official API and vLLM formats) - Preserve reasoning_content in message conversion for tool call iterations - Extract buildOpenAIRequestBody() for testability - Treat multimodal inputs (e.g. images) as new turn boundaries - Fix env var cleanup in tests to prevent state leak Signed-off-by: guunergooner <tongchao0923@gmail.com> * docs: update contributors --------- Signed-off-by: guunergooner <tongchao0923@gmail.com> Co-authored-by: guunergooner <18660867+guunergooner@users.noreply.github.com>
1 parent 73a18c3 commit a3505ae

5 files changed

Lines changed: 599 additions & 37 deletions

File tree

contributors.svg

Lines changed: 9 additions & 9 deletions
Loading

src/services/api/openai/__tests__/convertMessages.test.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,3 +249,209 @@ describe('anthropicMessagesToOpenAI', () => {
249249
)
250250
})
251251
})
252+
253+
describe('DeepSeek thinking mode (enableThinking)', () => {
254+
test('preserves thinking block as reasoning_content when enabled', () => {
255+
const result = anthropicMessagesToOpenAI(
256+
[makeUserMsg('question'), makeAssistantMsg([
257+
{ type: 'thinking' as const, thinking: 'Let me reason about this...' },
258+
{ type: 'text', text: 'The answer is 42.' },
259+
])],
260+
[] as any,
261+
{ enableThinking: true },
262+
)
263+
// Should have: user, assistant with reasoning_content
264+
expect(result).toHaveLength(2)
265+
expect(result[0].role).toBe('user')
266+
const assistant = result[1] as any
267+
expect(assistant.role).toBe('assistant')
268+
expect(assistant.content).toBe('The answer is 42.')
269+
expect(assistant.reasoning_content).toBe('Let me reason about this...')
270+
})
271+
272+
test('drops thinking block when enableThinking is false (default)', () => {
273+
const result = anthropicMessagesToOpenAI(
274+
[makeAssistantMsg([
275+
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
276+
{ type: 'text', text: 'visible response' },
277+
])],
278+
[] as any,
279+
)
280+
const assistant = result[0] as any
281+
expect(assistant.content).toBe('visible response')
282+
expect(assistant.reasoning_content).toBeUndefined()
283+
})
284+
285+
test('preserves reasoning_content with tool_calls in same turn', () => {
286+
const result = anthropicMessagesToOpenAI(
287+
[
288+
makeUserMsg('what is the weather?'),
289+
makeAssistantMsg([
290+
{ type: 'thinking' as const, thinking: 'I need to call the weather tool.' },
291+
{ type: 'text', text: '' },
292+
{
293+
type: 'tool_use' as const,
294+
id: 'toolu_001',
295+
name: 'get_weather',
296+
input: { location: 'Hangzhou' },
297+
},
298+
]),
299+
makeUserMsg([
300+
{
301+
type: 'tool_result' as const,
302+
tool_use_id: 'toolu_001',
303+
content: 'Cloudy 7~13°C',
304+
},
305+
]),
306+
],
307+
[] as any,
308+
{ enableThinking: true },
309+
)
310+
311+
// Find the assistant message
312+
const assistants = result.filter(m => m.role === 'assistant')
313+
expect(assistants.length).toBe(1)
314+
const assistant = assistants[0] as any
315+
expect(assistant.reasoning_content).toBe('I need to call the weather tool.')
316+
expect(assistant.tool_calls).toBeDefined()
317+
expect(assistant.tool_calls[0].function.name).toBe('get_weather')
318+
})
319+
320+
test('strips reasoning_content from previous turns', () => {
321+
const result = anthropicMessagesToOpenAI(
322+
[
323+
// Turn 1: user → assistant (with thinking)
324+
makeUserMsg('question 1'),
325+
makeAssistantMsg([
326+
{ type: 'thinking' as const, thinking: 'Turn 1 reasoning...' },
327+
{ type: 'text', text: 'Turn 1 answer' },
328+
]),
329+
// Turn 2: new user message → previous reasoning should be stripped
330+
makeUserMsg('question 2'),
331+
makeAssistantMsg([
332+
{ type: 'thinking' as const, thinking: 'Turn 2 reasoning...' },
333+
{ type: 'text', text: 'Turn 2 answer' },
334+
]),
335+
],
336+
[] as any,
337+
{ enableThinking: true },
338+
)
339+
340+
const assistants = result.filter(m => m.role === 'assistant')
341+
// Turn 1 assistant: reasoning should be stripped (previous turn)
342+
expect((assistants[0] as any).reasoning_content).toBeUndefined()
343+
expect((assistants[0] as any).content).toBe('Turn 1 answer')
344+
// Turn 2 assistant: reasoning should be preserved (current turn)
345+
expect((assistants[1] as any).reasoning_content).toBe('Turn 2 reasoning...')
346+
expect((assistants[1] as any).content).toBe('Turn 2 answer')
347+
})
348+
349+
test('preserves reasoning_content in multi-iteration tool call within same turn', () => {
350+
// Simulates a full DeepSeek tool call iteration:
351+
// user → assistant(thinking+tool_call) → tool_result → assistant(thinking+tool_call) → tool_result → assistant(thinking+text)
352+
const result = anthropicMessagesToOpenAI(
353+
[
354+
makeUserMsg("tomorrow's weather in Hangzhou"),
355+
// Iteration 1: thinking + tool call
356+
makeAssistantMsg([
357+
{ type: 'thinking' as const, thinking: 'I need the date first.' },
358+
{
359+
type: 'tool_use' as const,
360+
id: 'toolu_001',
361+
name: 'get_date',
362+
input: {},
363+
},
364+
]),
365+
makeUserMsg([
366+
{
367+
type: 'tool_result' as const,
368+
tool_use_id: 'toolu_001',
369+
content: '2026-04-08',
370+
},
371+
]),
372+
// Iteration 2: thinking + tool call
373+
makeAssistantMsg([
374+
{ type: 'thinking' as const, thinking: 'Now I can get the weather.' },
375+
{
376+
type: 'tool_use' as const,
377+
id: 'toolu_002',
378+
name: 'get_weather',
379+
input: { location: 'Hangzhou', date: '2026-04-08' },
380+
},
381+
]),
382+
makeUserMsg([
383+
{
384+
type: 'tool_result' as const,
385+
tool_use_id: 'toolu_002',
386+
content: 'Cloudy 7~13°C',
387+
},
388+
]),
389+
// Iteration 3: thinking + final answer
390+
makeAssistantMsg([
391+
{ type: 'thinking' as const, thinking: 'I have the info now.' },
392+
{ type: 'text', text: 'Tomorrow will be cloudy, 7-13°C.' },
393+
]),
394+
],
395+
[] as any,
396+
{ enableThinking: true },
397+
)
398+
399+
// All 3 assistant messages are in the current turn (after last user msg is the last tool_result,
400+
// but the "last user message" boundary logic finds the last user-typed message).
401+
// Actually, tool_result messages are also UserMessage type, so the last user message
402+
// is the one with tool_result for toolu_002. All assistant messages after that should have reasoning.
403+
const assistants = result.filter(m => m.role === 'assistant')
404+
expect(assistants.length).toBe(3)
405+
// All iterations within the same turn preserve reasoning
406+
expect((assistants[0] as any).reasoning_content).toBe('I need the date first.')
407+
expect((assistants[1] as any).reasoning_content).toBe('Now I can get the weather.')
408+
expect((assistants[2] as any).reasoning_content).toBe('I have the info now.')
409+
})
410+
411+
test('handles multiple thinking blocks in single assistant message', () => {
412+
const result = anthropicMessagesToOpenAI(
413+
[makeUserMsg('question'), makeAssistantMsg([
414+
{ type: 'thinking' as const, thinking: 'First thought.' },
415+
{ type: 'thinking' as const, thinking: 'Second thought.' },
416+
{ type: 'text', text: 'Final answer.' },
417+
])],
418+
[] as any,
419+
{ enableThinking: true },
420+
)
421+
const assistant = result.filter(m => m.role === 'assistant')[0] as any
422+
expect(assistant.reasoning_content).toBe('First thought.\nSecond thought.')
423+
})
424+
425+
test('skips empty thinking blocks', () => {
426+
const result = anthropicMessagesToOpenAI(
427+
[makeUserMsg('question'), makeAssistantMsg([
428+
{ type: 'thinking' as const, thinking: '' },
429+
{ type: 'text', text: 'Answer.' },
430+
])],
431+
[] as any,
432+
{ enableThinking: true },
433+
)
434+
const assistant = result.filter(m => m.role === 'assistant')[0] as any
435+
expect(assistant.reasoning_content).toBeUndefined()
436+
})
437+
438+
test('sets content to null when only thinking and tool_calls present', () => {
439+
const result = anthropicMessagesToOpenAI(
440+
[makeUserMsg('question'), makeAssistantMsg([
441+
{ type: 'thinking' as const, thinking: 'Reasoning only.' },
442+
{
443+
type: 'tool_use' as const,
444+
id: 'toolu_001',
445+
name: 'bash',
446+
input: { command: 'ls' },
447+
},
448+
])],
449+
[] as any,
450+
{ enableThinking: true },
451+
)
452+
const assistant = result.filter(m => m.role === 'assistant')[0] as any
453+
expect(assistant.content).toBeNull()
454+
expect(assistant.reasoning_content).toBe('Reasoning only.')
455+
expect(assistant.tool_calls).toHaveLength(1)
456+
})
457+
})

0 commit comments

Comments
 (0)