@@ -1124,6 +1124,145 @@ describe("LiteLLMHandler", () => {
11241124 } )
11251125 } )
11261126
1127+ describe ( "preserveReasoning message conversion" , ( ) => {
1128+ const mockStream = {
1129+ async * [ Symbol . asyncIterator ] ( ) {
1130+ yield {
1131+ choices : [ { delta : { content : "ok" } } ] ,
1132+ usage : { prompt_tokens : 1 , completion_tokens : 1 } ,
1133+ }
1134+ } ,
1135+ }
1136+
1137+ it ( "uses convertToR1Format (merging tool-result text) when the model info sets preserveReasoning" , async ( ) => {
1138+ const optionsWithReasoning : ApiHandlerOptions = {
1139+ ...mockOptions ,
1140+ litellmModelId : "deepseek-reasoner-alias" ,
1141+ }
1142+ handler = new LiteLLMHandler ( optionsWithReasoning )
1143+
1144+ vi . spyOn ( handler as any , "fetchModel" ) . mockResolvedValue ( {
1145+ id : "deepseek-reasoner-alias" ,
1146+ info : { ...litellmDefaultModelInfo , preserveReasoning : true } ,
1147+ } )
1148+
1149+ const systemPrompt = "You are a helpful assistant"
1150+ const messages : Anthropic . Messages . MessageParam [ ] = [
1151+ { role : "user" , content : "Hello" } ,
1152+ {
1153+ role : "assistant" ,
1154+ content : [
1155+ { type : "text" , text : "I'll help." } ,
1156+ { type : "tool_use" , id : "toolu_123" , name : "read_file" , input : { path : "test.txt" } } ,
1157+ ] ,
1158+ // DeepSeek-style interleaved thinking: must be echoed back in the next request.
1159+ reasoning_content : "Let me check the file first." ,
1160+ } as Anthropic . Messages . MessageParam & { reasoning_content : string } ,
1161+ {
1162+ role : "user" ,
1163+ content : [
1164+ { type : "tool_result" , tool_use_id : "toolu_123" , content : "file contents" } ,
1165+ { type : "text" , text : "Thanks, continue." } ,
1166+ ] ,
1167+ } ,
1168+ ]
1169+
1170+ mockCreate . mockReturnValue ( {
1171+ withResponse : vi . fn ( ) . mockResolvedValue ( { data : mockStream } ) ,
1172+ } )
1173+
1174+ const generator = handler . createMessage ( systemPrompt , messages )
1175+ for await ( const _chunk of generator ) {
1176+ // Consume
1177+ }
1178+
1179+ const createCall = mockCreate . mock . calls [ 0 ] [ 0 ]
1180+
1181+ // convertToR1Format with mergeToolResultText folds the trailing text into the
1182+ // tool message instead of appending a separate user message.
1183+ const toolMessage = createCall . messages . find ( ( msg : any ) => msg . role === "tool" )
1184+ expect ( toolMessage ) . toBeDefined ( )
1185+ expect ( toolMessage . content ) . toBe ( "file contents\n\nThanks, continue." )
1186+
1187+ const trailingUserMessage = createCall . messages . find (
1188+ ( msg : any ) => msg . role === "user" && msg . content === "Thanks, continue." ,
1189+ )
1190+ expect ( trailingUserMessage ) . toBeUndefined ( )
1191+
1192+ // The whole point of routing through convertToR1Format: reasoning_content
1193+ // must survive on the assistant message so the model doesn't reject the
1194+ // follow-up request for missing prior reasoning.
1195+ const assistantMessage = createCall . messages . find (
1196+ ( msg : any ) => msg . role === "assistant" && msg . tool_calls ?. length > 0 ,
1197+ )
1198+ expect ( assistantMessage ) . toBeDefined ( )
1199+ expect ( assistantMessage . reasoning_content ) . toBe ( "Let me check the file first." )
1200+ } )
1201+
1202+ it ( "uses convertToOpenAiMessages (no merging) when the model info does not set preserveReasoning" , async ( ) => {
1203+ vi . spyOn ( handler as any , "fetchModel" ) . mockResolvedValue ( {
1204+ id : litellmDefaultModelId ,
1205+ info : { ...litellmDefaultModelInfo , preserveReasoning : undefined } ,
1206+ } )
1207+
1208+ const systemPrompt = "You are a helpful assistant"
1209+ // Task.buildCleanConversationHistory() (src/core/task/Task.ts) already strips the
1210+ // reasoning content block before messages reach this handler when the model's
1211+ // preserveReasoning is not true, so no reasoning_content/reasoning block is present
1212+ // on the assistant message here — this input reflects what the handler actually
1213+ // receives in that case.
1214+ const messages : Anthropic . Messages . MessageParam [ ] = [
1215+ { role : "user" , content : "Hello" } ,
1216+ {
1217+ role : "assistant" ,
1218+ content : [
1219+ { type : "text" , text : "I'll help." } ,
1220+ { type : "tool_use" , id : "toolu_123" , name : "read_file" , input : { path : "test.txt" } } ,
1221+ ] ,
1222+ } ,
1223+ {
1224+ role : "user" ,
1225+ content : [
1226+ { type : "tool_result" , tool_use_id : "toolu_123" , content : "file contents" } ,
1227+ { type : "text" , text : "Thanks, continue." } ,
1228+ ] ,
1229+ } ,
1230+ ]
1231+
1232+ mockCreate . mockReturnValue ( {
1233+ withResponse : vi . fn ( ) . mockResolvedValue ( { data : mockStream } ) ,
1234+ } )
1235+
1236+ const generator = handler . createMessage ( systemPrompt , messages )
1237+ for await ( const _chunk of generator ) {
1238+ // Consume
1239+ }
1240+
1241+ const createCall = mockCreate . mock . calls [ 0 ] [ 0 ]
1242+
1243+ const toolMessage = createCall . messages . find ( ( msg : any ) => msg . role === "tool" )
1244+ expect ( toolMessage ) . toBeDefined ( )
1245+ expect ( toolMessage . content ) . toBe ( "file contents" )
1246+
1247+ const trailingUserMessage = createCall . messages . find (
1248+ ( msg : any ) =>
1249+ msg . role === "user" &&
1250+ ( msg . content === "Thanks, continue." ||
1251+ ( Array . isArray ( msg . content ) &&
1252+ msg . content . some ( ( part : any ) => part . text === "Thanks, continue." ) ) ) ,
1253+ )
1254+ expect ( trailingUserMessage ) . toBeDefined ( )
1255+
1256+ // No reasoning_content is sent to the API in this branch, matching what
1257+ // buildCleanConversationHistory already stripped upstream.
1258+ const assistantMessage = createCall . messages . find (
1259+ ( msg : any ) => msg . role === "assistant" && msg . tool_calls ?. length > 0 ,
1260+ )
1261+ expect ( assistantMessage ) . toBeDefined ( )
1262+ expect ( assistantMessage . reasoning_content ) . toBeUndefined ( )
1263+ } )
1264+ } )
1265+
11271266 describe ( "session ID header" , ( ) => {
11281267 const mockStream = {
11291268 async * [ Symbol . asyncIterator ] ( ) {
0 commit comments