@@ -28,6 +28,16 @@ using json = nlohmann::ordered_json;
2828
2929namespace minja {
3030
31+ enum class ThinkingPattern {
32+ NONE, // Template doesn't support thinking
33+ REASONING_CONTENT_FIELD, // Pattern A: message.reasoning_content (Qwen3, GLM-4.6/4.7)
34+ CONTENT_BLOCK_THINKING, // Pattern B: content[].type == "thinking" (Ministral)
35+ CONTENT_BLOCK_THOUGHTS, // Pattern C: content[].type == "thoughts" (Apertus)
36+ THOUGHT_FIELD, // Pattern D: message.thought (MiniCPM3)
37+ TOOL_PLAN_FIELD, // Pattern E: message.tool_plan (Command-R7B)
38+ THINKING_FIELD, // Pattern F: message.thinking (GPT-OSS-120B)
39+ };
40+
3141struct chat_template_caps {
3242 bool supports_tools = false ;
3343 bool supports_tool_calls = false ;
@@ -44,11 +54,18 @@ struct chat_template_caps {
4454 bool requires_typed_content = false ;
4555
4656 // Thinking / reasoning capabilities
47- bool supports_thinking = false ; // Template supports reasoning_content field
57+ bool supports_thinking = false ; // Template supports some form of reasoning
4858 bool supports_disable_thinking = true ; // Template respects enable_thinking=false
4959 bool supports_reasoning_only = true ; // Can emit reasoning without content/tool calls
5060 bool supports_reasoning_with_content = true ; // Can mix content text with reasoning
51- bool reasoning_requires_tools = false ; // Reasoning only appears when tools present
61+ bool reasoning_requires_tools = false ; // Reasoning only appears when tools present
62+
63+ // Thinking pattern details
64+ ThinkingPattern thinking_pattern = ThinkingPattern::NONE;
65+
66+ // Whether template supports clear_thinking flag (GLM-4.7 pattern)
67+ // When clear_thinking=false, all reasoning is shown; when true/undefined, position-based visibility
68+ bool supports_clear_thinking = false ;
5269};
5370
5471struct chat_template_inputs {
@@ -72,6 +89,8 @@ struct chat_template_options {
7289 bool polyfill_system_role = true ;
7390 bool polyfill_object_arguments = true ;
7491 bool polyfill_typed_content = true ;
92+ // Convert reasoning_content to template's native format (thought, thinking, tool_plan)
93+ bool polyfill_reasoning = true ;
7594};
7695
7796class chat_template {
@@ -247,11 +266,11 @@ class chat_template {
247266
248267 // Detect thinking / reasoning capabilities
249268 const std::string reasoning_needle = " <REASONING_NEEDLE>" ;
250- auto make_reasoning_msg = [&](const json & content) {
251- json msg = {
252- { " role " , " assistant " },
253- { " reasoning_content " , reasoning_needle},
254- };
269+ auto make_assistant_msg = [&](const json & extra_fields, const json & content = json () ) {
270+ json msg = {{ " role " , " assistant " }};
271+ for ( auto & [key, val] : extra_fields. items ()) {
272+ msg[key] = val;
273+ }
255274 if (!content.is_null ()) {
256275 msg[" content" ] = content;
257276 } else if (caps_.requires_non_null_content ) {
@@ -260,12 +279,112 @@ class chat_template {
260279 return msg;
261280 };
262281
263- // Test if template supports reasoning_content field
282+ // Pattern A: reasoning_content field (Qwen3, GLM-4.6/4.7)
283+ out = try_raw_render (json::array ({
284+ dummy_user_msg,
285+ make_assistant_msg ({{" reasoning_content" , reasoning_needle}}),
286+ }), {}, false );
287+ bool supports_reasoning_content = contains (out, reasoning_needle);
288+
289+ // Pattern D: thought field (MiniCPM3)
264290 out = try_raw_render (json::array ({
265291 dummy_user_msg,
266- make_reasoning_msg ( json () ),
292+ make_assistant_msg ({{ " thought " , reasoning_needle}}, " response " ),
267293 }), {}, false );
268- caps_.supports_thinking = contains (out, reasoning_needle);
294+ bool supports_thought_field = contains (out, reasoning_needle);
295+
296+ // Pattern F: thinking field (GPT-OSS-120B style)
297+ out = try_raw_render (json::array ({
298+ dummy_user_msg,
299+ make_assistant_msg ({{" thinking" , reasoning_needle}}, " response" ),
300+ }), {}, false );
301+ bool supports_thinking_field = contains (out, reasoning_needle);
302+
303+ // Pattern B: content blocks with type="thinking" (Ministral)
304+ // To detect stringification, we check if the output contains structural markers
305+ // like '"type"' or "'type'" which would appear in serialized JSON/Python
306+ json content_block_thinking_msg = {
307+ {" role" , " assistant" },
308+ {" content" , json::array ({
309+ {{" type" , " thinking" }, {" thinking" , reasoning_needle}},
310+ {{" type" , " text" }, {" text" , " response" }}
311+ })}
312+ };
313+ out = try_raw_render (json::array ({dummy_user_msg, content_block_thinking_msg}), {}, false );
314+ // Real support: needle appears but structural markers don't (template extracts content)
315+ // Stringified: needle appears with structural markers (template just serializes the object)
316+ bool supports_content_block_thinking = contains (out, reasoning_needle)
317+ && !contains (out, " \" type\" " ) && !contains (out, " 'type'" );
318+
319+ // Pattern C: content blocks with type="thoughts" (Apertus)
320+ json content_block_thoughts_msg = {
321+ {" role" , " assistant" },
322+ {" content" , json::array ({
323+ {{" type" , " thoughts" }, {" text" , reasoning_needle}},
324+ {{" type" , " text" }, {" text" , " response" }}
325+ })}
326+ };
327+ out = try_raw_render (json::array ({dummy_user_msg, content_block_thoughts_msg}), {}, false );
328+ bool supports_content_block_thoughts = contains (out, reasoning_needle)
329+ && !contains (out, " \" type\" " ) && !contains (out, " 'type'" );
330+
331+ // Pattern E: tool_plan field (Command-R7B) - requires tool_calls
332+ bool supports_tool_plan_field = false ;
333+ if (caps_.supports_tool_calls ) {
334+ auto dummy_args = caps_.requires_object_arguments ? dummy_args_obj : json (dummy_args_obj.dump ());
335+ json tool_plan_msg = {
336+ {" role" , " assistant" },
337+ {" content" , caps_.requires_non_null_content ? " " : json ()},
338+ {" tool_plan" , reasoning_needle},
339+ {" tool_calls" , json::array ({make_tool_call (" test_tool" , dummy_args)})},
340+ };
341+ out = try_raw_render (json::array ({
342+ dummy_user_msg,
343+ tool_plan_msg,
344+ }), {}, false );
345+ supports_tool_plan_field = contains (out, reasoning_needle);
346+ }
347+
348+ // Determine the primary thinking pattern (in priority order)
349+ // Field-based patterns are checked first as they are more specific
350+ // Content block patterns are checked last as many templates just stringify unknown content
351+ if (supports_reasoning_content) {
352+ caps_.supports_thinking = true ;
353+ caps_.thinking_pattern = ThinkingPattern::REASONING_CONTENT_FIELD;
354+ } else if (supports_thought_field) {
355+ caps_.supports_thinking = true ;
356+ caps_.thinking_pattern = ThinkingPattern::THOUGHT_FIELD;
357+ } else if (supports_thinking_field) {
358+ caps_.supports_thinking = true ;
359+ caps_.thinking_pattern = ThinkingPattern::THINKING_FIELD;
360+ } else if (supports_tool_plan_field) {
361+ caps_.supports_thinking = true ;
362+ caps_.thinking_pattern = ThinkingPattern::TOOL_PLAN_FIELD;
363+ caps_.reasoning_requires_tools = true ;
364+ } else if (supports_content_block_thinking) {
365+ caps_.supports_thinking = true ;
366+ caps_.thinking_pattern = ThinkingPattern::CONTENT_BLOCK_THINKING;
367+ } else if (supports_content_block_thoughts) {
368+ caps_.supports_thinking = true ;
369+ caps_.thinking_pattern = ThinkingPattern::CONTENT_BLOCK_THOUGHTS;
370+ }
371+
372+ // Test clear_thinking support (GLM-4.7 pattern)
373+ // When clear_thinking=false is passed, template should show all reasoning
374+ if (caps_.thinking_pattern == ThinkingPattern::REASONING_CONTENT_FIELD) {
375+ // Test with multiple assistant messages and clear_thinking=false
376+ const std::string first_reasoning = " <FIRST_REASONING>" ;
377+ const std::string second_reasoning = " <SECOND_REASONING>" ;
378+ json extra_ctx = {{" clear_thinking" , false }};
379+ out = try_raw_render (json::array ({
380+ dummy_user_msg,
381+ make_assistant_msg ({{" reasoning_content" , first_reasoning}}, " first" ),
382+ dummy_user_msg,
383+ make_assistant_msg ({{" reasoning_content" , second_reasoning}}, " second" ),
384+ }), {}, false , extra_ctx);
385+ // If both reasonings are visible with clear_thinking=false, template supports it
386+ caps_.supports_clear_thinking = contains (out, first_reasoning) && contains (out, second_reasoning);
387+ }
269388
270389 try {
271390 if (!caps_.supports_tools ) {
@@ -371,6 +490,7 @@ class chat_template {
371490 auto has_tool_calls = false ;
372491 auto has_tool_responses = false ;
373492 auto has_string_content = false ;
493+ auto has_reasoning_content = false ;
374494 for (const auto & message : inputs.messages ) {
375495 if (message.contains (" tool_calls" ) && !message[" tool_calls" ].is_null ()) {
376496 has_tool_calls = true ;
@@ -381,6 +501,9 @@ class chat_template {
381501 if (message.contains (" content" ) && message[" content" ].is_string ()) {
382502 has_string_content = true ;
383503 }
504+ if (message.contains (" reasoning_content" ) && !message[" reasoning_content" ].is_null ()) {
505+ has_reasoning_content = true ;
506+ }
384507 }
385508
386509 auto polyfill_system_role = opts.polyfill_system_role && !caps_.supports_system_role ;
@@ -390,6 +513,11 @@ class chat_template {
390513 auto polyfill_tool_responses = opts.polyfill_tool_responses && has_tool_responses && !caps_.supports_tool_responses ;
391514 auto polyfill_object_arguments = opts.polyfill_object_arguments && has_tool_calls && caps_.requires_object_arguments ;
392515 auto polyfill_typed_content = opts.polyfill_typed_content && has_string_content && caps_.requires_typed_content ;
516+ // Polyfill reasoning_content to template's native format when template supports
517+ // a different thinking pattern than REASONING_CONTENT_FIELD
518+ auto polyfill_reasoning = opts.polyfill_reasoning && has_reasoning_content
519+ && caps_.thinking_pattern != ThinkingPattern::NONE
520+ && caps_.thinking_pattern != ThinkingPattern::REASONING_CONTENT_FIELD;
393521
394522 auto needs_polyfills = opts.apply_polyfills && (false
395523 || polyfill_system_role
@@ -398,6 +526,7 @@ class chat_template {
398526 || polyfill_tool_responses
399527 || polyfill_object_arguments
400528 || polyfill_typed_content
529+ || polyfill_reasoning
401530 );
402531
403532 if (needs_polyfills) {
@@ -505,6 +634,66 @@ class chat_template {
505634 message.erase (" name" );
506635 }
507636
637+ // Polyfill reasoning_content to template's native format
638+ if (polyfill_reasoning && message.contains (" reasoning_content" ) && !message[" reasoning_content" ].is_null ()) {
639+ auto reasoning = message[" reasoning_content" ];
640+ switch (caps_.thinking_pattern ) {
641+ case ThinkingPattern::THOUGHT_FIELD:
642+ // MiniCPM3 style: message.thought
643+ message[" thought" ] = reasoning;
644+ break ;
645+ case ThinkingPattern::THINKING_FIELD:
646+ // GPT-OSS-120B style: message.thinking
647+ message[" thinking" ] = reasoning;
648+ break ;
649+ case ThinkingPattern::TOOL_PLAN_FIELD:
650+ // Command-R7B style: message.tool_plan (only with tool_calls)
651+ if (message.contains (" tool_calls" )) {
652+ message[" tool_plan" ] = reasoning;
653+ }
654+ break ;
655+ case ThinkingPattern::CONTENT_BLOCK_THINKING:
656+ // Ministral style: content blocks with type="thinking"
657+ {
658+ json content_blocks = json::array ();
659+ content_blocks.push_back ({{" type" , " thinking" }, {" thinking" , reasoning}});
660+ if (message.contains (" content" ) && !message[" content" ].is_null ()) {
661+ auto original_content = message[" content" ];
662+ if (original_content.is_string ()) {
663+ content_blocks.push_back ({{" type" , " text" }, {" text" , original_content}});
664+ } else if (original_content.is_array ()) {
665+ for (const auto & block : original_content) {
666+ content_blocks.push_back (block);
667+ }
668+ }
669+ }
670+ message[" content" ] = content_blocks;
671+ }
672+ break ;
673+ case ThinkingPattern::CONTENT_BLOCK_THOUGHTS:
674+ // Apertus style: content blocks with type="thoughts"
675+ {
676+ json content_blocks = json::array ();
677+ content_blocks.push_back ({{" type" , " thoughts" }, {" text" , reasoning}});
678+ if (message.contains (" content" ) && !message[" content" ].is_null ()) {
679+ auto original_content = message[" content" ];
680+ if (original_content.is_string ()) {
681+ content_blocks.push_back ({{" type" , " text" }, {" text" , original_content}});
682+ } else if (original_content.is_array ()) {
683+ for (const auto & block : original_content) {
684+ content_blocks.push_back (block);
685+ }
686+ }
687+ }
688+ message[" content" ] = content_blocks;
689+ }
690+ break ;
691+ default :
692+ break ;
693+ }
694+ message.erase (" reasoning_content" );
695+ }
696+
508697 if (!message[" content" ].is_null () && polyfill_system_role) {
509698 std::string content = message.at (" content" );
510699 if (role == " system" ) {
0 commit comments