Skip to content

Commit 0c9ae97

Browse files
ochafikclaude
andcommitted
Add ThinkingPattern polyfills, improved detection, and test infrastructure
ThinkingPattern detection & polyfills: - Add polyfill logic to transform reasoning_content to template's native format - Support for THOUGHT_FIELD (MiniCPM3), THINKING_FIELD (GPT-OSS), TOOL_PLAN_FIELD (Command-R7B) - Add CONTENT_BLOCK patterns (Ministral/Apertus) with improved detection - Improved content block detection: reject stringified output by checking for structural markers - Add supports_clear_thinking detection for templates like GLM-4.7 Test infrastructure: - Add test metadata (_test_metadata) to context JSON files for template-independent validation - Add expected_strings/forbidden_strings checks to test-supported-template.cpp - Support conditional checks: expected_strings_if_supports_thinking, _system_role, _tool_calls, _tool_responses - Add ThinkingPattern capability tests to test-capabilities.cpp New reasoning test contexts: - reasoning_only.json - basic reasoning content - reasoning_multi_turn.json - multi-turn conversation with reasoning - reasoning_position_based.json - position-based visibility - reasoning_clear_thinking.json - clear_thinking flag behavior - reasoning_with_tools.json - reasoning with tool calls - reasoning_disabled.json - enable_thinking=false 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8ba96b6 commit 0c9ae97

14 files changed

+837
-14
lines changed

include/minja/chat-template.hpp

Lines changed: 199 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ using json = nlohmann::ordered_json;
2828

2929
namespace 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+
3141
struct 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

5471
struct 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

7796
class 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

Comments
 (0)