@@ -31,6 +31,16 @@ interface DeepSeekUsage {
3131 readonly completion_tokens ?: number | null ;
3232}
3333
34+ interface PreparedDeepSeekHistory {
35+ readonly messages : Array < Record < string , unknown > > ;
36+ readonly validToolCallIds : ReadonlySet < string > ;
37+ }
38+
39+ interface NormalizedDeepSeekTurn {
40+ readonly messages : Array < Record < string , unknown > > ;
41+ readonly validToolCallIds : ReadonlySet < string > ;
42+ }
43+
3444/**
3545 * DeepSeek's thinking-mode Chat Completions protocol has one non-OpenAI quirk:
3646 * assistant tool-call messages must be replayed with reasoning_content.
@@ -93,7 +103,7 @@ function buildRequestBody(
93103 const caps = resolveCapabilities ( config . capabilities ) ;
94104 const body : Record < string , unknown > = {
95105 model : config . model ,
96- messages : messages . map ( convertDeepSeekMessage ) ,
106+ messages : prepareDeepSeekHistory ( messages ) ,
97107 stream : true ,
98108 stream_options : { include_usage : true } ,
99109 max_tokens : config . maxTokens ?? caps . defaultMaxTokens ,
@@ -116,13 +126,96 @@ function buildRequestBody(
116126 return body ;
117127}
118128
119- function convertDeepSeekMessage ( message : Message ) : Record < string , unknown > {
120- if ( message . role === MessageRole . SYSTEM ) {
121- return { role : "system" , content : message . content ?? "" } ;
129+ function prepareDeepSeekHistory ( messages : ReadonlyArray < Message > ) : Array < Record < string , unknown > > {
130+ const prepared = normalizeDeepSeekHistory ( messages ) ;
131+ validateDeepSeekHistory ( prepared ) ;
132+ return prepared . messages ;
133+ }
134+
135+ function normalizeDeepSeekHistory ( messages : ReadonlyArray < Message > ) : PreparedDeepSeekHistory {
136+ const validToolCallIds = new Set < string > ( ) ;
137+ const converted : Array < Record < string , unknown > > = [ ] ;
138+ let turn : Message [ ] = [ ] ;
139+
140+ for ( const message of messages ) {
141+ if ( message . role === MessageRole . SYSTEM ) {
142+ flushDeepSeekTurn ( turn , converted , validToolCallIds ) ;
143+ turn = [ ] ;
144+ converted . push ( { role : "system" , content : message . content ?? "" } ) ;
145+ continue ;
146+ }
147+ if ( message . role === MessageRole . USER ) {
148+ flushDeepSeekTurn ( turn , converted , validToolCallIds ) ;
149+ turn = [ ] ;
150+ converted . push ( { role : "user" , content : message . content ?? "" } ) ;
151+ continue ;
152+ }
153+ turn . push ( message ) ;
122154 }
123- if ( message . role === MessageRole . USER ) {
124- return { role : "user" , content : message . content ?? "" } ;
155+ flushDeepSeekTurn ( turn , converted , validToolCallIds ) ;
156+
157+ return { messages : converted , validToolCallIds } ;
158+ }
159+
160+ function flushDeepSeekTurn (
161+ turn : ReadonlyArray < Message > ,
162+ converted : Array < Record < string , unknown > > ,
163+ validToolCallIds : Set < string > ,
164+ ) : void {
165+ if ( turn . length === 0 ) return ;
166+ const normalized = normalizeDeepSeekTurn ( turn ) ;
167+ for ( const id of normalized . validToolCallIds ) validToolCallIds . add ( id ) ;
168+ converted . push ( ...normalized . messages ) ;
169+ }
170+
171+ function normalizeDeepSeekTurn ( turn : ReadonlyArray < Message > ) : NormalizedDeepSeekTurn {
172+ const droppedToolCallIds = collectUnreplayableDeepSeekToolCallIds ( turn ) ;
173+ const validToolCallIds = collectReplayableDeepSeekToolCallIds ( turn ) ;
174+ const hasToolUse = validToolCallIds . size > 0 ;
175+ const messages : Array < Record < string , unknown > > = [ ] ;
176+
177+ for ( const message of turn ) {
178+ if ( shouldDropDeepSeekToolResult ( message , droppedToolCallIds , validToolCallIds ) ) continue ;
179+ const normalized = convertDeepSeekTurnMessage ( message , droppedToolCallIds , hasToolUse ) ;
180+ if ( normalized ) messages . push ( normalized ) ;
181+ }
182+
183+ return { messages, validToolCallIds } ;
184+ }
185+
186+ function collectUnreplayableDeepSeekToolCallIds ( messages : ReadonlyArray < Message > ) : Set < string > {
187+ const dropped = new Set < string > ( ) ;
188+ for ( const message of messages ) {
189+ if ( message . role !== MessageRole . ASSISTANT || message . thinking || ! message . toolCalls ?. length ) continue ;
190+ for ( const toolCall of message . toolCalls ) dropped . add ( toolCall . callId ) ;
191+ }
192+ return dropped ;
193+ }
194+
195+ function collectReplayableDeepSeekToolCallIds ( messages : ReadonlyArray < Message > ) : Set < string > {
196+ const valid = new Set < string > ( ) ;
197+ for ( const message of messages ) {
198+ if ( message . role !== MessageRole . ASSISTANT || ! message . thinking || ! message . toolCalls ?. length ) continue ;
199+ for ( const toolCall of message . toolCalls ) valid . add ( toolCall . callId ) ;
125200 }
201+ return valid ;
202+ }
203+
204+ function shouldDropDeepSeekToolResult (
205+ message : Message ,
206+ droppedToolCallIds : ReadonlySet < string > ,
207+ validToolCallIds : ReadonlySet < string > ,
208+ ) : boolean {
209+ if ( message . role !== MessageRole . TOOL ) return false ;
210+ if ( ! message . toolCallId ) return true ;
211+ return droppedToolCallIds . has ( message . toolCallId ) || ! validToolCallIds . has ( message . toolCallId ) ;
212+ }
213+
214+ function convertDeepSeekTurnMessage (
215+ message : Message ,
216+ droppedToolCallIds : ReadonlySet < string > ,
217+ hasToolUse : boolean ,
218+ ) : Record < string , unknown > | null {
126219 if ( message . role === MessageRole . TOOL ) {
127220 return {
128221 role : "tool" ,
@@ -135,22 +228,90 @@ function convertDeepSeekMessage(message: Message): Record<string, unknown> {
135228 role : "assistant" ,
136229 content : message . content ?? "" ,
137230 } ;
138- if ( message . thinking && message . toolCalls ?. length ) {
139- converted [ "reasoning_content" ] = message . thinking ;
231+ if ( ! message . toolCalls ?. length ) {
232+ if ( message . thinking && hasToolUse ) converted [ "reasoning_content" ] = message . thinking ;
233+ return hasToolUse && ! message . thinking ? null : converted ;
140234 }
141- if ( message . toolCalls ?. length ) {
142- converted [ "tool_calls" ] = message . toolCalls . map ( ( toolCall ) => ( {
143- id : toolCall . callId ,
144- type : "function" ,
145- function : {
146- name : toolCall . name ,
147- arguments : JSON . stringify ( toolCall . arguments ) ,
148- } ,
149- } ) ) ;
235+ if ( ! message . thinking ) {
236+ return hasToolUse ? null : message . content ?. trim ( ) ? converted : null ;
150237 }
238+
239+ const toolCalls = message . toolCalls . filter ( ( toolCall ) => ! droppedToolCallIds . has ( toolCall . callId ) ) ;
240+ if ( toolCalls . length === 0 ) return message . content ?. trim ( ) ? converted : null ;
241+
242+ converted [ "reasoning_content" ] = message . thinking ;
243+ converted [ "tool_calls" ] = toolCalls . map ( convertDeepSeekToolCall ) ;
151244 return converted ;
152245}
153246
247+ function convertDeepSeekToolCall ( toolCall : NonNullable < Message [ "toolCalls" ] > [ number ] ) : Record < string , unknown > {
248+ return {
249+ id : toolCall . callId ,
250+ type : "function" ,
251+ function : {
252+ name : toolCall . name ,
253+ arguments : JSON . stringify ( toolCall . arguments ) ,
254+ } ,
255+ } ;
256+ }
257+
258+ function validateDeepSeekHistory ( prepared : PreparedDeepSeekHistory ) : void {
259+ for ( const turn of splitDeepSeekHistoryTurns ( prepared . messages ) ) {
260+ const hasToolUse = turn . some ( ( message ) => readDeepSeekToolCalls ( message ) . length > 0 ) ;
261+ for ( const message of turn ) {
262+ validateDeepSeekAssistantMessage ( message , hasToolUse ) ;
263+ validateDeepSeekToolMessage ( message , prepared . validToolCallIds ) ;
264+ }
265+ }
266+ }
267+
268+ function splitDeepSeekHistoryTurns ( messages : ReadonlyArray < Record < string , unknown > > ) : Array < Array < Record < string , unknown > > > {
269+ const turns : Array < Array < Record < string , unknown > > > = [ ] ;
270+ let current : Array < Record < string , unknown > > = [ ] ;
271+ for ( const message of messages ) {
272+ if ( message [ "role" ] === "user" || message [ "role" ] === "system" ) {
273+ if ( current . length > 0 ) turns . push ( current ) ;
274+ current = [ message ] ;
275+ } else {
276+ current . push ( message ) ;
277+ }
278+ }
279+ if ( current . length > 0 ) turns . push ( current ) ;
280+ return turns ;
281+ }
282+
283+ function validateDeepSeekAssistantMessage ( message : Record < string , unknown > , hasToolUse : boolean ) : void {
284+ if ( message [ "role" ] !== "assistant" ) return ;
285+ const toolCalls = readDeepSeekToolCalls ( message ) ;
286+ for ( const toolCall of toolCalls ) {
287+ if ( typeof toolCall . id !== "string" || toolCall . id . length === 0 ) {
288+ throw new ProviderError ( "DeepSeek history error: assistant tool calls require non-empty ids" ) ;
289+ }
290+ }
291+ if ( toolCalls . length > 0 && typeof message [ "reasoning_content" ] !== "string" ) {
292+ throw new ProviderError ( "DeepSeek history error: assistant tool calls require reasoning_content" ) ;
293+ }
294+ if ( toolCalls . length === 0 && "reasoning_content" in message && ! hasToolUse ) {
295+ throw new ProviderError ( "DeepSeek history error: final assistant messages must not include reasoning_content" ) ;
296+ }
297+ }
298+
299+ function validateDeepSeekToolMessage (
300+ message : Record < string , unknown > ,
301+ validToolCallIds : ReadonlySet < string > ,
302+ ) : void {
303+ if ( message [ "role" ] !== "tool" ) return ;
304+ const toolCallId = message [ "tool_call_id" ] ;
305+ if ( typeof toolCallId !== "string" || ! validToolCallIds . has ( toolCallId ) ) {
306+ throw new ProviderError ( "DeepSeek history error: tool result does not match an assistant tool call" ) ;
307+ }
308+ }
309+
310+ function readDeepSeekToolCalls ( message : Record < string , unknown > ) : Array < { id ?: unknown } > {
311+ const toolCalls = message [ "tool_calls" ] ;
312+ return Array . isArray ( toolCalls ) ? toolCalls as Array < { id ?: unknown } > : [ ] ;
313+ }
314+
154315function convertDeepSeekTool ( tool : ToolSpec ) : Record < string , unknown > {
155316 return {
156317 type : "function" ,
0 commit comments