char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Aldehir Rojaschat : improve parsing of complex types in qwen3-coder (#28742)790cf51aa

char/aliceai-t5-moe
306.6 KiB7411 linesraw
1//  Tests chat handling, including grammar genration and parsing for tool calling, for various templates.
2//
3//  Also acts as a CLI to generate a Markdown summary of the formats of Jinja templates,
4//  e.g. given Minja (http://github.com/google/minja) checked out in parent dir:
5//
6//    cmake -B build && cmake --build build --parallel && ./build/bin/test-chat ../minja/build/tests/*.jinja 2>/dev/null
7//
8#include "../src/llama-grammar.h"
9#include "../src/unicode.h"
10#include "../tools/server/server-chat.h"
11#include "chat-auto-parser.h"
12#include "chat.h"
13#include "common.h"
14#include "ggml.h"
15#include "log.h"
16
17#include <algorithm>
18#include <exception>
19#include <fstream>
20#include <functional>
21#include <iostream>
22#include "json.h"
23#include <set>
24#include <stdexcept>
25#include <string>
26
27using json = common_json;
28
29static std::ostream & operator<<(std::ostream & os, const common_chat_msg_diff & diff) {
30    os << "{ content_delta: " << diff.content_delta << "; ";
31    os << "reasoning_content_delta: " << diff.reasoning_content_delta << "; ";
32    if (diff.tool_call_index != std::string::npos) {
33        os << "tool_call_index: " << diff.tool_call_index << "; ";
34        os << "tool_call_delta.name: " << diff.tool_call_delta.name << "; ";
35        os << "tool_call_delta.id: " << diff.tool_call_delta.id << "; ";
36        os << "tool_call_delta.arguments: " << diff.tool_call_delta.arguments << "; ";
37    }
38    os << "}";
39    return os;
40}
41
42// operator<< for vector<common_chat_msg_diff>:
43static std::ostream & operator<<(std::ostream & os, const std::vector<common_chat_msg_diff> & diffs) {
44    os << "[\n";
45    for (const auto & diff : diffs) {
46        os << "  " << diff << ",\n";
47    }
48    os << "]";
49    return os;
50}
51
52static std::ostream & operator<<(std::ostream & os, const common_chat_msg & msg) {
53    os << "{ role: " << msg.role << "; ";
54    os << "content: " << msg.content << "; ";
55    os << "content_parts: [\n";
56    for (const auto & part : msg.content_parts) {
57        os << "  { type: " << part.type << "; text: " << part.text << " },\n";
58    }
59    os << "]; ";
60    os << "reasoning_content: " << msg.reasoning_content << "; ";
61    os << "tool_calls: [\n";
62    for (const auto & tool_call : msg.tool_calls) {
63        os << "  { name: " << tool_call.name << "; arguments: " << tool_call.arguments << "; id: " << tool_call.id
64           << " },\n";
65    }
66    os << "]";
67    os << "}";
68    return os;
69}
70
71template <class T> static bool equals(const T & expected, const T & actual) {
72    return expected == actual;
73}
74
75static common_chat_msg normalize(const common_chat_msg & msg) {
76    common_chat_msg normalized = msg;
77    for (auto & tool_call : normalized.tool_calls) {
78        try {
79            tool_call.arguments = json::parse(tool_call.arguments).dump();
80        } catch (const std::exception &) {
81        }
82    }
83    return normalized;
84}
85
86template <> bool equals(const common_chat_msg & expected, const common_chat_msg & actual) {
87    return normalize(expected) == normalize(actual);
88}
89
90template <class T> static void assert_equals(const T & expected, const T & actual) {
91    if (!equals(expected, actual)) {
92        std::ostringstream oss_expected;
93        oss_expected << expected;
94        std::ostringstream oss_actual;
95        oss_actual << actual;
96        LOG_ERR("Expected: %s\n", oss_expected.str().c_str());
97        LOG_ERR("Actual: %s\n", oss_actual.str().c_str());
98        common_log_flush(common_log_main());
99        throw std::runtime_error("Test failed");
100    }
101}
102
103static void assert_contains(const std::string & haystack, const std::string & needle) {
104    if (haystack.find(needle) == std::string::npos) {
105        LOG_ERR("Expected to contain: %s\n", needle.c_str());
106        LOG_ERR("Actual: %s\n", haystack.c_str());
107        common_log_flush(common_log_main());
108        throw std::runtime_error("Test failed");
109    }
110}
111
112static void assert_not_contains(const std::string & haystack, const std::string & needle) {
113    if (haystack.find(needle) != std::string::npos) {
114        LOG_ERR("Expected NOT to contain: %s\n", needle.c_str());
115        LOG_ERR("Actual: %s\n", haystack.c_str());
116        common_log_flush(common_log_main());
117        throw std::runtime_error("Test failed");
118    }
119}
120
121static void assert_ends_with(const std::string & str, const std::string & suffix) {
122    if (str.size() < suffix.size() ||
123        str.compare(str.size() - suffix.size(), suffix.size(), suffix) != 0) {
124        LOG_ERR("Expected to end with: %s\n", suffix.c_str());
125        LOG_ERR("Actual: %s\n", str.c_str());
126        common_log_flush(common_log_main());
127        throw std::runtime_error("Test failed");
128    }
129}
130
131static std::string read_file(const std::string & path) {
132    std::ifstream fs(path, std::ios_base::binary);
133    if (!fs.is_open()) {
134        fs = std::ifstream("../" + path, std::ios_base::binary);
135        if (!fs.is_open()) {
136            throw std::runtime_error("Failed to open file: " + path);
137        }
138    }
139    fs.seekg(0, std::ios_base::end);
140    auto size = fs.tellg();
141    fs.seekg(0);
142    std::string out;
143    out.resize(static_cast<size_t>(size));
144    fs.read(out.data(), static_cast<std::streamsize>(size));
145    return out;
146}
147
148static common_chat_templates_ptr read_templates(const std::string & path) {
149    return common_chat_templates_ptr(common_chat_templates_init(/* model= */ nullptr, read_file(path)));
150}
151
152static std::unique_ptr<llama_grammar> build_grammar(const std::string & grammar_str) {
153    return std::unique_ptr<llama_grammar>(
154        llama_grammar_init_impl(nullptr, grammar_str.c_str(), "root", false, nullptr, 0, nullptr, 0));
155}
156
157// Helper to format a code point as a readable string
158static std::string format_codepoint(uint32_t cp) {
159    if (cp >= 32 && cp < 127) {
160        return std::string("'") + static_cast<char>(cp) + "'";
161    } else if (cp == '\n') {
162        return "'\\n'";
163    } else if (cp == '\r') {
164        return "'\\r'";
165    } else if (cp == '\t') {
166        return "'\\t'";
167    } else {
168        return "U+" + std::to_string(cp);
169    }
170}
171
172// Helper to format expected element from grammar stack
173static std::string format_expected_element(const llama_grammar_rules & /* rules*/, const llama_grammar_element * elem) {
174    if (!elem) {
175        return "<end>";
176    }
177
178    switch (elem->type) {
179        case LLAMA_GRETYPE_END:
180            return "<end of rule>";
181        case LLAMA_GRETYPE_ALT:
182            return "<alternative>";
183        case LLAMA_GRETYPE_RULE_REF:
184            {
185                // Find rule name - just show rule ID for now
186                return "<rule-" + std::to_string(elem->value) + ">";
187            }
188        case LLAMA_GRETYPE_CHAR:
189            {
190                std::string                   result;
191                const llama_grammar_element * pos   = elem;
192                bool                          first = true;
193
194                do {
195                    if (!first) {
196                        result += " | ";
197                    }
198                    first = false;
199
200                    if (pos[1].type == LLAMA_GRETYPE_CHAR_RNG_UPPER) {
201                        // Range like [a-z]
202                        result += "[" + format_codepoint(pos->value) + "-" + format_codepoint(pos[1].value) + "]";
203                        pos += 2;
204                    } else {
205                        result += format_codepoint(pos->value);
206                        pos += 1;
207                    }
208                } while (pos->type == LLAMA_GRETYPE_CHAR_ALT);
209
210                return result;
211            }
212        case LLAMA_GRETYPE_CHAR_NOT:
213            {
214                std::string                   result = "[^";
215                const llama_grammar_element * pos    = elem;
216                bool                          first  = true;
217
218                do {
219                    if (!first) {
220                        result += " ";
221                    }
222                    first = false;
223
224                    if (pos[1].type == LLAMA_GRETYPE_CHAR_RNG_UPPER) {
225                        result += format_codepoint(pos->value) + "-" + format_codepoint(pos[1].value);
226                        pos += 2;
227                    } else {
228                        result += format_codepoint(pos->value);
229                        pos += 1;
230                    }
231                } while (pos->type == LLAMA_GRETYPE_CHAR_ALT);
232
233                return result + "]";
234            }
235        case LLAMA_GRETYPE_CHAR_ANY:
236            return "<any char>";
237        case LLAMA_GRETYPE_TOKEN:
238            return "<token-" + std::to_string(elem->value) + ">";
239        case LLAMA_GRETYPE_TOKEN_NOT:
240            return "<not-token-" + std::to_string(elem->value) + ">";
241        default:
242            return "<unknown>";
243    }
244}
245
246// Get description of what the grammar expects at current position
247static std::string get_expected_description(const llama_grammar_rules & rules, const llama_grammar_stacks & stacks) {
248    if (stacks.empty()) {
249        return "<no valid continuations>";
250    }
251
252    std::string           result;
253    std::set<std::string> seen;
254
255    for (const auto & stack : stacks) {
256        if (stack.empty()) {
257            if (seen.insert("<end>").second) {
258                if (!result.empty()) {
259                    result += " OR ";
260                }
261                result += "<end>";
262            }
263            continue;
264        }
265
266        const llama_grammar_element * elem = stack.back();
267        std::string                   desc = format_expected_element(rules, elem);
268        if (seen.insert(desc).second) {
269            if (!result.empty()) {
270                result += " OR ";
271            }
272            result += desc;
273        }
274    }
275
276    return result;
277}
278
279// Result of a detailed grammar match attempt
280struct grammar_match_result {
281    bool        success            = false;  // Did the string fully match the grammar?
282    size_t      matched_bytes      = 0;      // Bytes successfully matched before failure
283    size_t      matched_codepoints = 0;      // Codepoints successfully matched before failure
284    size_t      total_bytes        = 0;      // Total bytes in input
285    size_t      total_codepoints   = 0;      // Total codepoints in input
286    std::string matched_prefix;              // The portion that was successfully matched
287    std::string failing_char;                // The character that caused failure (if any)
288    std::string expected_description;        // What the grammar expected at failure point
289    bool        incomplete = false;          // True if matched all input but grammar expects more
290};
291
292// Detailed version of match_string that returns failure information
293static grammar_match_result match_string_detailed(const std::string & input, llama_grammar * grammar) {
294    grammar_match_result result;
295    result.total_bytes = input.size();
296
297    const auto cpts         = unicode_cpts_from_utf8(input);
298    result.total_codepoints = cpts.size();
299
300    auto &       stacks_cur = llama_grammar_get_stacks(grammar);
301    const auto & rules      = llama_grammar_get_rules(grammar);
302
303    size_t byte_pos = 0;
304
305    for (size_t i = 0; i < cpts.size(); i++) {
306        const auto & cpt = cpts[i];
307
308        // Get expected before accepting (for error reporting)
309        std::string expected_before = get_expected_description(rules, stacks_cur);
310
311        llama_grammar_accept(grammar, cpt);
312
313        // Calculate byte position for this codepoint
314        size_t cpt_bytes = 0;
315        if (cpt < 0x80) {
316            cpt_bytes = 1;
317        } else if (cpt < 0x800) {
318            cpt_bytes = 2;
319        } else if (cpt < 0x10000) {
320            cpt_bytes = 3;
321        } else {
322            cpt_bytes = 4;
323        }
324
325        if (stacks_cur.empty()) {
326            // Grammar failed to match at this point
327            result.matched_bytes        = byte_pos;
328            result.matched_codepoints   = i;
329            result.matched_prefix       = input.substr(0, byte_pos);
330            result.failing_char         = format_codepoint(cpt);
331            result.expected_description = expected_before;
332            result.incomplete           = false;
333            return result;
334        }
335
336        byte_pos += cpt_bytes;
337    }
338
339    // All input matched - check if grammar is complete
340    result.matched_bytes      = input.size();
341    result.matched_codepoints = cpts.size();
342    result.matched_prefix     = input;
343
344    if (std::any_of(stacks_cur.begin(), stacks_cur.end(), [](const auto & stack) { return stack.empty(); })) {
345        // An empty stack means that the grammar has been completed
346        result.success    = true;
347        result.incomplete = false;
348    } else {
349        // Grammar expects more input
350        result.success              = false;
351        result.incomplete           = true;
352        result.expected_description = get_expected_description(rules, stacks_cur);
353    }
354
355    return result;
356}
357
358// TODO: extract to common helper (copied from test-grammar-integration.cpp)
359static bool match_string(const std::string & input, llama_grammar * grammar) {
360    const auto cpts = unicode_cpts_from_utf8(input);
361
362    auto & stacks_cur = llama_grammar_get_stacks(grammar);
363
364    for (const auto & cpt : cpts) {
365        llama_grammar_accept(grammar, cpt);
366
367        if (stacks_cur.empty()) {
368            // no stacks means that the grammar failed to match at this point
369            return false;
370        }
371    }
372
373    if (std::any_of(stacks_cur.begin(), stacks_cur.end(), [](const auto & stack) { return stack.empty(); })) {
374        // An empty stack means that the grammar has been completed
375        return true;
376    }
377
378    return false;
379}
380
381static std::string renormalize_json(const std::string & json_str) {
382    try {
383        auto json_obj = json::parse(json_str);
384        return json_obj.dump();
385    } catch (const std::exception & e) {
386        return "";  // ignore parial JSON contents for comparison purposes
387    }
388}
389
390static void assert_msg_equals(const common_chat_msg & expected,
391                              const common_chat_msg & actual,
392                              bool                    ignore_whitespace_differences = false) {
393    assert_equals(expected.role, actual.role);
394    if (ignore_whitespace_differences) {
395        assert_equals(string_strip(expected.content), string_strip(actual.content));
396    } else {
397        assert_equals(expected.content, actual.content);
398    }
399    assert_equals(expected.content_parts.size(), actual.content_parts.size());
400    for (size_t i = 0; i < expected.content_parts.size(); i++) {
401        const auto & expected_part = expected.content_parts[i];
402        const auto & actual_part   = actual.content_parts[i];
403        assert_equals(expected_part.type, actual_part.type);
404        if (ignore_whitespace_differences) {
405            assert_equals(string_strip(expected_part.text), string_strip(actual_part.text));
406        } else {
407            assert_equals(expected_part.text, actual_part.text);
408        }
409    }
410    if (ignore_whitespace_differences) {
411        assert_equals(string_strip(expected.reasoning_content), string_strip(actual.reasoning_content));
412    } else {
413        assert_equals(expected.reasoning_content, actual.reasoning_content);
414    }
415    assert_equals(expected.tool_calls.size(), actual.tool_calls.size());
416    for (size_t i = 0; i < expected.tool_calls.size(); i++) {
417        const auto & expected_tool_call = expected.tool_calls[i];
418        const auto & actual_tool_call   = actual.tool_calls[i];
419        assert_equals(expected_tool_call.name, actual_tool_call.name);
420        assert_equals(renormalize_json(expected_tool_call.arguments), renormalize_json(actual_tool_call.arguments));
421        assert_equals(expected_tool_call.id, actual_tool_call.id);
422    }
423}
424
425static common_chat_tool special_function_tool{
426    /* .name = */ "special_function",
427    /* .description = */ "I'm special",
428    /* .parameters = */ R"({
429        "type": "object",
430        "properties": {
431            "arg1": {
432                "type": "integer",
433                "description": "The arg."
434            }
435        },
436        "required": ["arg1"]
437    })",
438};
439static common_chat_tool special_function_tool_with_optional_param{
440    /* .name = */ "special_function_with_opt",
441    /* .description = */ "I'm special but have optional stuff",
442    /* .parameters = */ R"({
443        "type": "object",
444        "properties": {
445            "arg1": {
446                "type": "integer",
447                "description": "The arg."
448            },
449            "arg2": {
450                "type": "integer",
451                "description": "The optional arg."
452            }
453        },
454        "required": ["arg1"]
455    })",
456};
457
458static common_chat_tool empty_args_tool{
459    /* .name = */ "empty_args",
460    /* .description = */ "A tool that takes no arguments",
461    /* .parameters = */ R"({
462        "type": "object",
463        "properties": {}
464    })",
465};
466
467static common_chat_tool empty_args_tool_no_properties{
468    /* .name = */ "empty_args_no_props",
469    /* .description = */ "A tool that takes no arguments and has no properties",
470    /* .parameters = */ R"({
471        "type": "object"
472    })",
473};
474
475static common_chat_tool empty_args_tool_no_schema{
476    /* .name = */ "empty_args_no_schema",
477    /* .description = */ "A tool that takes no arguments and has no parameters schema",
478    /* .parameters = */ "{}",
479};
480
481static common_chat_tool python_tool{
482    /* .name = */ "python",
483    /* .description = */ "an ipython interpreter",
484    /* .parameters = */ R"({
485        "type": "object",
486        "properties": {
487            "code": {
488                "type": "string",
489                "description": "Python code to execute."
490            }
491        },
492        "required": ["code"]
493    })",
494};
495
496static common_chat_tool html_tool{
497    /* .name = */ "html",
498    /* .description = */ "an html validator",
499    /* .parameters = */ R"({
500        "type": "object",
501        "properties": {
502            "markup": {
503                "type": "string",
504                "description": "HTML markup to validate."
505            }
506        },
507        "required": ["markup"]
508    })",
509};
510
511static common_chat_tool get_time_tool{
512    /* .name = */ "get_time",
513    /* .description = */ "Get the current time in a city",
514    /* .parameters = */ R"({
515        "type": "object",
516        "properties": {
517            "city": {
518                "type": "string",
519                "description": "City name"
520            }
521        },
522        "required": ["city"]
523    })",
524};
525
526static common_chat_tool get_weather_tool{
527    /* .name = */ "get_weather",
528    /* .description = */ "Get the current weather in a city",
529    /* .parameters = */ R"({
530        "type": "object",
531        "properties": {
532            "city": {
533                "type": "string",
534                "description": "City name"
535            }
536        },
537        "required": ["city"]
538    })",
539};
540
541static common_chat_tool todo_list{
542    /* .name = */ "todo_list",
543    /* .description = */ "Create or update the todo list",
544    /* .parameters = */ R"({
545        "type": "object",
546        "properties": {
547            "todos": {
548                "type": "array",
549                "description": "List of TODO list items"
550            }
551        },
552        "required": ["todos"]
553    })",
554};
555
556static common_chat_tool edit_tool{
557    /* .name = */ "edit",
558    /* .description = */ "Edit file",
559    /* .parameters = */ R"({
560        "type": "object",
561        "properties": {
562            "filename": {
563                "type": "string",
564                "description": "Path of file to edit"
565            },
566            "oldString": {
567                "type": "string",
568                "description": "String to replace"
569            },
570            "newString": {
571                "type": "string",
572                "description": "New (replacement) value"
573            }
574        },
575        "required": ["filename", "oldString", "newString"]
576    })",
577};
578
579static common_chat_tool manage_todo_list_tool{
580    /* .name = */ "manage_todo_list",
581    /* .description = */ "Create or update the todo list",
582    /* .parameters = */ R"({
583        "type": "object",
584        "properties": {
585            "todos": {
586                "type": "array",
587                "description": "List of TODO list items"
588            }
589        },
590        "required": ["todos"]
591    })",
592};
593
594static common_chat_tool run_in_terminal_tool{
595    /* .name = */ "run_in_terminal",
596    /* .description = */ "Run a shell command.",
597    /* .parameters = */ R"({
598        "type": "object",
599        "properties": {
600            "command": {
601                "type": "string",
602                "description": "Shell command to run"
603            }
604        },
605        "required": ["command"]
606    })",
607};
608
609static common_chat_tool magic_tool{
610    /* .name = */ "magic",
611    /* .description = */ "Magic tool that takes a hash",
612    /* .parameters = */ R"({
613        "type": "object",
614        "properties": {
615            "name": {
616                "type": "string"
617            },
618            "ref": {
619                "type": "string"
620            }
621        },
622        "required": ["name", "ref"]
623    })",
624};
625
626static common_chat_tool magic_int_tool{
627    /* .name = */ "magic_int",
628    /* .description = */ "Magic tool that takes a hash",
629    /* .parameters = */ R"({
630        "type": "object",
631        "properties": {
632            "ref": {
633                "type": "integer"
634            },
635            "name": {
636                "type": "string"
637            }
638        },
639        "required": ["ref"]
640    })",
641};
642
643static common_chat_tool amount_tool{
644    /* .name = */ "amount",
645    /* .description = */ "Amount converter",
646    /* .parameters = */ R"({
647        "type": "object",
648        "properties": {
649            "orig": {
650                "type": "number"
651            }
652        },
653        "required": ["orig"]
654    })",
655};
656
657static common_chat_tool toggle_tool{
658    /* .name = */ "toggle",
659    /* .description = */ "Toggle a feature",
660    /* .parameters = */ R"({
661        "type": "object",
662        "properties": {
663            "enabled": {
664                "type": "boolean",
665                "description": "Whether to enable the feature"
666            }
667        },
668        "required": ["enabled"]
669    })",
670};
671
672static common_chat_tool nullable_tool{
673    /* .name = */ "set_nullable",
674    /* .description = */ "Set a nullable value",
675    /* .parameters = */ R"({
676        "type": "object",
677        "properties": {
678            "value": {
679                "type": "null",
680                "description": "A null value"
681            }
682        },
683        "required": ["value"]
684    })",
685};
686
687static common_chat_tool config_tool{
688    /* .name = */ "set_config",
689    /* .description = */ "Set configuration",
690    /* .parameters = */ R"({
691        "type": "object",
692        "properties": {
693            "config": {
694                "type": "object",
695                "description": "Configuration dict"
696            }
697        },
698        "required": ["config"]
699    })",
700};
701
702static common_chat_tool calendar_create_event_tool{
703    /* .name = */ "Calendar.create_event",
704    /* .description = */ "Create a calendar event",
705    /* .parameters = */ R"({
706        "type": "object",
707        "properties": {
708            "title": { "type": "string" },
709            "participants": { "type": "array", "items": { "type": "string" } },
710            "metadata": { "type": "object" }
711        },
712        "required": ["title", "participants", "metadata"]
713    })",
714};
715
716static common_chat_tool imaginary_number_tool{
717    /* .name = */ "imaginary_number",
718    /* .description = */ "Imaginary number converter",
719    /* .parameters = */ R"({
720        "type": "object",
721        "properties": {
722            "number": {
723                "type": "object",
724                "properties": {
725                    "real": {
726                        "type": "number"
727                    },
728                    "imaginary": {
729                        "type": "number"
730                    }
731                },
732                "required": ["real", "imaginary"]
733            }
734        },
735        "required": ["number"]
736    })",
737};
738
739static common_chat_tool nested_args_tool{
740    /* .name = */ "nested_args",
741    /* .description = */ "Tool with nested array arguments",
742    /* .parameters = */ R"({
743        "type": "object",
744        "properties": {
745            "tags": {
746                "type": "array",
747                "items": { "type": "string" }
748            },
749            "entries": {
750                "type": "array",
751                "items": {
752                    "type": "object",
753                    "properties": {
754                        "id": { "type": "integer" },
755                        "label": { "type": "string" }
756                    },
757                    "required": ["id", "label"]
758                }
759            }
760        },
761        "required": ["tags", "entries"]
762    })",
763};
764
765static common_chat_tool union_args_tool{
766    /* .name = */ "union_args",
767    /* .description = */ "Tool with union arguments",
768    /* .parameters = */ R"({
769        "type": "object",
770        "properties": {
771            "filter": {
772                "anyOf": [
773                    { "type": "array", "items": { "type": "string" } },
774                    {
775                        "type": "object",
776                        "properties": {
777                            "field": { "type": "string" },
778                            "op": { "type": "string" }
779                        },
780                        "required": ["field", "op"]
781                    }
782                ]
783            },
784            "label": {
785                "oneOf": [
786                    { "type": "string" },
787                    { "type": "object", "properties": { "text": { "type": "string" } } }
788                ]
789            },
790            "limit": {
791                "oneOf": [
792                    { "type": "integer" },
793                    {
794                        "type": "object",
795                        "properties": { "max": { "type": "integer" } },
796                        "required": ["max"]
797                    }
798                ]
799            }
800        }
801    })",
802};
803
804static common_chat_tool nullable_string_tool{
805    /* .name = */ "set_nullable_str",
806    /* .description = */ "Set a nullable string value",
807    /* .parameters = */ R"({
808        "type": "object",
809        "properties": {
810            "name": {
811                "type": ["string", "null"],
812                "description": "A nullable string"
813            }
814        },
815        "required": ["name"]
816    })",
817};
818
819static common_chat_tool nullable_string_null_first_tool{
820    /* .name = */ "set_nullable_str_nf",
821    /* .description = */ "Set a nullable string value with null first in type array",
822    /* .parameters = */ R"({
823        "type": "object",
824        "properties": {
825            "name": {
826                "type": ["null", "string"],
827                "description": "A nullable string with null first"
828            }
829        },
830        "required": ["name"]
831    })",
832};
833
834static common_chat_tool nullable_int_tool{
835    /* .name = */ "set_nullable_int",
836    /* .description = */ "Set a nullable integer value",
837    /* .parameters = */ R"({
838        "type": "object",
839        "properties": {
840            "count": {
841                "type": ["integer", "null"],
842                "description": "A nullable integer"
843            }
844        },
845        "required": ["count"]
846    })",
847};
848
849static common_chat_tool string_union_tool{
850    /* .name = */ "set_union",
851    /* .description = */ "Set values whose types are unions with string",
852    /* .parameters = */ R"({
853        "type": "object",
854        "properties": {
855            "value": {
856                "type": ["string", "object"],
857                "description": "A string or object value"
858            },
859            "amount": {
860                "type": ["string", "integer"],
861                "description": "A string or integer value"
862            }
863        },
864        "required": ["value", "amount"]
865    })",
866};
867
868static common_chat_tool enum_no_type_tool{
869    /* .name = */ "set_unit",
870    /* .description = */ "Set a temperature unit",
871    /* .parameters = */ R"({
872        "type": "object",
873        "properties": {
874            "unit": {
875                "enum": ["celsius", "fahrenheit"],
876                "description": "Temperature unit"
877            }
878        },
879        "required": ["unit"]
880    })",
881};
882
883static common_chat_tool string_param_tool{
884    /* .name = */ "string_param",
885    /* .description = */ "Tool with string parameter for testing",
886    /* .parameters = */ R"({
887        "type": "object",
888        "properties": {
889            "text": {
890                "type": "string",
891                "description": "A text parameter"
892            }
893        },
894        "required": []
895    })",
896};
897
898static common_chat_tool quoted_unquoted_tool{
899    /* .name = */ "quoted_unquoted",
900    /* .description = */ "Tool with two string parameters, one for quoted string, one for unquoted",
901    /* .parameters = */ R"({
902        "type": "object",
903        "properties": {
904            "quoted": {
905                "type": "string",
906                "description": "Quoted value"
907            },
908            "unquoted": {
909                "type": "string",
910                "description": "Unquoted value"
911            }
912        },
913        "required": ["quoted", "unquoted"]
914    })",
915};
916
917
918static common_chat_tool tool_2req_4opt{
919    /* .name = */ "tool_2req_4opt",
920    /* .description = */ "Tool with 2 required and 4 optional params",
921    /* .parameters = */ R"({
922        "type": "object",
923        "properties": {
924            "req1": { "type": "string", "description": "Required string" },
925            "req2": { "type": "integer", "description": "Required int" },
926            "opt1": { "type": "string", "description": "Optional string 1" },
927            "opt2": { "type": "integer", "description": "Optional int 1" },
928            "opt3": { "type": "string", "description": "Optional string 2" },
929            "opt4": { "type": "integer", "description": "Optional int 2" }
930        },
931        "required": ["req1", "req2"]
932    })",
933};
934
935static common_chat_tool tool_2req_5opt{
936    /* .name = */ "tool_2req_5opt",
937    /* .description = */ "Tool with 2 required and 5 optional params",
938    /* .parameters = */ R"({
939        "type": "object",
940        "properties": {
941            "req1": { "type": "string", "description": "Required string" },
942            "req2": { "type": "integer", "description": "Required int" },
943            "opt1": { "type": "string", "description": "Optional string 1" },
944            "opt2": { "type": "integer", "description": "Optional int 1" },
945            "opt3": { "type": "string", "description": "Optional string 2" },
946            "opt4": { "type": "integer", "description": "Optional int 2" },
947            "opt5": { "type": "string", "description": "Optional string 3" }
948        },
949        "required": ["req1", "req2"]
950    })",
951};
952
953static std::vector<common_chat_tool> tools{ special_function_tool, special_function_tool_with_optional_param,
954                                            python_tool, html_tool, todo_list };
955
956const common_chat_msg message_user{
957    "user",
958    "Hey there!",
959    /* .content_parts = */ {},
960    /* .tool_calls = */ {},
961    /* .reasoning_content = */ "",
962    /* .tool_name = */ "",
963    /* .tool_call_id = */ "",
964};
965
966const common_chat_msg message_user_parts{
967    "user",
968    /* .content = */ "",
969    /* .content_parts = */
970    {
971     { "text", "Hey" },
972     { "text", "there" },
973     },
974    /* .tool_calls = */
975    {                 },
976    /* .reasoning_content = */
977    "",
978    /* .tool_name = */ "",
979    /* .tool_call_id = */ "",
980};
981
982static common_chat_msg simple_assist_msg(const std::string & content,
983                                         const std::string & reasoning_content = "",
984                                         const std::string & tool_name         = "",
985                                         const std::string & arguments         = "",
986                                         const std::string & id                = "") {
987    common_chat_msg msg;
988    msg.role              = "assistant";
989    msg.content           = content;
990    msg.reasoning_content = reasoning_content;
991    if (!tool_name.empty() || !id.empty()) {
992        msg.tool_calls.push_back({ tool_name, arguments, id });
993    }
994    return msg;
995}
996
997static common_chat_msg message_with_tool_calls(const std::string & tool_name, const std::string & arguments) {
998    return simple_assist_msg("", "", tool_name, arguments);
999}
1000
1001static common_chat_msg message_with_tool_calls_and_reasoning(const std::string & tool_name,
1002                                                             const std::string & arguments,
1003                                                             const std::string & reasoning) {
1004    return simple_assist_msg("", reasoning, tool_name, arguments);
1005}
1006
1007static common_chat_msg message_with_reasoning_content_and_multiple_tool_calls(
1008    const std::string &                                      reasoning,
1009    const std::string &                                      content,
1010    const std::vector<std::pair<std::string, std::string>> & tool_calls) {
1011    common_chat_msg msg;
1012    msg.role              = "assistant";
1013    msg.content           = content;
1014    msg.reasoning_content = reasoning;
1015    for (const auto & [name, args] : tool_calls) {
1016        msg.tool_calls.push_back({ name, args, "" });
1017    }
1018    return msg;
1019}
1020
1021static common_chat_msg message_with_content_and_tool_call(const std::string & content,
1022                                                          const std::string & tool_name,
1023                                                          const std::string & arguments) {
1024    return simple_assist_msg(content, "", tool_name, arguments);
1025}
1026
1027static common_chat_msg message_with_reasoning_and_tool_call(const std::string & reasoning,
1028                                                            const std::string & tool_name,
1029                                                            const std::string & arguments) {
1030    return simple_assist_msg("", reasoning, tool_name, arguments);
1031}
1032
1033const common_chat_msg message_assist       = simple_assist_msg("Hello, world!\nWhat's up?");
1034const common_chat_msg message_assist_empty = simple_assist_msg("");
1035const common_chat_msg message_assist_thoughts_unparsed_deepseek =
1036    simple_assist_msg("<think>I'm\nthinking</think>Hello, world!\nWhat's up?");
1037const common_chat_msg message_assist_thoughts_unparsed_md =
1038    simple_assist_msg("<think>I'm\nthinking</think>Hello, world!\nWhat's up?\n```json\n{}```");
1039const common_chat_msg message_assist_thoughts_unparsed_md_partial =
1040    simple_assist_msg("<think>I'm\nthinking</think>Hello, world!\nWhat's up?\n```json\n{}");
1041
1042const common_chat_msg message_assist_thoughts_unparsed_r7b =
1043    simple_assist_msg("<|START_THINKING|>I'm\nthinking<|END_THINKING|>Hello, world!\nWhat's up?");
1044const common_chat_msg message_assist_thoughts_unparsed_magistral =
1045    simple_assist_msg("[THINK]raisonnement[/THINK]Réponse");
1046const common_chat_msg message_assist_thoughts = simple_assist_msg("Hello, world!\nWhat's up?", "I'm\nthinking");
1047const common_chat_msg message_assist_thoughts_unopened_unparsed =
1048    simple_assist_msg("I'm\nthinking</think>Hello, world!\nWhat's up?");
1049const common_chat_msg message_assist_thoughts_no_content = simple_assist_msg("", "I'm\nthinking");
1050const common_chat_msg message_assist_call = simple_assist_msg("", "", "special_function", "{\"arg1\": 1}");
1051const common_chat_msg message_assist_call_noopt =
1052    simple_assist_msg("", "", "special_function_with_opt", "{\"arg1\": 1}");
1053const common_chat_msg message_assist_call_withopt =
1054    simple_assist_msg("", "", "special_function_with_opt", "{\"arg1\": 1, \"arg2\": 2}");
1055const common_chat_msg message_assist_call_content =
1056    simple_assist_msg("Hello, world!\nWhat's up?", "", "special_function", "{\"arg1\":1}");
1057const common_chat_msg message_assist_call_empty_args  = simple_assist_msg("", "", "special_function");
1058const common_chat_msg message_assist_call_cutoff_args = simple_assist_msg("", "", "special_function", "{\"arg");
1059const common_chat_msg message_assist_call_thoughts =
1060    simple_assist_msg("", "I'm\nthinking", "special_function", "{\"arg1\":1}");
1061const common_chat_msg message_assist_call_thoughts_unparsed =
1062    simple_assist_msg("<think>I'm\nthinking</think>\n\n", "", "special_function", "{\"arg1\": 1}");
1063const common_chat_msg message_assist_call_thoughts_content =
1064    simple_assist_msg("Hello, world!\nWhat's up?", "I'm\nthinking", "special_function", "{\"arg1\": 1}");
1065const common_chat_msg message_assist_call_id =
1066    simple_assist_msg("", "", "special_function", "{\"arg1\":1}", /* .id = */ "123456789");
1067const common_chat_msg message_assist_call_idx =
1068    simple_assist_msg("", "", "special_function", "{\"arg1\":1}", /* .id = */ "0");
1069const common_chat_msg message_assist_thoughts_call_idx =
1070    simple_assist_msg("", "I'm\nthinking", "special_function", "{\"arg1\": 1}", /* id = */ "0");
1071const common_chat_msg message_assist_thoughts_partial_call =
1072    simple_assist_msg("", "I'm\nthinking", "special_function", "", /* id = */ "0");
1073const common_chat_msg message_assist_call_python = simple_assist_msg("", "", "python", "{\"code\":\"print('hey')\"}");
1074const common_chat_msg message_assist_call_python_lines =
1075    simple_assist_msg("", "", "python", "{\"code\":\"# This is a program:\\nprint('hey')\"}");
1076const common_chat_msg message_assist_call_python_lines_unclosed =
1077    simple_assist_msg("", "", "python", "{\"code\":\"# This is a program:\\nprint('hey')");
1078const common_chat_msg message_assist_json_content =
1079    simple_assist_msg("{\n  \"response\": \"Hello, world!\\nWhat's up?\"\n}");
1080const common_chat_msg message_assist_prefill_content   = simple_assist_msg("Hello, ", "I'm thinking");
1081const common_chat_msg message_assist_prefill_reasoning = simple_assist_msg("", "I'm");
1082
1083// Use for PEG parser implementations
1084struct peg_test_case {
1085    common_chat_templates_inputs params;
1086    std::string                  input;
1087    common_chat_msg              expect;
1088    bool                         is_partial            = false;
1089    bool                         expect_reconstruction = false;
1090};
1091
1092struct make_peg_parser {
1093    common_chat_params params_;
1094    common_peg_arena   arena_;
1095    bool               detailed_debug_;
1096
1097    make_peg_parser(common_chat_templates *              tmpls,
1098                    const common_chat_templates_inputs & inputs,
1099                    bool                                 detailed_debug = false) {
1100        detailed_debug_ = detailed_debug;
1101        params_         = common_chat_templates_apply(tmpls, inputs);
1102        arena_.load(params_.parser);
1103    }
1104
1105    common_chat_msg parse(const std::string & msg, bool is_partial) const {
1106        common_chat_parser_params parser_params(params_);
1107        parser_params.debug = detailed_debug_;
1108        return common_chat_peg_parse(arena_, msg, is_partial, parser_params);
1109    }
1110};
1111
1112// Global template filter for --template flag
1113static std::string g_template_filter;
1114
1115// When true, run reconstruction test on every non-partial test and report results
1116static bool g_force_reconstruction_test = false;
1117
1118static void test_peg_parser(common_chat_templates *                      tmpls,
1119                            const std::function<void(peg_test_case &)> & init,
1120                            bool                                         detailed_debug) {
1121    // UTF-8-safe truncation helper (same as in test_parser_with_streaming)
1122    constexpr auto utf8_truncate_safe_len = [](const std::string_view s) -> size_t {
1123        auto len = s.size();
1124        if (len == 0) {
1125            return 0;
1126        }
1127        auto i = len;
1128        for (size_t back = 0; back < 4 && i > 0; ++back) {
1129            --i;
1130            unsigned char c = s[i];
1131            if ((c & 0x80) == 0) {
1132                return len;
1133            }
1134            if ((c & 0xC0) == 0xC0) {
1135                size_t expected_len = 0;
1136                if ((c & 0xE0) == 0xC0) {
1137                    expected_len = 2;
1138                } else if ((c & 0xF0) == 0xE0) {
1139                    expected_len = 3;
1140                } else if ((c & 0xF8) == 0xF0) {
1141                    expected_len = 4;
1142                } else {
1143                    return i;
1144                }
1145                if (len - i >= expected_len) {
1146                    return len;
1147                }
1148                return i;
1149            }
1150        }
1151        return len - std::min(len, size_t(3));
1152    };
1153
1154    peg_test_case tc;
1155    init(tc);
1156    if (tc.params.messages.empty()) {
1157        tc.params.messages = { message_user };
1158    }
1159    if (tc.expect.role.empty()) {
1160        tc.expect.role = "assistant";
1161    }
1162
1163    auto parser = make_peg_parser(tmpls, tc.params, detailed_debug);
1164    if (detailed_debug) {
1165        LOG_DBG("Using parser: \n%s\n", parser.arena_.dump(parser.arena_.root()).c_str());
1166        LOG_DBG("Generation prompt: '%s'\n", parser.params_.generation_prompt.c_str());
1167    }
1168
1169    common_chat_msg msg_accum;
1170    common_chat_msg msg_prev;
1171    msg_accum.role = msg_prev.role = "assistant";
1172
1173    for (size_t i = 1; i <= tc.input.size(); ++i) {
1174        auto            is_partial  = i < tc.input.size() || tc.is_partial;
1175        // Use UTF-8 safe truncation to avoid corrupting multi-byte characters
1176        size_t          safe_len    = utf8_truncate_safe_len(std::string_view(tc.input).substr(0, i));
1177        std::string     prefix      = tc.input.substr(0, safe_len);
1178        common_chat_msg msg_current = parser.parse(prefix, is_partial);
1179
1180        for (const auto & diff : common_chat_msg_diff::compute_diffs(msg_prev, msg_current)) {
1181            if (!diff.reasoning_content_delta.empty()) {
1182                msg_accum.reasoning_content += diff.reasoning_content_delta;
1183            }
1184            if (!diff.content_delta.empty()) {
1185                msg_accum.content += diff.content_delta;
1186            }
1187            if (diff.tool_call_index != std::string::npos) {
1188                // During partial parsing, a new tool call may appear with empty name initially
1189                // The name gets filled in as more input is parsed
1190                while (msg_accum.tool_calls.size() <= diff.tool_call_index) {
1191                    msg_accum.tool_calls.push_back({ "", "", "" });
1192                }
1193                // Always update name and id from diff (may change during incremental parsing), but only if the delta
1194                // actually contains them
1195                if (!diff.tool_call_delta.name.empty()) {
1196                    msg_accum.tool_calls[diff.tool_call_index].name = diff.tool_call_delta.name;
1197                }
1198                if (!diff.tool_call_delta.id.empty()) {
1199                    msg_accum.tool_calls[diff.tool_call_index].id = diff.tool_call_delta.id;
1200                }
1201                if (!diff.tool_call_delta.arguments.empty()) {
1202                    msg_accum.tool_calls[diff.tool_call_index].arguments += diff.tool_call_delta.arguments;
1203                }
1204            }
1205        }
1206        try {
1207            assert_msg_equals(msg_current, msg_accum, true);
1208        } catch (std::exception & e) {
1209            throw std::runtime_error((std::string("Error comparing accumulated message to current: ") + e.what()).c_str());
1210        }
1211
1212        msg_prev = msg_current;
1213    }
1214
1215    if (!tc.is_partial) {
1216        assert_msg_equals(tc.expect, parser.parse(tc.input, false), true);
1217    }
1218    assert_msg_equals(tc.expect, msg_accum, true);
1219
1220    // Test grammar if present in params
1221    if (!parser.params_.grammar.empty()) {
1222        auto grammar = build_grammar(parser.params_.grammar);
1223        if (!grammar) {
1224            throw std::runtime_error("Failed to build grammar: " + parser.params_.grammar);
1225        }
1226
1227        // In production, grammar triggers match against the full generated text
1228        // including the generation prompt. All positions are in full_input coordinates.
1229        const auto & gen_prompt = parser.params_.generation_prompt;
1230        std::string full_input = gen_prompt + tc.input;
1231
1232        // Determine whether the reasoning-budget sampler path applies: tool-call grammar
1233        // with all WORD triggers and thinking tags present. In production, the reasoning
1234        // budget sampler inhibits grammar application while inside thinking blocks —
1235        // triggers inside <think>...</think> are suppressed.
1236        bool use_reasoning_budget_path = false;
1237        if (parser.params_.grammar_lazy && !parser.params_.thinking_end_tags.empty()) {
1238            use_reasoning_budget_path = true;
1239            for (const auto & trigger : parser.params_.grammar_triggers) {
1240                if (trigger.type != COMMON_GRAMMAR_TRIGGER_TYPE_WORD) {
1241                    use_reasoning_budget_path = false;
1242                    break;
1243                }
1244            }
1245        }
1246
1247        // Find the earliest trigger position to determine the constrained portion
1248        auto earliest_trigger_pos = std::string::npos;
1249
1250        if (use_reasoning_budget_path) {
1251            // Reasoning-budget path: simulate thinking-aware trigger detection.
1252            // Walk through full_input tracking thinking state; only match triggers
1253            // when outside thinking blocks.
1254            const auto & think_start = parser.params_.thinking_start_tag;
1255            const auto & think_ends  = parser.params_.thinking_end_tags;
1256
1257            bool in_thinking = false;
1258            for (size_t i = 0; i < full_input.size(); ++i) {
1259                if (!in_thinking && !think_start.empty()
1260                        && full_input.compare(i, think_start.size(), think_start) == 0) {
1261                    in_thinking = true;
1262                    i += think_start.size() - 1;
1263                    continue;
1264                }
1265                if (in_thinking) {
1266                    for (const auto & think_end : think_ends) {
1267                        if (full_input.compare(i, think_end.size(), think_end) == 0) {
1268                            in_thinking = false;
1269                            i += think_end.size() - 1;
1270                            break;
1271                        }
1272                    }
1273                    continue;
1274                }
1275                // Outside thinking — check if any trigger word starts here
1276                for (const auto & trigger : parser.params_.grammar_triggers) {
1277                    if (full_input.compare(i, trigger.value.size(), trigger.value) == 0) {
1278                        if (earliest_trigger_pos == std::string::npos || i < earliest_trigger_pos) {
1279                            earliest_trigger_pos = i;
1280                        }
1281                    }
1282                }
1283                if (earliest_trigger_pos != std::string::npos) {
1284                    break;  // found the earliest
1285                }
1286            }
1287
1288            // If the reasoning-budget path found no trigger outside thinking but the test
1289            // expects tool calls, this template nests tool calls inside thinking
1290            // blocks (e.g. Kimi). Fall back to the legacy path for this case.
1291            if (earliest_trigger_pos == std::string::npos && !tc.expect.tool_calls.empty()) {
1292                use_reasoning_budget_path = false;
1293            }
1294        }
1295
1296        if (!use_reasoning_budget_path) {
1297            // Legacy path: find triggers without thinking-awareness
1298            for (const auto & trigger : parser.params_.grammar_triggers) {
1299                size_t      pos = std::string::npos;
1300                std::smatch match;
1301                switch (trigger.type) {
1302                    case COMMON_GRAMMAR_TRIGGER_TYPE_WORD:
1303                        {
1304                            const auto & word = trigger.value;
1305                            pos               = full_input.find(word);
1306                            break;
1307                        }
1308                    case COMMON_GRAMMAR_TRIGGER_TYPE_PATTERN:
1309                        {
1310                            const auto & compiled = std::regex(trigger.value);
1311                            if (std::regex_search(full_input, match, compiled)) {
1312                                pos = match.position(compiled.mark_count());
1313                            }
1314                            break;
1315                        }
1316                    case COMMON_GRAMMAR_TRIGGER_TYPE_PATTERN_FULL:
1317                        {
1318                            // In production, PATTERN_FULL triggers are checked against
1319                            // the text generated so far, growing token by token. Simulate
1320                            // by trying every prefix of full_input.
1321                            const auto & compiled = std::regex(trigger.value);
1322                            for (size_t end = gen_prompt.size(); end <= full_input.size(); ++end) {
1323                                std::string prefix = full_input.substr(0, end);
1324                                if (std::regex_match(prefix, match, compiled)) {
1325                                    pos = std::string::npos;
1326                                    for (size_t gi = 1; gi < match.size(); ++gi) {
1327                                        if (match[gi].length() > 0) {
1328                                            pos = match.position(gi);
1329                                            break;
1330                                        }
1331                                    }
1332                                    if (pos == std::string::npos) {
1333                                        pos = match.position(0);
1334                                    }
1335                                    break;
1336                                }
1337                            }
1338                            break;
1339                        }
1340                    default:
1341                        throw std::runtime_error("Unknown trigger type");
1342                }
1343                if (pos != std::string::npos) {
1344                    if (earliest_trigger_pos == std::string::npos || pos < earliest_trigger_pos) {
1345                        earliest_trigger_pos = pos;
1346                    }
1347                }
1348            }
1349        }
1350
1351        // If the test expects tool calls and the grammar is lazy, the trigger must fire.
1352        // Otherwise the grammar would never activate in production and tool calls wouldn't
1353        // be constrained. A silent skip here would hide broken triggers.
1354        if (parser.params_.grammar_lazy && !tc.expect.tool_calls.empty() && !tc.is_partial
1355                && earliest_trigger_pos == std::string::npos) {
1356            std::string trigger_desc;
1357            for (const auto & trigger : parser.params_.grammar_triggers) {
1358                trigger_desc += "\n  [type=" + std::to_string(trigger.type) + "] " + trigger.value;
1359            }
1360            throw std::runtime_error(
1361                "Grammar trigger did not fire, but test expects tool calls (lazy grammar).\n"
1362                ">>> Input: " + full_input + "\n"
1363                ">>> Triggers (" + std::to_string(parser.params_.grammar_triggers.size()) + "):" + trigger_desc);
1364        }
1365
1366        // Determine the constrained portion of input to test against grammar.
1367        // If the trigger position falls inside the generation prompt, the grammar
1368        // sampler was already active before model output began — constrain from the
1369        // start of the model output (i.e. tc.input).
1370        std::string constrained = full_input;
1371        bool grammar_triggered = false;
1372        if (earliest_trigger_pos != std::string::npos) {
1373            auto constrain_from = std::max(earliest_trigger_pos, gen_prompt.size());
1374            constrained = full_input.substr(constrain_from);
1375            grammar_triggered = true;
1376        } else if (!parser.params_.grammar_lazy) {
1377            // For non-lazy grammars, the entire input should match
1378            grammar_triggered = true;
1379        }
1380
1381        // Test the constrained portion against the grammar
1382        if (grammar_triggered && !tc.is_partial) {
1383            auto result = match_string_detailed(constrained, grammar.get());
1384            if (!result.success) {
1385                std::string error_msg;
1386                if (result.incomplete) {
1387                    error_msg =
1388                        "Grammar matched all input but expects more:\n\n"
1389                        ">>> Input: " + tc.input +
1390                        "\n\n>>> Constrained: " + constrained +
1391                        "\n\n>>> Matched prefix (" + std::to_string(result.matched_bytes) + " bytes, " +
1392                        std::to_string(result.matched_codepoints) + " codepoints): " +
1393                        (result.matched_prefix.size() > 100 ? result.matched_prefix.substr(0, 100) + "..." : result.matched_prefix) +
1394                        "\n\n>>> Expected next: " + result.expected_description +
1395                        "\n\n>>> Grammar: " + parser.params_.grammar;
1396                } else {
1397                    error_msg =
1398                        "Grammar match failed:\n\n"
1399                        ">>> Input: " + tc.input +
1400                        "\n\n>>> Constrained: " + constrained +
1401                        "\n\n>>> Matched prefix (" + std::to_string(result.matched_bytes) + " bytes, " +
1402                        std::to_string(result.matched_codepoints) + " codepoints): " +
1403                        (result.matched_prefix.size() > 100 ? result.matched_prefix.substr(0, 100) + "..." : result.matched_prefix) +
1404                        "\n\n>>> Failing character: " + result.failing_char +
1405                        "\n\n>>> Expected: " + result.expected_description +
1406                        "\n\n>>> Grammar: " + parser.params_.grammar;
1407                }
1408                throw std::runtime_error(error_msg);
1409            }
1410        }
1411    }
1412
1413    // Reconstruction test: verify that appending the parsed message to the original
1414    // messages and re-rendering the template (without generation prompt) reproduces
1415    // the original prompt + input exactly, or as a proper prefix (the template may
1416    // append end-of-turn tokens after the assistant message).
1417    if ((tc.expect_reconstruction || g_force_reconstruction_test) && !tc.is_partial) {
1418        // Start from tc.expect but copy tool call arguments from the actual parser
1419        // output, which preserves original JSON formatting (e.g. {"arg1":1} vs {"arg1": 1}).
1420        auto reconstruction_msg = tc.expect;
1421        auto parsed_msg         = parser.parse(tc.input, false);
1422        for (size_t i = 0; i < reconstruction_msg.tool_calls.size() && i < parsed_msg.tool_calls.size(); i++) {
1423            reconstruction_msg.tool_calls[i].arguments = parsed_msg.tool_calls[i].arguments;
1424        }
1425        common_chat_templates_inputs reconstruction_inputs = tc.params;
1426        reconstruction_inputs.messages.push_back(reconstruction_msg);
1427        reconstruction_inputs.add_generation_prompt = false;
1428
1429        auto reconstruction_params = common_chat_templates_apply(tmpls, reconstruction_inputs);
1430        std::string expected_text  = parser.params_.prompt + tc.input;
1431        bool match = reconstruction_params.prompt == expected_text ||
1432            (reconstruction_params.prompt.size() > expected_text.size() &&
1433             reconstruction_params.prompt.compare(0, expected_text.size(), expected_text) == 0);
1434        if (!match && g_force_reconstruction_test && !tc.expect_reconstruction) {
1435            // In forced mode, report mismatch but don't fail
1436            // Find the first difference position
1437            size_t diff_pos = 0;
1438            size_t min_len  = std::min(expected_text.size(), reconstruction_params.prompt.size());
1439            while (diff_pos < min_len && expected_text[diff_pos] == reconstruction_params.prompt[diff_pos]) {
1440                diff_pos++;
1441            }
1442            size_t ctx_start = diff_pos > 60 ? diff_pos - 60 : 0;
1443            size_t ctx_end_e = std::min(expected_text.size(), diff_pos + 40);
1444            size_t ctx_end_r = std::min(reconstruction_params.prompt.size(), diff_pos + 40);
1445            LOG_ERR("\x1b[31m[RECONSTRUCTION FAIL]\x1b[0m "
1446                    "first diff at byte %zu (expected len=%zu, reconstructed len=%zu)\n"
1447                    "  expected:      ...%s...\n"
1448                    "  reconstructed: ...%s...\n",
1449                    diff_pos, expected_text.size(), reconstruction_params.prompt.size(),
1450                    expected_text.substr(ctx_start, ctx_end_e - ctx_start).c_str(),
1451                    reconstruction_params.prompt.substr(ctx_start, ctx_end_r - ctx_start).c_str());
1452        } else if (!match) {
1453            std::string error_msg =
1454                "Reconstruction mismatch:\n\n"
1455                ">>> Expected (prompt + input):\n" + expected_text +
1456                "\n\n>>> Reconstructed:\n" + reconstruction_params.prompt;
1457            throw std::runtime_error(error_msg);
1458        } else if (g_force_reconstruction_test) {
1459            LOG_INF("\x1b[32m[RECONSTRUCTION OK]\x1b[0m\n");
1460        }
1461    }
1462}
1463
1464// Fluent builder for PEG parser tests
1465class peg_test_builder;
1466
1467class peg_tester {
1468    common_chat_templates_ptr tmpls_;
1469    std::string               template_path_;
1470    bool                      detailed_debug_;
1471    friend class peg_test_builder;
1472
1473  public:
1474    explicit peg_tester(const std::string & template_path, const bool detailed_debug = false) :
1475        tmpls_(read_templates(template_path)),
1476        template_path_(template_path),
1477        detailed_debug_(detailed_debug) {}
1478
1479    const std::string & template_path() const { return template_path_; }
1480
1481    peg_test_builder test(const std::string & input);
1482};
1483
1484class peg_test_builder {
1485    peg_tester &  tester_;
1486    peg_test_case tc_;
1487
1488  public:
1489    peg_test_builder(peg_tester & tester, const std::string & input) : tester_(tester) {
1490        tc_.input = input;
1491        tc_.params.add_generation_prompt = true;
1492    }
1493
1494    // Parameter setters
1495    peg_test_builder & reasoning_format(common_reasoning_format fmt) {
1496        tc_.params.reasoning_format = fmt;
1497        return *this;
1498    }
1499
1500    peg_test_builder & tools(std::vector<common_chat_tool> tools) {
1501        tc_.params.tools = std::move(tools);
1502        return *this;
1503    }
1504
1505    peg_test_builder & enable_thinking(bool val) {
1506        tc_.params.enable_thinking = val;
1507        return *this;
1508    }
1509
1510    peg_test_builder & parallel_tool_calls(bool val) {
1511        tc_.params.parallel_tool_calls = val;
1512        return *this;
1513    }
1514
1515    peg_test_builder & add_generation_prompt(bool val) {
1516        tc_.params.add_generation_prompt = val;
1517        return *this;
1518    }
1519
1520    peg_test_builder & continue_final_message(common_chat_continuation cont) {
1521        tc_.params.continue_final_message = cont;
1522        return *this;
1523    }
1524
1525    peg_test_builder & json_schema(const std::string & schema) {
1526        tc_.params.json_schema = schema;
1527        return *this;
1528    }
1529
1530    peg_test_builder & is_partial(bool val) {
1531        tc_.is_partial = val;
1532        return *this;
1533    }
1534
1535    peg_test_builder & expect_reconstruction(bool val = true) {
1536        tc_.expect_reconstruction = val;
1537        return *this;
1538    }
1539
1540    // Expect setters
1541    peg_test_builder & expect(const common_chat_msg & msg) {
1542        tc_.expect = msg;
1543        return *this;
1544    }
1545
1546    peg_test_builder & expect_content(const std::string & content) {
1547        tc_.expect.content = content;
1548        return *this;
1549    }
1550
1551    peg_test_builder & expect_reasoning(const std::string & reasoning) {
1552        tc_.expect.reasoning_content = reasoning;
1553        return *this;
1554    }
1555
1556    peg_test_builder & expect_tool_calls(std::vector<common_chat_tool_call> calls) {
1557        tc_.expect.tool_calls = std::move(calls);
1558        return *this;
1559    }
1560
1561    peg_test_builder & tool_choice(common_chat_tool_choice choice) {
1562        tc_.params.tool_choice = choice;
1563        return *this;
1564    }
1565
1566    peg_test_builder & messages(std::vector<common_chat_msg> messages) {
1567        tc_.params.messages = std::move(messages);
1568        return *this;
1569    }
1570
1571    // Execute the test
1572    void run() {
1573        // Check template filter
1574        if (!g_template_filter.empty()) {
1575            // Case-insensitive substring match
1576            std::string template_path_lower = tester_.template_path();
1577            std::string filter_lower        = g_template_filter;
1578            std::transform(template_path_lower.begin(), template_path_lower.end(), template_path_lower.begin(),
1579                           ::tolower);
1580            std::transform(filter_lower.begin(), filter_lower.end(), filter_lower.begin(), ::tolower);
1581            if (template_path_lower.find(filter_lower) == std::string::npos) {
1582                // Skip this test
1583                return;
1584            }
1585        }
1586        LOG_INF("\n\x1b[38;5;126m[%s]\x1b[0m\n%s\n\n", tester_.template_path().c_str(), tc_.input.c_str());
1587        test_peg_parser(tester_.tmpls_.get(), [this](peg_test_case & t) { t = tc_; }, tester_.detailed_debug_);
1588    }
1589};
1590
1591peg_test_builder peg_tester::test(const std::string & input) {
1592    return peg_test_builder(*this, input);
1593}
1594
1595static void test_msgs_oaicompat_json_conversion() {
1596    LOG_DBG("%s\n", __func__);
1597    std::vector<common_chat_msg> msgs{
1598        message_user,
1599        message_user_parts,
1600        message_assist_call,
1601        message_assist_call_thoughts,
1602        message_assist_call_thoughts_unparsed,
1603        message_assist_call_thoughts_content,
1604        message_assist_call_id,
1605        message_assist_call_idx,
1606        message_assist_call_python,
1607    };
1608    for (const auto & msg : msgs) {
1609        auto oai_json = common_chat_msgs_to_json_oaicompat({ msg });
1610        auto msgs2    = common_chat_msgs_parse_oaicompat(oai_json);
1611        assert_equals((size_t) 1, msgs2.size());
1612        const auto & msg2 = msgs2[0];
1613        assert_msg_equals(msg, msg2);
1614    }
1615    assert_equals(std::string("[\n"
1616                              "  {\n"
1617                              "    \"role\": \"user\",\n"
1618                              "    \"content\": [\n"
1619                              "      {\n"
1620                              "        \"type\": \"text\",\n"
1621                              "        \"text\": \"Hey\"\n"
1622                              "      },\n"
1623                              "      {\n"
1624                              "        \"type\": \"text\",\n"
1625                              "        \"text\": \"there\"\n"
1626                              "      }\n"
1627                              "    ]\n"
1628                              "  }\n"
1629                              "]"),
1630                  common_chat_msgs_to_json_oaicompat({ message_user_parts }).dump(2));
1631
1632    // Note: content is "" instead of null due to workaround for templates that render null as "None"
1633    assert_equals(std::string("[\n"
1634                              "  {\n"
1635                              "    \"role\": \"assistant\",\n"
1636                              "    \"content\": \"\",\n"
1637                              "    \"tool_calls\": [\n"
1638                              "      {\n"
1639                              "        \"type\": \"function\",\n"
1640                              "        \"function\": {\n"
1641                              "          \"name\": \"python\",\n"
1642                              "          \"arguments\": \"{\\\"code\\\":\\\"print('hey')\\\"}\"\n"
1643                              "        }\n"
1644                              "      }\n"
1645                              "    ]\n"
1646                              "  }\n"
1647                              "]"),
1648                  common_chat_msgs_to_json_oaicompat({ message_assist_call_python }).dump(2));
1649
1650    auto res = common_chat_msgs_parse_oaicompat(json::parse("[{\"role\": \"assistant\", \"tool_calls\": []}]"));
1651    assert_equals<size_t>(1, res.size());
1652    assert_equals<std::string>(res[0].role, "assistant");
1653    assert_equals(true, res[0].content.empty());
1654    assert_equals(true, res[0].tool_calls.empty());
1655
1656    try {
1657        common_chat_msgs_parse_oaicompat(json::parse("[{\"role\": \"assistant\"}]"));
1658        throw std::runtime_error("Expected exception");
1659    } catch (const std::exception & e) {
1660        if (std::string(e.what()).find("'content'") == std::string::npos) {
1661            throw std::runtime_error("Expected exception about missing 'content'");
1662        }
1663    }
1664}
1665
1666static void test_msg_token_delimiters_split() {
1667    LOG_DBG("%s\n", __func__);
1668
1669    // Delimiters that share a leading token, distinguished by the second token,
1670    // to exercise the per-position token matching.
1671    const common_chat_msg_delimiters delims = {
1672        { { COMMON_CHAT_ROLE_USER,      "", { 10, 11 } },
1673          { COMMON_CHAT_ROLE_ASSISTANT, "", { 10, 12 } } }
1674    };
1675
1676    // Empty inputs
1677    assert_equals<size_t>(0, common_chat_msg_delimiters{}.split({}).spans.size());
1678    assert_equals<size_t>(0, common_chat_msg_delimiters{}.split({ 10, 11 }).spans.size());
1679    assert_equals<size_t>(0, delims.split({}).spans.size());
1680
1681    // No delimiters match -> no spans
1682    assert_equals<size_t>(0, delims.split({ 100, 101, 102 }).spans.size());
1683
1684    // Multi-role conversation: <user>Hi<assistant>Hello<user>Bye
1685    {
1686        const llama_tokens tokens = {
1687            10, 11,            // <user>
1688            100, 101,          // Hi
1689            10, 12,            // <assistant>
1690            200, 201, 202,     // Hello
1691            10, 11,            // <user>
1692            300, 301,          // Bye
1693        };
1694
1695        const auto result = delims.split(tokens);
1696        const auto & spans = result.spans;
1697        assert_equals<size_t>(3, spans.size());
1698
1699        assert_equals(COMMON_CHAT_ROLE_USER, spans[0].role);
1700        assert_equals<size_t>(0, spans[0].pos);
1701        assert_equals<size_t>(4, spans[0].len);
1702
1703        assert_equals(COMMON_CHAT_ROLE_ASSISTANT, spans[1].role);
1704        assert_equals<size_t>(4, spans[1].pos);
1705        assert_equals<size_t>(5, spans[1].len);
1706
1707        assert_equals(COMMON_CHAT_ROLE_USER, spans[2].role);
1708        assert_equals<size_t>(9, spans[2].pos);
1709        assert_equals<size_t>(4, spans[2].len);
1710
1711        // is_user_start() is true at the token position where a user span begins
1712        assert_equals(true,  result.is_user_start(0));
1713        assert_equals(false, result.is_user_start(4));  // assistant span
1714        assert_equals(true,  result.is_user_start(9));
1715    }
1716
1717    // Content before the first delimiter is not captured as a span
1718    {
1719        const llama_tokens tokens = {
1720            500, 501,    // leading content (dropped)
1721            10, 11,      // <user>
1722            100,         // Hi
1723        };
1724
1725        const auto spans = delims.split(tokens).spans;
1726        assert_equals<size_t>(1, spans.size());
1727        assert_equals(COMMON_CHAT_ROLE_USER, spans[0].role);
1728        assert_equals<size_t>(2, spans[0].pos);
1729        assert_equals<size_t>(3, spans[0].len);
1730    }
1731
1732    // Skipped regions (media chunks) are jumped over but still count as span content
1733    {
1734        const llama_tokens tokens = {
1735            10, 11,             // <user>
1736            LLAMA_TOKEN_NULL,   // media chunk (3 tokens)
1737            LLAMA_TOKEN_NULL,
1738            LLAMA_TOKEN_NULL,
1739            100,                // Hi
1740            10, 12,             // <assistant>
1741        };
1742
1743        const std::map<size_t, size_t> skips = { { 2, 3 } };
1744
1745        const auto spans = delims.split(tokens, skips).spans;
1746        assert_equals<size_t>(2, spans.size());
1747
1748        assert_equals(COMMON_CHAT_ROLE_USER, spans[0].role);
1749        assert_equals<size_t>(0, spans[0].pos);
1750        assert_equals<size_t>(6, spans[0].len);
1751
1752        assert_equals(COMMON_CHAT_ROLE_ASSISTANT, spans[1].role);
1753        assert_equals<size_t>(6, spans[1].pos);
1754        assert_equals<size_t>(2, spans[1].len);
1755    }
1756
1757    // A delimiter sequence inside a skipped region is not matched
1758    {
1759        const llama_tokens tokens = {
1760            10, 11,      // <user>
1761            10, 12,      // skipped region that happens to contain delimiter tokens
1762            100,         // Hi
1763        };
1764
1765        const std::map<size_t, size_t> skips = { { 2, 2 } };
1766
1767        const auto spans = delims.split(tokens, skips).spans;
1768        assert_equals<size_t>(1, spans.size());
1769        assert_equals(COMMON_CHAT_ROLE_USER, spans[0].role);
1770        assert_equals<size_t>(0, spans[0].pos);
1771        assert_equals<size_t>(5, spans[0].len);
1772    }
1773}
1774
1775static void test_tools_oaicompat_json_conversion() {
1776    LOG_DBG("%s\n", __func__);
1777    std::vector<common_chat_tool> tools{
1778        special_function_tool,
1779        python_tool,
1780    };
1781
1782    for (const auto & tool : tools) {
1783        auto oai_json = common_chat_tools_to_json_oaicompat({ tool });
1784        auto tools2   = common_chat_tools_parse_oaicompat(oai_json);
1785        assert_equals((size_t) 1, tools2.size());
1786        auto tool2 = tools2[0];
1787        assert_equals(tool.name, tool2.name);
1788        assert_equals(tool.description, tool2.description);
1789        assert_equals(json::parse(tool.parameters).dump(2), json::parse(tool2.parameters).dump(2));
1790    }
1791
1792    assert_equals(std::string("[\n"
1793                              "  {\n"
1794                              "    \"type\": \"function\",\n"
1795                              "    \"function\": {\n"
1796                              "      \"name\": \"special_function\",\n"
1797                              "      \"description\": \"I'm special\",\n"
1798                              "      \"parameters\": {\n"
1799                              "        \"type\": \"object\",\n"
1800                              "        \"properties\": {\n"
1801                              "          \"arg1\": {\n"
1802                              "            \"type\": \"integer\",\n"
1803                              "            \"description\": \"The arg.\"\n"
1804                              "          }\n"
1805                              "        },\n"
1806                              "        \"required\": [\n"
1807                              "          \"arg1\"\n"
1808                              "        ]\n"
1809                              "      }\n"
1810                              "    }\n"
1811                              "  }\n"
1812                              "]"),
1813                  common_chat_tools_to_json_oaicompat({ special_function_tool }).dump(2));
1814}
1815
1816static void test_convert_responses_to_chatcmpl() {
1817    LOG_DBG("%s\n", __func__);
1818
1819    // Test basic conversion with input messages (user/assistant alternating)
1820    {
1821        json input = json::parse(R"({
1822            "input": [
1823                {
1824                    "type": "message",
1825                    "role": "user",
1826                    "content": "hi wassup"
1827                },
1828                {
1829                    "type": "message",
1830                    "role": "assistant",
1831                    "content": "Hey! 👋 Not much, just here ready to chat. What's up with you? Anything I can help you with today?"
1832                },
1833                {
1834                    "type": "message",
1835                    "role": "user",
1836                    "content": "hi"
1837                }
1838            ],
1839            "model": "gpt-5-mini",
1840            "stream": false,
1841            "text": {},
1842            "reasoning": {
1843                "effort": "medium"
1844            }
1845        })");
1846
1847        json result = server_chat_convert_responses_to_chatcmpl(input);
1848
1849        // Verify messages were converted correctly
1850        assert_equals(true, result.contains("messages"));
1851        assert_equals(true, result.at("messages").is_array());
1852        assert_equals((size_t)3, result.at("messages").size());
1853
1854        // Check first message (user)
1855        const auto & msg0 = result.at("messages")[0];
1856        assert_equals(std::string("user"), msg0.at("role").get<std::string>());
1857        assert_equals(true, msg0.at("content").is_array());
1858        assert_equals(std::string("text"), msg0.at("content")[0].at("type").get<std::string>());
1859        assert_equals(std::string("hi wassup"), msg0.at("content")[0].at("text").get<std::string>());
1860
1861        // Check second message (assistant)
1862        const auto & msg1 = result.at("messages")[1];
1863        assert_equals(std::string("assistant"), msg1.at("role").get<std::string>());
1864        assert_equals(true, msg1.at("content").is_array());
1865        assert_equals(std::string("text"), msg1.at("content")[0].at("type").get<std::string>());
1866        assert_equals(std::string("Hey! 👋 Not much, just here ready to chat. What's up with you? Anything I can help you with today?"), msg1.at("content")[0].at("text").get<std::string>());
1867
1868        // Check third message (user)
1869        const auto & msg2 = result.at("messages")[2];
1870        assert_equals(std::string("user"), msg2.at("role").get<std::string>());
1871        assert_equals(true, msg2.at("content").is_array());
1872        assert_equals(std::string("text"), msg2.at("content")[0].at("type").get<std::string>());
1873        assert_equals(std::string("hi"), msg2.at("content")[0].at("text").get<std::string>());
1874
1875        // Verify other fields preserved
1876        assert_equals(std::string("gpt-5-mini"), result.at("model").get<std::string>());
1877        assert_equals(false, result.at("stream").get<bool>());
1878    }
1879
1880    // Test string input
1881    {
1882        json input = json::parse(R"({
1883            "input": "Hello, world!",
1884            "model": "test-model"
1885        })");
1886
1887        json result = server_chat_convert_responses_to_chatcmpl(input);
1888
1889        assert_equals((size_t)1, result.at("messages").size());
1890        const auto & msg = result.at("messages")[0];
1891        assert_equals(std::string("user"), msg.at("role").get<std::string>());
1892        assert_equals(std::string("Hello, world!"), msg.at("content").get<std::string>());
1893    }
1894
1895    // Test with instructions (system message)
1896    {
1897        json input = json::parse(R"({
1898            "input": "Hello",
1899            "instructions": "You are a helpful assistant.",
1900            "model": "test-model"
1901        })");
1902
1903        json result = server_chat_convert_responses_to_chatcmpl(input);
1904
1905        assert_equals((size_t)2, result.at("messages").size());
1906        const auto & sys_msg = result.at("messages")[0];
1907        assert_equals(std::string("system"), sys_msg.at("role").get<std::string>());
1908        assert_equals(std::string("You are a helpful assistant."), sys_msg.at("content").get<std::string>());
1909    }
1910
1911    // Test with max_output_tokens conversion
1912    {
1913        json input = json::parse(R"({
1914            "input": "Hello",
1915            "model": "test-model",
1916            "max_output_tokens": 100
1917        })");
1918
1919        json result = server_chat_convert_responses_to_chatcmpl(input);
1920
1921        assert_equals(true, result.contains("max_tokens"));
1922        assert_equals(false, result.contains("max_output_tokens"));
1923        assert_equals(100, result.at("max_tokens").get<int>());
1924    }
1925
1926    // Test mixed Responses tools: convert only function tools
1927    {
1928        json input = json::parse(R"({
1929            "input": "Hello",
1930            "model": "test-model",
1931            "tools": [
1932                {
1933                    "type": "web_search"
1934                },
1935                {
1936                    "type": "function",
1937                    "name": "get_weather",
1938                    "description": "Get weather for a location",
1939                    "parameters": {
1940                        "type": "object",
1941                        "properties": {
1942                            "location": {
1943                                "type": "string"
1944                            }
1945                        },
1946                        "required": ["location"]
1947                    }
1948                },
1949                {
1950                    "type": "image_generation"
1951                },
1952                {
1953                    "type": "mcp",
1954                    "server_label": "test-server"
1955                },
1956                {
1957                    "type": "namespace",
1958                    "name": "browser"
1959                }
1960            ]
1961        })");
1962
1963        json result = server_chat_convert_responses_to_chatcmpl(input);
1964
1965        assert_equals(true, result.contains("tools"));
1966        assert_equals(true, result.at("tools").is_array());
1967        assert_equals((size_t)1, result.at("tools").size());
1968
1969        const auto & tool = result.at("tools")[0];
1970        assert_equals(std::string("function"), tool.at("type").get<std::string>());
1971        assert_equals(std::string("get_weather"), tool.at("function").at("name").get<std::string>());
1972        assert_equals(true, tool.at("function").at("strict").get<bool>());
1973    }
1974
1975    // Test non-function Responses tools are ignored
1976    {
1977        json input = json::parse(R"({
1978            "input": "Hello",
1979            "model": "test-model",
1980            "tools": [
1981                {
1982                    "type": "web_search"
1983                },
1984                {
1985                    "type": "image_generation"
1986                },
1987                {
1988                    "type": "mcp",
1989                    "server_label": "test-server"
1990                },
1991                {
1992                    "type": "namespace",
1993                    "name": "browser"
1994                }
1995            ]
1996        })");
1997
1998        json result = server_chat_convert_responses_to_chatcmpl(input);
1999
2000        assert_equals(false, result.contains("tools"));
2001    }
2002}
2003
2004// Shared LFM2 parser cases - all variants use one output format and parser
2005static void test_lfm2_parser(const std::string & template_path, bool detailed_debug) {
2006    auto tst = peg_tester(template_path, detailed_debug);
2007
2008    // Basic content only
2009    tst.test("Hello, world!\nWhat's up?").expect(message_assist).run();
2010
2011    // Single tool call without reasoning
2012    tst.test("<|tool_call_start|>[special_function(arg1=1)]<|tool_call_end|>")
2013        .tools({ special_function_tool })
2014        .expect(message_assist_call)
2015        .run();
2016
2017    // Tool call with string argument
2018    tst.test("<|tool_call_start|>[get_time(city=\"XYZCITY\")]<|tool_call_end|>")
2019        .tools({ get_time_tool })
2020        .expect(message_with_tool_calls("get_time", "{\"city\":\"XYZCITY\"}"))
2021        .run();
2022
2023    // Python literals become JSON
2024    tst.test("<|tool_call_start|>[toggle(enabled=True)]<|tool_call_end|>")
2025        .tools({ toggle_tool })
2026        .expect(message_with_tool_calls("toggle", R"({"enabled": true})"))
2027        .run();
2028
2029    tst.test("<|tool_call_start|>[set_nullable(value=None)]<|tool_call_end|>")
2030        .tools({ nullable_tool })
2031        .expect(message_with_tool_calls("set_nullable", R"({"value": null})"))
2032        .run();
2033
2034    // Nested Python literal
2035    tst.test("<|tool_call_start|>[set_config(config={\"enabled\": True, \"count\": 3})]<|tool_call_end|>")
2036        .tools({ config_tool })
2037        .expect(message_with_tool_calls("set_config", R"({"config": {"enabled": true, "count": 3}})"))
2038        .run();
2039
2040    // JSON literals are accepted too
2041    tst.test("<|tool_call_start|>[set_config(config={\"enabled\": true, \"note\": null})]<|tool_call_end|>")
2042        .tools({ config_tool })
2043        .expect(message_with_tool_calls("set_config", R"({"config": {"enabled": true, "note": null}})"))
2044        .run();
2045
2046    // Dotted function name with structured args
2047    tst.test("<|tool_call_start|>[Calendar.create_event(title=\"demo\", participants=[\"Alice\", \"Bob\"], "
2048             "metadata={\"priority\": \"high\", \"reminder\": true})]<|tool_call_end|>")
2049        .tools({ calendar_create_event_tool })
2050        .expect(message_with_tool_calls(
2051            "Calendar.create_event",
2052            R"({"title": "demo", "participants": ["Alice", "Bob"], "metadata": {"priority": "high", "reminder": true}})"))
2053        .run();
2054
2055    // Markdown links stay content
2056    tst.test("Use this format: [link text](url). Example: [Wikipedia](https://www.wikipedia.org).")
2057        .tools({ get_time_tool })
2058        .expect(simple_assist_msg("Use this format: [link text](url). Example: [Wikipedia](https://www.wikipedia.org)."))
2059        .run();
2060
2061    // Python tool with multiline code in string: the \n in the literal decodes to a real
2062    // newline, emitted as a JSON \n escape (not a doubled backslash).
2063    tst.test("<|tool_call_start|>[python(code=\"def hello():\\n    print('hey')\")]<|tool_call_end|>")
2064        .tools({ python_tool })
2065        .expect_tool_calls({
2066            { "python", R"#({"code": "def hello():\n    print('hey')"})#", "" }
2067        })
2068        .run();
2069
2070    // String escape sequences decode to their actual characters (newline + tab here),
2071    // so a "write a two line file" style call produces real line breaks, not literal "\n".
2072    tst.test("<|tool_call_start|>[python(code=\"First line\\nSecond line\\tindented\")]<|tool_call_end|>")
2073        .tools({ python_tool })
2074        .expect_tool_calls({
2075            { "python", R"#({"code": "First line\nSecond line\tindented"})#", "" }
2076        })
2077        .run();
2078
2079    // Escaped quotes inside a string argument survive the round-trip.
2080    tst.test("<|tool_call_start|>[python(code=\"print(\\\"hi\\\")\")]<|tool_call_end|>")
2081        .tools({ python_tool })
2082        .expect_tool_calls({
2083            { "python", R"#({"code": "print(\"hi\")"})#", "" }
2084        })
2085        .run();
2086
2087    // Content before tool call (no reasoning)
2088    tst.test("Let me check the time.<|tool_call_start|>[get_time(city=\"Paris\")]<|tool_call_end|>")
2089        .tools({ get_time_tool })
2090        .expect(message_with_reasoning_content_and_multiple_tool_calls(
2091            "", "Let me check the time.", { { "get_time", "{\"city\":\"Paris\"}" } }
2092        ))
2093        .run();
2094
2095    // Multiple tool calls (parallel)
2096    tst.test("<|tool_call_start|>[special_function(arg1=1), special_function_with_opt(arg1=1, arg2=2)]<|tool_call_end|>")
2097        .parallel_tool_calls(true)
2098        .tools({ special_function_tool, special_function_tool_with_optional_param })
2099        .expect_tool_calls({
2100            { "special_function", R"({"arg1": 1})", {} },
2101            { "special_function_with_opt", R"({"arg1": 1, "arg2": 2})", {} },
2102        })
2103        .run();
2104
2105    // Partial tool call (streaming)
2106    tst.test("<|tool_call_start|>[special_function(arg1=")
2107        .tools({ special_function_tool })
2108        .is_partial(true)
2109        .expect(simple_assist_msg("", "", "special_function", "{\"arg1\": "))
2110        .run();
2111
2112    // Tool call with empty arguments
2113    tst.test("<|tool_call_start|>[empty_args()]<|tool_call_end|>")
2114        .tools({ empty_args_tool })
2115        .expect(simple_assist_msg("", "", "empty_args", "{}"))
2116        .run();
2117
2118}
2119
2120static void test_template_output_peg_parsers(bool detailed_debug) {
2121    LOG_DBG("%s\n", __func__);
2122
2123    // JSON schemas
2124    const char * invoice_schema = R"({
2125        "type": "object",
2126        "properties": {
2127            "amount": {"type": "number"},
2128            "date": {"type": "string"}
2129        }
2130    })";
2131
2132    const char * const_schema = R"({
2133        "const": "42"
2134    })";
2135
2136    {
2137        // Qwen3.5 (basically same as Nemotron, but keeping separate tests just in case)
2138        auto tst = peg_tester("models/templates/Qwen3.5-4B.jinja", detailed_debug);
2139
2140        tst.test("I'm\nthinking\n</think>\n\nHello, world!\nWhat's up?")
2141            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2142            .enable_thinking(true)
2143            .expect(message_assist_thoughts)
2144            .run();
2145
2146        tst.test("I'm\nthinking\n</think>\n\nHello, world!\nWhat's up?")
2147            .enable_thinking(true)
2148            .reasoning_format(COMMON_REASONING_FORMAT_NONE)
2149            .expect_content("<think>\nI'm\nthinking\n</think>\n\nHello, world!\nWhat's up?")
2150            .run();
2151
2152        tst.test(
2153               "<tool_call>\n"
2154               "<function=special_function>\n"
2155               "<parameter=arg1>\n1\n</parameter>\n"
2156               "</function>\n"
2157               "</tool_call>")
2158            .enable_thinking(false)
2159            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2160            .tools({ special_function_tool })
2161            .expect(message_assist_call)
2162            .run();
2163
2164        tst.test(
2165               "I'm\nthinking\n</think>\n\n"
2166               "<tool_call>\n"
2167               "<function=special_function>\n"
2168               "<parameter=arg1>\n1\n</parameter>\n"
2169               "</function>\n"
2170               "</tool_call>")
2171            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2172            .tools({ special_function_tool })
2173            .expect(message_assist_call_thoughts)
2174            .run();
2175
2176        tst.test(
2177               "<tool_call>\n"
2178               "<function=special_function>\n"
2179               "<parameter=arg1>\n1\n</parameter>\n"
2180               "</function>\n"
2181               "</tool_call>\n"
2182               "<tool_call>\n"
2183               "<function=special_function_with_opt>\n"
2184               "<parameter=arg1>\n1\n</parameter>\n"
2185               "<parameter=arg2>\n2\n</parameter>\n"
2186               "</function>\n"
2187               "</tool_call>")
2188            .enable_thinking(false)
2189            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2190            .parallel_tool_calls(true)
2191            .tools({
2192                special_function_tool, special_function_tool_with_optional_param
2193        })
2194            .expect_tool_calls({
2195                { "special_function", R"({"arg1": 1})", {} },
2196                { "special_function_with_opt", R"({"arg1": 1, "arg2": 2})", {} },
2197            })
2198            .run();
2199
2200        tst.test(
2201               "<tool_call>\n"
2202               "<function=python>\n"
2203               "<parameter=code>\n"
2204               "def hello():\n"
2205               "    print(\"Hello, world!\")\n"
2206               "\n"
2207               "hello()\n"
2208               "</parameter>\n"
2209               "</function>\n"
2210               "</tool_call>")
2211            .enable_thinking(false)
2212            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2213            .tools({
2214                python_tool
2215        })
2216            .expect_tool_calls({
2217                { "python", "{\"code\": \"def hello():\\n    print(\\\"Hello, world!\\\")\\n\\nhello()\"}", {} },
2218            })
2219            .run();
2220
2221        tst.test(
2222               "<tool_call>\n"
2223               "<function=edit>\n"
2224               "<parameter=filename>\n"
2225               "foo.c\n"
2226               "</parameter>\n"
2227               "<parameter=oldString>\n"
2228               "#iclunde\n"
2229               "</parameter>\n"
2230               "<parameter=newString>\n"
2231               "#include\n"
2232               "</parameter>\n"
2233               "</function>\n"
2234               "</tool_call>")
2235            .enable_thinking(false)
2236            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2237            .tools({
2238                edit_tool
2239        })
2240            .expect_tool_calls({
2241                { "edit", "{\"filename\": \"foo.c\", \"oldString\": \"#iclunde\", \"newString\": \"#include\"}", {} },
2242            })
2243            .run();
2244
2245        // a parameter value that itself ends in a newline (e.g. a source file with a
2246        // trailing newline). The structural delimiter is "\n</parameter>\n", so the value
2247        // "#include\n" renders as "...#include\n\n</parameter>\n". The trailing newline must
2248        // be preserved faithfully (no stripping), and the generated grammar must admit a
2249        // value ending on a delimiter prefix. Regression test for gbnf_excluding_pattern.
2250        tst.test(
2251               "<tool_call>\n"
2252               "<function=edit>\n"
2253               "<parameter=filename>\n"
2254               "foo.c\n"
2255               "</parameter>\n"
2256               "<parameter=oldString>\n"
2257               "#iclunde\n"
2258               "</parameter>\n"
2259               "<parameter=newString>\n"
2260               "#include\n"
2261               "\n"
2262               "</parameter>\n"
2263               "</function>\n"
2264               "</tool_call>")
2265            .enable_thinking(false)
2266            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2267            .tools({
2268                edit_tool
2269        })
2270            .expect_tool_calls({
2271                { "edit", "{\"filename\": \"foo.c\", \"oldString\": \"#iclunde\", \"newString\": \"#include\\n\"}", {} },
2272            })
2273            .run();
2274
2275
2276        // test code that starts with indent
2277        tst.test(
2278               "<tool_call>\n"
2279               "<function=python>\n"
2280               "<parameter=code>\n"
2281               "    print(\"Hello, world!\")\n"
2282               "</parameter>\n"
2283               "</function>\n"
2284               "</tool_call>")
2285            .enable_thinking(false)
2286            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2287            .tools({
2288                python_tool
2289        })
2290            .expect_tool_calls({
2291                { "python", "{\"code\": \"    print(\\\"Hello, world!\\\")\"}", {} },
2292            })
2293            .run();
2294
2295        tst.test(
2296               "I need to output the invoice details in JSON\n"
2297               "</think>\n\n"
2298               R"({"amount": 123.45, "date": "2025-12-03"})")
2299            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2300            .enable_thinking(true)
2301            .json_schema(invoice_schema)
2302            .expect_reasoning("I need to output the invoice details in JSON")
2303            .expect_content(R"({"amount": 123.45, "date": "2025-12-03"})")
2304            .run();
2305
2306        // a tool call ends the prefilled thinking block, with or without a closing </think>
2307        tst.test(
2308               "<tool_call>\n"
2309               "<function=run_in_terminal>\n"
2310               "<parameter=command>\n"
2311               "pwd\n"
2312               "</parameter>\n"
2313               "</function>\n"
2314               "</tool_call>")
2315            .enable_thinking(true)
2316            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2317            .tools({ run_in_terminal_tool })
2318            .expect_tool_calls({
2319                { "run_in_terminal", R"({"command": "pwd"})", {} },
2320            })
2321            .run();
2322
2323        // ...including after the model has thought about it
2324        tst.test(
2325               "Need to inspect the current directory.\n"
2326               "<tool_call>\n"
2327               "<function=run_in_terminal>\n"
2328               "<parameter=command>\n"
2329               "pwd\n"
2330               "</parameter>\n"
2331               "</function>\n"
2332               "</tool_call>")
2333            .enable_thinking(true)
2334            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2335            .tools({ run_in_terminal_tool })
2336            .expect_reasoning("Need to inspect the current directory.")
2337            .expect_tool_calls({
2338                { "run_in_terminal", R"({"command": "pwd"})", {} },
2339            })
2340            .run();
2341
2342        // No args tool
2343        tst.test(
2344               "<tool_call>\n"
2345               "<function=empty_args>\n"
2346               "</function>\n"
2347               "</tool_call>")
2348            .enable_thinking(false)
2349            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2350            .tools({ empty_args_tool })
2351            .expect(message_with_tool_calls("empty_args", "{}"))
2352            .run();
2353
2354        // No args tool with no properties defined
2355        tst.test(
2356               "<tool_call>\n"
2357               "<function=empty_args_no_props>\n"
2358               "</function>\n"
2359               "</tool_call>")
2360            .enable_thinking(false)
2361            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2362            .tools({ empty_args_tool_no_properties })
2363            .expect(message_with_tool_calls("empty_args_no_props", "{}"))
2364            .run();
2365
2366        // Edge cases when reasoning traces are not sent
2367        tst.test(
2368               "<think>\n\n</think>\n\n"
2369               "<tool_call>\n"
2370               "<function=special_function>\n"
2371               "<parameter=arg1>\n1\n</parameter>\n"
2372               "</function>\n"
2373               "</tool_call>")
2374            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2375            .tools({
2376                special_function_tool
2377        })
2378            .expect_reasoning("<think>\n\n")
2379            .expect_tool_calls({ { "special_function", "{\"arg1\": 1}", "" } })
2380            .run();
2381
2382        tst.test(
2383               "</think>\n\n"
2384               "<tool_call>\n"
2385               "<function=special_function>\n"
2386               "<parameter=arg1>\n1\n</parameter>\n"
2387               "</function>\n"
2388               "</tool_call>")
2389            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2390            .tools({
2391                special_function_tool
2392        })
2393            .expect_reasoning("")
2394            .expect_tool_calls({ { "special_function", "{\"arg1\": 1}", "" } })
2395            .run();
2396
2397        tst.test(
2398               "</think>\n\n"
2399               "<tool_call>\n"
2400               "<function=run_in_terminal>\n"
2401               "<parameter=command>\n"
2402               "pwd\n"
2403               "</parameter>\n"
2404               "</function>\n"
2405               "</tool_call>")
2406            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2407            .enable_thinking(true)
2408            .tools({
2409                run_in_terminal_tool
2410        })
2411            .expect_tool_calls({
2412                { "run_in_terminal", R"({"command": "pwd"})", {} },
2413            })
2414            .run();
2415
2416        tst.test(
2417               "</think>\n\n"
2418               "Let me inspect the current directory.\n"
2419               "<tool_call>\n"
2420               "<function=run_in_terminal>\n"
2421               "<parameter=command>\n"
2422               "pwd\n"
2423               "</parameter>\n"
2424               "</function>\n"
2425               "</tool_call>")
2426            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2427            .enable_thinking(true)
2428            .tools({
2429                run_in_terminal_tool
2430        })
2431            .expect_content("Let me inspect the current directory.\n")
2432            .expect_tool_calls({
2433                { "run_in_terminal", R"({"command": "pwd"})", {} },
2434            })
2435            .run();
2436
2437        tst.test(
2438               "</think>\n\n"
2439               "Let me inspect the current directory.\n"
2440               "<tool_call>\n"
2441               "<function=run_in_terminal>\n"
2442               "<parameter=command>\n"
2443               "pwd\n"
2444               "</parameter>\n"
2445               "</function>\n"
2446               "</tool_call>")
2447            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2448            .enable_thinking(true)
2449            .tools({
2450                run_in_terminal_tool
2451        })
2452            .tool_choice(COMMON_CHAT_TOOL_CHOICE_REQUIRED)
2453            .expect_content("Let me inspect the current directory.\n")
2454            .expect_tool_calls({
2455                { "run_in_terminal", R"({"command": "pwd"})", {} },
2456            })
2457            .run();
2458
2459        tst.test(
2460               "I should inspect the directory.\n"
2461               "</think>\n\n"
2462               "Let me inspect it now.\n"
2463               "<tool_call>\n"
2464               "<function=run_in_terminal>\n"
2465               "<parameter=command>\n"
2466               "pwd\n"
2467               "</parameter>\n"
2468               "</function>\n"
2469               "</tool_call>")
2470            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2471            .enable_thinking(true)
2472            .tools({
2473                run_in_terminal_tool
2474        })
2475            .expect_reasoning("I should inspect the directory.")
2476            .expect_content("Let me inspect it now.\n")
2477            .expect_tool_calls({
2478                { "run_in_terminal", R"({"command": "pwd"})", {} },
2479            })
2480            .run();
2481
2482        // Continuation tests
2483        tst.test("world!\nWhat's up?")
2484            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2485            .enable_thinking(true)
2486            .messages({ message_user, message_assist_prefill_content })
2487            .add_generation_prompt(false)
2488            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
2489            .expect_reasoning("I'm thinking")
2490            .expect_content("Hello, world!\nWhat's up?")
2491            .run();
2492
2493        tst.test(" thinking\n</think>\n\nHello, world!\nWhat's up?")
2494            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2495            .enable_thinking(true)
2496            .messages({ message_user, message_assist_prefill_reasoning })
2497            .add_generation_prompt(false)
2498            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
2499            .expect_reasoning("I'm thinking")
2500            .expect_content("Hello, world!\nWhat's up?")
2501            .run();
2502
2503        {
2504            common_chat_msg user_start;
2505            user_start.role    = "user";
2506            user_start.content = "Create a todo list, then inspect the repository.";
2507
2508            common_chat_msg assistant_todos =
2509                simple_assist_msg("", "", "manage_todo_list",
2510                                  R"({"todos":[{"item":"Inspect repository","selected":false}]})", "call_todos");
2511
2512            common_chat_msg tool_result;
2513            tool_result.role         = "tool";
2514            tool_result.content      = "Successfully wrote todo list";
2515            tool_result.tool_call_id = "call_todos";
2516
2517            common_chat_msg user_continue;
2518            user_continue.role    = "user";
2519            user_continue.content = "Proceed.";
2520
2521            tst.test(
2522                   "I need to run a terminal command.\n"
2523                   "</think>\n\n"
2524                   "<tool_call>\n"
2525                   "<function=run_in_terminal>\n"
2526                   "<parameter=command>\n"
2527                   "pwd\n"
2528                   "</parameter>\n"
2529                   "</function>\n"
2530                   "</tool_call>")
2531                .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2532                .enable_thinking(true)
2533                .tools({
2534                    manage_todo_list_tool, run_in_terminal_tool
2535            })
2536                .messages({ user_start, assistant_todos, tool_result, user_continue })
2537                .expect_reasoning("I need to run a terminal command.")
2538                .expect_tool_calls({
2539                    { "run_in_terminal", R"({"command": "pwd"})", {} },
2540                })
2541                .run();
2542
2543            tst.test(
2544                   "I need to run a terminal command.\n"
2545                   "</think>\n\n"
2546                   "Let me inspect the current directory.\n"
2547                   "<tool_call>\n"
2548                   "<function=run_in_terminal>\n"
2549                   "<parameter=command>\n"
2550                   "pwd\n"
2551                   "</parameter>\n"
2552                   "</function>\n"
2553                   "</tool_call>")
2554                .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2555                .enable_thinking(true)
2556                .tools({
2557                    manage_todo_list_tool, run_in_terminal_tool
2558            })
2559                .tool_choice(COMMON_CHAT_TOOL_CHOICE_REQUIRED)
2560                .messages({ user_start, assistant_todos, tool_result, user_continue })
2561                .expect_reasoning("I need to run a terminal command.")
2562                .expect_content("Let me inspect the current directory.\n")
2563                .expect_tool_calls({
2564                    { "run_in_terminal", R"({"command": "pwd"})", {} },
2565                })
2566                .run();
2567
2568            tst.test(
2569                   "</think>\n\n"
2570                   "<tool_call>\n"
2571                   "<function=run_in_terminal>\n"
2572                   "<parameter=command>\n"
2573                   "pwd\n"
2574                   "</parameter>\n"
2575                   "</function>\n"
2576                   "</tool_call>")
2577                .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2578                .enable_thinking(true)
2579                .tools({
2580                    manage_todo_list_tool, run_in_terminal_tool
2581            })
2582                .messages({ user_start, assistant_todos, tool_result, user_continue })
2583                .expect_tool_calls({
2584                    { "run_in_terminal", R"({"command": "pwd"})", {} },
2585                })
2586                .run();
2587        }
2588    }
2589
2590    {
2591        // Ministral-3-14B-Reasoning-2512
2592        auto tst = peg_tester("models/templates/mistralai-Ministral-3-14B-Reasoning-2512.jinja", detailed_debug);
2593
2594        tst.test("Hello, world!\nWhat's up?").expect(message_assist).expect_reconstruction().run();
2595
2596        tst.test("[THINK]I'm\nthinking[/THINK]Hello, world!\nWhat's up?")
2597            .expect_content("[THINK]I'm\nthinking[/THINK]Hello, world!\nWhat's up?")
2598            .expect_reconstruction()
2599            .run();
2600
2601        tst.test("[THINK]I'm\nthinking[/THINK]Hello, world!\nWhat's up?")
2602            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2603            .enable_thinking(true)
2604            .expect(message_assist_thoughts)
2605            .expect_reconstruction()
2606            .run();
2607
2608        tst.test(R"([TOOL_CALLS]special_function[ARGS]{"arg1":1})")
2609            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2610            .enable_thinking(true)
2611            .tools({ special_function_tool })
2612            .expect(message_assist_call)
2613            .run();
2614
2615        tst.test(
2616               "[THINK]I'm\nthinking[/THINK]"
2617               R"([TOOL_CALLS]special_function[ARGS]{"arg1":1})")
2618            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2619            .enable_thinking(true)
2620            .tools({ special_function_tool })
2621            .expect(message_assist_call_thoughts)
2622            .run();
2623
2624        tst.test(R"([TOOL_CALLS]special_function[ARGS]{"arg1": 1})"
2625                 R"([TOOL_CALLS]special_function_with_opt[ARGS]{"arg1": 1, "arg2": 2})")
2626            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2627            .parallel_tool_calls(true)
2628            .tools({
2629                special_function_tool, special_function_tool_with_optional_param
2630        })
2631            .expect_tool_calls({
2632                { "special_function", R"({"arg1": 1})", {} },
2633                { "special_function_with_opt", R"({"arg1": 1, "arg2": 2})", {} },
2634            })
2635            .expect_reconstruction()
2636            .run();
2637
2638        tst.test(
2639               "[THINK]I need to output the invoice details in JSON[/THINK]"
2640               "```json\n"
2641               R"({"amount": 123.45, "date": "2025-12-03"})"
2642               "\n```")
2643            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2644            .json_schema(invoice_schema)
2645            .expect_reasoning("I need to output the invoice details in JSON")
2646            .expect_content(R"({"amount": 123.45, "date": "2025-12-03"})")
2647            .run();
2648
2649        // fake tool call marker in reasoning
2650        tst.test(
2651               "[THINK]Let me think about [TOOL_CALLS]special_function[ARGS]{\"arg1\":1} and more[/THINK]"
2652               R"([TOOL_CALLS]special_function[ARGS]{"arg1": 1})")
2653            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2654            .enable_thinking(true)
2655            .tools({ special_function_tool })
2656            .expect_reasoning("Let me think about [TOOL_CALLS]special_function[ARGS]{\"arg1\":1} and more")
2657            .expect_tool_calls({
2658                { "special_function", R"({"arg1": 1})", {} },
2659            })
2660            .expect_reconstruction()
2661            .run();
2662
2663        // Continuation tests
2664        tst.test("world!\nWhat's up?")
2665            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2666            .enable_thinking(true)
2667            .messages({ message_user, message_assist_prefill_content })
2668            .add_generation_prompt(false)
2669            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
2670            .expect_reasoning("I'm thinking")
2671            .expect_content("Hello, world!\nWhat's up?")
2672            .run();
2673
2674        tst.test(" thinking[/THINK]Hello, world!\nWhat's up?")
2675            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2676            .enable_thinking(true)
2677            .messages({ message_user, message_assist_prefill_reasoning })
2678            .add_generation_prompt(false)
2679            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
2680            .expect_reasoning("I'm thinking")
2681            .expect_content("Hello, world!\nWhat's up?")
2682            .run();
2683    }
2684
2685    {
2686        // NVIDIA Nemotron-3 Nano
2687        auto tst = peg_tester("models/templates/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16.jinja", detailed_debug);
2688
2689        tst.test("Hello, world!\nWhat's up?").
2690            enable_thinking(false).
2691            reasoning_format(COMMON_REASONING_FORMAT_AUTO).
2692            expect(message_assist).run();
2693
2694        tst.test("I'm\nthinking\n</think>\nHello, world!\nWhat's up?")
2695            .enable_thinking(true)
2696            .reasoning_format(COMMON_REASONING_FORMAT_NONE)
2697            .expect_content("<think>\nI'm\nthinking\n</think>\nHello, world!\nWhat's up?")
2698            .run();
2699
2700        tst.test("I'm\nthinking\n</think>\nHello, world!\nWhat's up?")
2701            .enable_thinking(true)
2702            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2703            .expect(message_assist_thoughts)
2704            .run();
2705
2706        tst.test(
2707               "<tool_call>\n"
2708               "<function=special_function>\n"
2709               "<parameter=arg1>\n1\n</parameter>\n"
2710               "</function>\n"
2711               "</tool_call>\n")
2712            .enable_thinking(false)
2713            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2714            .tools({ special_function_tool })
2715            .expect(message_assist_call)
2716            .run();
2717
2718        tst.test(
2719               "I'm\nthinking\n</think>\n"
2720               "<tool_call>\n"
2721               "<function=special_function>\n"
2722               "<parameter=arg1>\n1\n</parameter>\n"
2723               "</function>\n"
2724               "</tool_call>\n")
2725            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2726            .tools({ special_function_tool })
2727            .expect(message_assist_call_thoughts)
2728            .run();
2729
2730        tst.test(
2731               "<tool_call>\n"
2732               "<function=special_function>\n"
2733               "<parameter=arg1>\n1\n</parameter>\n"
2734               "</function>\n"
2735               "</tool_call>\n"
2736               "<tool_call>\n"
2737               "<function=special_function_with_opt>\n"
2738               "<parameter=arg1>\n1\n</parameter>\n"
2739               "<parameter=arg2>\n2\n</parameter>\n"
2740               "</function>\n"
2741               "</tool_call>\n")
2742            .enable_thinking(false)
2743            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2744            .parallel_tool_calls(true)
2745            .tools({
2746                special_function_tool, special_function_tool_with_optional_param
2747        })
2748            .expect_tool_calls({
2749                { "special_function", R"({"arg1": 1})", {} },
2750                { "special_function_with_opt", R"({"arg1": 1, "arg2": 2})", {} },
2751            })
2752            .run();
2753
2754        tst.test(
2755               "<tool_call>\n"
2756               "<function=python>\n"
2757               "<parameter=code>\n"
2758               "def hello():\n"
2759               "    print(\"Hello, world!\")\n"
2760               "\n"
2761               "hello()\n"
2762               "</parameter>\n"
2763               "</function>\n"
2764               "</tool_call>\n")
2765            .enable_thinking(false)
2766            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2767            .tools({
2768                python_tool
2769        })
2770            .expect_tool_calls({
2771                { "python", "{\"code\": \"def hello():\\n    print(\\\"Hello, world!\\\")\\n\\nhello()\"}", {} },
2772            })
2773            .run();
2774
2775        tst.test(
2776               "I need to output the invoice details in JSON\n"
2777               "</think>\n"
2778               R"({"amount": 123.45, "date": "2025-12-03"})")
2779            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2780            .enable_thinking(true)
2781            .json_schema(invoice_schema)
2782            .expect_reasoning("I need to output the invoice details in JSON")
2783            .expect_content(R"({"amount": 123.45, "date": "2025-12-03"})")
2784            .run();
2785
2786        // Continuation tests
2787        tst.test("world!\nWhat's up?")
2788            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2789            .enable_thinking(true)
2790            .messages({ message_user, message_assist_prefill_content })
2791            .add_generation_prompt(false)
2792            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
2793            .expect_reasoning("I'm thinking")
2794            .expect_content("Hello, world!\nWhat's up?")
2795            .run();
2796
2797        tst.test(" thinking\n</think>\nHello, world!\nWhat's up?")
2798            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2799            .enable_thinking(true)
2800            .messages({ message_user, message_assist_prefill_reasoning })
2801            .add_generation_prompt(false)
2802            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
2803            .expect_reasoning("I'm thinking")
2804            .expect_content("Hello, world!\nWhat's up?")
2805            .run();
2806    }
2807
2808    {
2809        // CohereForAI Command-R 7B (2024-tool_use)
2810        auto tst = peg_tester("models/templates/CohereForAI-c4ai-command-r7b-12-2024-tool_use.jinja", detailed_debug);
2811
2812        tst.test("<|START_RESPONSE|>Hello, world!\nWhat's up?<|END_RESPONSE|>").expect(message_assist).run();
2813
2814        tst.test(
2815               "<|START_THINKING|>I'm\nthinking<|END_THINKING|>"
2816               "<|START_RESPONSE|>Hello, world!\nWhat's up?<|END_RESPONSE|>")
2817            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
2818            .expect(message_assist_thoughts)
2819            .run();
2820
2821        tst.test(
2822               "<|START_THINKING|>I'm\nthinking<|END_THINKING|>"
2823               "<|START_RESPONSE|>Hello, world!\nWhat's up?<|END_RESPONSE|>")
2824            .expect(message_assist_thoughts_unparsed_r7b)
2825            .run();
2826
2827        tst.test(
2828               "<|START_THINKING|>I'm\nthinking<|END_THINKING|>"
2829               "<|START_ACTION|>[\n"
2830               "    {\"tool_call_id\": \"0\", \"tool_name\": \"special_function\", \"parameters\": {\"arg1\": 1}}\n"
2831               "]<|END_ACTION|>")
2832            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
2833            .tools({ special_function_tool })
2834            .expect(message_assist_thoughts_call_idx)
2835            .run();
2836
2837        tst.test(
2838               "<|START_THINKING|>I'm\nthinking<|END_THINKING|>"
2839               "<|START_ACTION|>[\n"
2840               "    {\"tool_call_id\": \"0\", \"tool_name\": \"special_function\", ")
2841            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
2842            .tools({ special_function_tool })
2843            .is_partial(true)
2844            .expect(message_assist_thoughts_partial_call)
2845            .run();
2846
2847        tst.test(
2848               "<|START_THINKING|><|END_THINKING|>"
2849               "<|START_ACTION|>[\n"
2850               "    {\"tool_call_id\": \"0\", \"tool_name\": \"special_function\", \"parameters\": {\"arg1\": 1}}\n"
2851               "]<|END_ACTION|>")
2852            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
2853            .tools({ special_function_tool })
2854            .expect(message_assist_call_idx)
2855            .run();
2856    }
2857
2858    {
2859        // Cohere2 MoE (North Code) - dedicated parser.
2860        // Marker-wrapped format: <|START_THINKING|>...<|END_THINKING|> then either
2861        // <|START_TEXT|>...<|END_TEXT|> (content) or <|START_ACTION|>[json]<|END_ACTION|> (tools).
2862        // The generation prompt forces a leading <|START_THINKING|>, so model output begins inside
2863        // the thinking block: test inputs start with the reasoning body, not the <|START_THINKING|> tag.
2864        auto tst = peg_tester("models/templates/Cohere2MoE.jinja", detailed_debug);
2865
2866        // Content with reasoning, extracted.
2867        tst.test("I'm\nthinking<|END_THINKING|><|START_TEXT|>Hello, world!\nWhat's up?<|END_TEXT|>")
2868            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
2869            .expect(message_assist_thoughts)
2870            .run();
2871
2872        // Content with reasoning, reasoning_format=NONE -> thinking kept inline in content (markers preserved).
2873        tst.test("I'm\nthinking<|END_THINKING|><|START_TEXT|>Hello, world!\nWhat's up?<|END_TEXT|>")
2874            .expect(message_assist_thoughts_unparsed_r7b)
2875            .run();
2876
2877        // Content with empty thinking block.
2878        tst.test("<|END_THINKING|><|START_TEXT|>Hello, world!\nWhat's up?<|END_TEXT|>")
2879            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
2880            .expect(message_assist)
2881            .run();
2882
2883        // JSON output schema
2884        tst.test(
2885                "I need to output the invoice details in JSON<|END_THINKING|>"
2886                "<|START_TEXT|>{\"amount\": 123.45, \"date\": \"2025-12-03\"}<|END_TEXT|>")
2887            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
2888            .json_schema(invoice_schema)
2889            .tools({ special_function_tool })
2890            .expect_reasoning("I need to output the invoice details in JSON")
2891            .expect_content(R"({"amount": 123.45, "date": "2025-12-03"})")
2892                .run();
2893
2894        // Single tool call with reasoning.
2895        tst.test(
2896               "I'm\nthinking<|END_THINKING|>"
2897               "<|START_ACTION|>[\n"
2898               "    {\"tool_call_id\": \"0\", \"tool_name\": \"special_function\", \"parameters\": {\"arg1\": 1}}\n"
2899               "]<|END_ACTION|>")
2900            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
2901            .tools({ special_function_tool })
2902            .expect(message_assist_thoughts_call_idx)
2903            .run();
2904
2905        // Single tool call, empty thinking block (no reasoning content).
2906        tst.test(
2907               "<|END_THINKING|>"
2908               "<|START_ACTION|>[\n"
2909               "    {\"tool_call_id\": \"0\", \"tool_name\": \"special_function\", \"parameters\": {\"arg1\": 1}}\n"
2910               "]<|END_ACTION|>")
2911            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
2912            .tools({ special_function_tool })
2913            .expect(message_assist_call_idx)
2914            .run();
2915
2916        // Tool call with an array argument (todo_list).
2917        tst.test(
2918               "<|END_THINKING|>"
2919               "<|START_ACTION|>[\n"
2920               "    {\"tool_call_id\": \"0\", \"tool_name\": \"todo_list\", \"parameters\": {\"todos\": [\"buy milk\", \"walk dog\"]}}\n"
2921               "]<|END_ACTION|>")
2922            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
2923            .tools({ todo_list })
2924            .expect(simple_assist_msg("", "", "todo_list", "{\"todos\": [\"buy milk\", \"walk dog\"]}", "0"))
2925            .run();
2926
2927        // Parallel tool calls with reasoning.
2928        tst.test(
2929               "I'm\nthinking<|END_THINKING|>"
2930               "<|START_ACTION|>[\n"
2931               "    {\"tool_call_id\": \"0\", \"tool_name\": \"special_function\", \"parameters\": {\"arg1\": 1}},\n"
2932               "    {\"tool_call_id\": \"1\", \"tool_name\": \"python\", \"parameters\": {\"code\": \"print('hey')\"}}\n"
2933               "]<|END_ACTION|>")
2934            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
2935            .parallel_tool_calls(true)
2936            .tools({ special_function_tool, python_tool })
2937            .expect_reasoning("I'm\nthinking")
2938            .expect_tool_calls({
2939                { "special_function", R"({"arg1": 1})", "0" },
2940                { "python", "{\"code\": \"print('hey')\"}", "1" },
2941            })
2942            .run();
2943
2944        // Tools available but the model answers with content instead of calling a tool.
2945        tst.test("I'm\nthinking<|END_THINKING|><|START_TEXT|>Hello, world!\nWhat's up?<|END_TEXT|>")
2946            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
2947            .tools({ special_function_tool })
2948            .expect(message_assist_thoughts)
2949            .run();
2950
2951        // Partial tool call (streaming): name/id resolved before arguments arrive.
2952        tst.test(
2953               "I'm\nthinking<|END_THINKING|>"
2954               "<|START_ACTION|>[\n"
2955               "    {\"tool_call_id\": \"0\", \"tool_name\": \"special_function\", ")
2956            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
2957            .tools({ special_function_tool })
2958            .is_partial(true)
2959            .expect(message_assist_thoughts_partial_call)
2960            .run();
2961    }
2962
2963    {
2964        // Google Gemma 2 2B - does not support tool calling
2965        auto tst = peg_tester("models/templates/google-gemma-2-2b-it.jinja");
2966
2967        tst.test("Hello, world!").expect(simple_assist_msg("Hello, world!")).expect_reconstruction().run();
2968
2969        tst.test("Line 1\nLine 2\nLine 3").expect(simple_assist_msg("Line 1\nLine 2\nLine 3")).expect_reconstruction().run();
2970
2971        // Continuation tests
2972        tst.test("world!\nWhat's up?")
2973            .messages({ message_user, message_assist_prefill_content })
2974            .add_generation_prompt(false)
2975            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
2976            .expect_content("Hello, world!\nWhat's up?")
2977            .run();
2978    }
2979
2980    {
2981        // Google Gemma 4 (tool calling with Gemma4 dict format)
2982        auto tst = peg_tester("models/templates/google-gemma-4-31B-it.jinja");
2983
2984        tst.test("Hello, world!").expect(simple_assist_msg("Hello, world!")).run();
2985
2986        // Reasoning and content
2987        tst.test(
2988                "<|channel>thought\nI'm\nthinking<channel|>Hello, world!\nWhat's up?")
2989            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2990            .expect(message_assist_thoughts)
2991            .run();
2992
2993        // Empty reasoning (budget=0: sampler forces end tag before newline)
2994        tst.test(
2995                "<|channel>thought<channel|>Hello, world!\nWhat's up?")
2996            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2997            .expect(simple_assist_msg("Hello, world!\nWhat's up?", ""))
2998            .run();
2999
3000        // Reasoning and content with reasoning_format = none
3001        tst.test(
3002                "<|channel>thought\nI'm\nthinking<channel|>Hello, world!\nWhat's up?")
3003            .reasoning_format(COMMON_REASONING_FORMAT_NONE)
3004            .expect_content("<|channel>thought\nI'm\nthinking<channel|>Hello, world!\nWhat's up?")
3005            .run();
3006
3007        // Simple tool call with string argument
3008        tst.test(
3009                "<|tool_call>call:get_time{city:<|\"|>London<|\"|>}<tool_call|>")
3010            .tools({ get_time_tool })
3011            .expect(message_with_tool_calls("get_time", R"({"city": "London"})"))
3012            .run();
3013
3014        // Tool call with string argument containing special chars
3015        tst.test(
3016                "<|tool_call>call:get_time{city:<|\"|>San Francisco<|\"|>}<tool_call|>")
3017            .tools({ get_time_tool })
3018            .expect(message_with_tool_calls("get_time", R"({"city": "San Francisco"})"))
3019            .run();
3020
3021        // Tool call with empty args
3022        tst.test(
3023                "<|tool_call>call:empty_args{}<tool_call|>")
3024            .tools({ empty_args_tool })
3025            .expect(message_with_tool_calls("empty_args", "{}"))
3026            .run();
3027
3028        // Tool call with string and content
3029        tst.test(
3030                "Hello, world!\nWhat's up?<|tool_call>call:get_time{city:<|\"|>Paris<|\"|>}<tool_call|>")
3031            .tools({ get_time_tool })
3032            .expect(message_with_content_and_tool_call("Hello, world!\nWhat's up?", "get_time", R"({"city": "Paris"})"))
3033            .run();
3034
3035        // Parallel tool calls
3036        tst.test(
3037                "<|tool_call>call:get_time{city:<|\"|>London<|\"|>}<tool_call|>"
3038                "<|tool_call>call:get_weather{city:<|\"|>Paris<|\"|>}<tool_call|>")
3039            .tools({ get_time_tool, get_weather_tool })
3040            .parallel_tool_calls(true)
3041            .expect_tool_calls({
3042                { "get_time", R"({"city": "London"})", "" },
3043                { "get_weather", R"({"city": "Paris"})", "" },
3044            })
3045            .run();
3046
3047        // Tool call with integer argument (number type)
3048        tst.test(
3049                "<|tool_call>call:special_function{arg1:42}<tool_call|>")
3050            .tools({ special_function_tool })
3051            .expect(message_with_tool_calls("special_function", R"({"arg1": 42})"))
3052            .run();
3053
3054        // Tool call with negative number argument
3055        tst.test(
3056                "<|tool_call>call:special_function{arg1:-7}<tool_call|>")
3057            .tools({ special_function_tool })
3058            .expect(message_with_tool_calls("special_function", R"({"arg1": -7})"))
3059            .run();
3060
3061        // Tool call with decimal number argument
3062        tst.test(
3063                "<|tool_call>call:amount{orig:3.14}<tool_call|>")
3064            .tools({ amount_tool })
3065            .expect(message_with_tool_calls("amount", R"({"orig": 3.14})"))
3066            .run();
3067
3068        // Tool call with boolean argument (true)
3069        tst.test(
3070                "<|tool_call>call:toggle{enabled:true}<tool_call|>")
3071            .tools({ toggle_tool })
3072            .expect(message_with_tool_calls("toggle", R"({"enabled": true})"))
3073            .run();
3074
3075        // Tool call with boolean argument (false)
3076        tst.test(
3077                "<|tool_call>call:toggle{enabled:false}<tool_call|>")
3078            .tools({ toggle_tool })
3079            .expect(message_with_tool_calls("toggle", R"({"enabled": false})"))
3080            .run();
3081
3082        // Tool call with null argument
3083        tst.test(
3084                "<|tool_call>call:set_nullable{value:null}<tool_call|>")
3085            .tools({ nullable_tool })
3086            .expect(message_with_tool_calls("set_nullable", R"({"value": null})"))
3087            .run();
3088
3089        // Tool call with array argument (todo list)
3090        tst.test(
3091                "<|tool_call>call:todo_list{todos:[<|\"|>buy milk<|\"|>,<|\"|>walk dog<|\"|>]}<tool_call|>")
3092            .tools({ todo_list })
3093            .expect(message_with_tool_calls("todo_list", R"({"todos":["buy milk","walk dog"]})"))
3094            .run();
3095
3096        // Tool call with object/dict argument
3097        tst.test(
3098                "<|tool_call>call:set_config{config:{theme:<|\"|>dark<|\"|>,count:3}}<tool_call|>")
3099            .tools({ config_tool })
3100            .expect(message_with_tool_calls("set_config", R"({"config":{"theme":"dark","count":3}})"))
3101            .run();
3102
3103        // Tool call with empty array
3104        tst.test(
3105                "<|tool_call>call:todo_list{todos:[]}<tool_call|>")
3106            .tools({ todo_list })
3107            .expect(message_with_tool_calls("todo_list", R"({"todos":[]})"))
3108            .run();
3109
3110        // Tool call with empty dict
3111        tst.test(
3112                "<|tool_call>call:set_config{config:{}}<tool_call|>")
3113            .tools({ config_tool })
3114            .expect(message_with_tool_calls("set_config", R"({"config":{}})"))
3115            .run();
3116
3117        // Tool call with scientific notation number
3118        tst.test(
3119                "<|tool_call>call:amount{orig:1.5e10}<tool_call|>")
3120            .tools({ amount_tool })
3121            .expect(message_with_tool_calls("amount", R"({"orig": 1.5e10})"))
3122            .run();
3123
3124        // Edge cases
3125        tst.test(
3126                "<|channel>thought\n<channel|>Hello, world!\nWhat's up?<channel|>")
3127            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
3128            .expect(message_assist)
3129            .run();
3130
3131        tst.test(
3132                "<|channel>thought\n<channel|>Hello, world!\nWhat's up?<|channel>thought\n<channel|>")
3133            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
3134            .expect(message_assist)
3135            .run();
3136
3137        tst.test(
3138                "<|channel>thought\n<channel|>Hello, world!\nWhat's up?<|channel>thought\n<channel|><channel|>")
3139            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
3140            .expect(message_assist)
3141            .run();
3142
3143        tst.test(
3144                "<|channel><|channel>thought\n<channel|>Hello, world!\nWhat's up?")
3145            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
3146            .expect(message_assist)
3147            .run();
3148
3149        // Continuation tests
3150        tst.test("world!\nWhat's up?")
3151            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
3152            .enable_thinking(true)
3153            .messages({ message_user, message_assist_prefill_content })
3154            .add_generation_prompt(false)
3155            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
3156            .expect_reasoning("I'm thinking")
3157            .expect_content("Hello, world!\nWhat's up?")
3158            .run();
3159
3160        tst.test(" thinking<channel|>Hello, world!\nWhat's up?")
3161            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
3162            .enable_thinking(true)
3163            .messages({ message_user, message_assist_prefill_reasoning })
3164            .add_generation_prompt(false)
3165            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
3166            .expect_reasoning("I'm thinking")
3167            .expect_content("Hello, world!\nWhat's up?")
3168            .run();
3169
3170        {
3171            // additional tests for https://github.com/ggml-org/llama.cpp/pull/21760
3172            auto tmpls = read_templates("models/templates/google-gemma-4-31B-it.jinja");
3173
3174            common_chat_msg tool_call_msg = simple_assist_msg(
3175                "Let me check.", "", "special_function", "{\"arg1\": 1}","c0");
3176
3177            common_chat_msg tool_msg;
3178            tool_msg.role         = "tool";
3179            tool_msg.tool_name    = "special_function";
3180            tool_msg.tool_call_id = "c0";
3181            tool_msg.content      = "{\"r\":\"ok\"}";
3182
3183            {
3184                common_chat_templates_inputs inputs;
3185                inputs.messages              = { message_user, tool_call_msg, tool_msg };
3186                inputs.tools                 = { special_function_tool };
3187                inputs.add_generation_prompt = true;
3188
3189                auto params = common_chat_templates_apply(tmpls.get(), inputs);
3190
3191                if (!string_ends_with(params.prompt, "<turn|>\n<|turn>model\n")) {
3192                    throw std::runtime_error("Missing generation prompt for Gemma 4");
3193                }
3194            }
3195
3196            {
3197                common_chat_templates_inputs inputs;
3198                inputs.messages              = { message_user, tool_call_msg, tool_msg };
3199                inputs.tools                 = { special_function_tool };
3200                inputs.add_generation_prompt = false;
3201
3202                auto params = common_chat_templates_apply(tmpls.get(), inputs);
3203
3204                if (string_ends_with(params.prompt, "<|turn>model\n")) {
3205                    throw std::runtime_error("Gemma 4: generation prompt was modified despite add_generation_prompt=false");
3206                }
3207            }
3208        }
3209
3210        {
3211            // StepFun trimming regression test (see https://github.com/ggml-org/llama.cpp/pull/25238)
3212            auto tmpls = read_templates("models/templates/StepFun3.5-Flash.jinja");
3213
3214            common_chat_msg message_chatbot = simple_assist_msg("Let me check.\n\n", "I am thinking.\n\n");
3215
3216            {
3217                common_chat_templates_inputs inputs;
3218                inputs.messages              = { message_chatbot };
3219                inputs.add_generation_prompt = true;
3220
3221                auto params = common_chat_templates_apply(tmpls.get(), inputs);
3222
3223                if (params.prompt.find("Let me check.\n\n") != std::string::npos) {
3224                    throw std::runtime_error("StepFun 3.5: content not trimmed");
3225                }
3226
3227                if (params.prompt.find("I am thinking.\n\n") != std::string::npos) {
3228                    throw std::runtime_error("StepFun 3.5: reasoning_content not trimmed");
3229                }
3230            }
3231
3232            {
3233                // Trimming must also reach typed (text) content parts, not just string content
3234                // (see https://github.com/ggml-org/llama.cpp/pull/25238)
3235                common_chat_msg message_parts;
3236                message_parts.role          = "user";
3237                message_parts.content_parts = {
3238                    { /* .type = */ "text", /* .text = */ "First part.\n\n" },
3239                    { /* .type = */ "media_marker", /* .text = */ "<__media__>" },
3240                    { /* .type = */ "text", /* .text = */ "Second part.\n\n" },
3241                };
3242
3243                common_chat_templates_inputs inputs;
3244                inputs.messages              = { message_parts };
3245                inputs.add_generation_prompt = true;
3246
3247                auto params = common_chat_templates_apply(tmpls.get(), inputs);
3248
3249                if (params.prompt.find("First part.\n\n") != std::string::npos ||
3250                    params.prompt.find("Second part.\n\n") != std::string::npos) {
3251                    throw std::runtime_error("StepFun 3.5: text content parts not trimmed");
3252                }
3253
3254                // the trimmed text itself must still be present
3255                if (params.prompt.find("First part.") == std::string::npos ||
3256                    params.prompt.find("Second part.") == std::string::npos) {
3257                    throw std::runtime_error("StepFun 3.5: text content parts missing after trim");
3258                }
3259            }
3260        }
3261
3262    }
3263
3264    {
3265        // Qwen-QwQ-32B (reasoning model)
3266        auto tst = peg_tester("models/templates/Qwen-QwQ-32B.jinja");
3267
3268        // QwQ always has thinking forced open - input starts after the <think>\n in the prompt
3269        tst.test("Let me think about this...\n</think>\nThe answer is 42.")
3270            .enable_thinking(true)
3271            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
3272            .expect(simple_assist_msg("The answer is 42.", "Let me think about this..."))
3273            .run();
3274
3275        tst.test("</think>Hello, world!").reasoning_format(COMMON_REASONING_FORMAT_AUTO).expect(simple_assist_msg("Hello, world!")).run();
3276
3277        // Continuation tests
3278        tst.test("world!\nWhat's up?")
3279            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
3280            .enable_thinking(true)
3281            .messages({ message_user, message_assist_prefill_content })
3282            .add_generation_prompt(false)
3283            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
3284            .expect_reasoning("I'm thinking")
3285            .expect_content("Hello, world!\nWhat's up?")
3286            .run();
3287
3288        tst.test(" thinking\n</think>\nHello, world!\nWhat's up?")
3289            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
3290            .enable_thinking(true)
3291            .messages({ message_user, message_assist_prefill_reasoning })
3292            .add_generation_prompt(false)
3293            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
3294            .expect_reasoning("I'm thinking")
3295            .expect_content("Hello, world!\nWhat's up?")
3296            .run();
3297    }
3298    {
3299        // NousResearch-Hermes-2-Pro and Hermes-3 (tool calling models)
3300        auto tst = peg_tester("models/templates/NousResearch-Hermes-2-Pro-Llama-3-8B-tool_use.jinja", detailed_debug);
3301
3302        tst.test(
3303               "<tool_call>\n"
3304               "{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}"
3305               "</tool_call>")
3306            .tools({ special_function_tool })
3307            .expect(message_assist_call)
3308            .run();
3309
3310        tst.test(
3311               "Hello, world!\nWhat's up?<tool_call>\n"
3312               "{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}"
3313               "</tool_call>")
3314            .tools({ special_function_tool })
3315            .expect(message_assist_call_content)
3316            .run();
3317
3318        // Note: Hermes template doesn't support thinking/reasoning natively
3319        // Note: We only support one tool calling format per template, no alternate formats
3320
3321        // Continuation tests
3322        tst.test("world!\nWhat's up?")
3323            .messages({ message_user, message_assist_prefill_content })
3324            .add_generation_prompt(false)
3325            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
3326            .expect_content("Hello, world!\nWhat's up?")
3327            .run();
3328    }
3329    {
3330        // Test simple content-only template
3331        auto tst = peg_tester("models/templates/google-gemma-2-2b-it.jinja", detailed_debug);
3332
3333        tst.test("Hello, world!\nWhat's up?").expect(message_assist).expect_reconstruction().run();
3334    }
3335    {
3336        // IBM Granite (reasoning and tool calling model)
3337        auto tst = peg_tester("models/templates/ibm-granite-granite-3.3-2B-Instruct.jinja", detailed_debug);
3338
3339        tst.test("Hello, world!\nWhat's up?").expect(message_assist).run();
3340
3341        tst.test("<think>I'm\nthinking</think>Hello, world!\nWhat's up?")
3342            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
3343            .expect(message_assist_thoughts)
3344            .run();
3345
3346        // TODO: pending support for WRAPPED_WITH_REASONING
3347        // tst.test("<think>I'm\nthinking</think><response>Hello, world!\nWhat's up?</response>")
3348        //     .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
3349        //     .expect(message_assist_thoughts)
3350        //     .run();
3351
3352        // Continuation tests
3353        tst.test("world!\nWhat's up?")
3354            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
3355            .enable_thinking(true)
3356            .messages({ message_user, message_assist_prefill_content })
3357            .add_generation_prompt(false)
3358            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
3359            .expect_reasoning("I'm thinking")
3360            .expect_content("Hello, world!\nWhat's up?")
3361            .run();
3362
3363        tst.test(" thinking</think>Hello, world!\nWhat's up?")
3364            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
3365            .enable_thinking(true)
3366            .messages({ message_user, message_assist_prefill_reasoning })
3367            .add_generation_prompt(false)
3368            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
3369            .expect_reasoning("I'm thinking")
3370            .expect_content("Hello, world!\nWhat's up?")
3371            .run();
3372    }
3373
3374    {
3375        // IBM Granite 4.0 (production template shared by h-tiny, h-small, micro)
3376        // Uses <tool_call> XML tags for tool calls, tools in system message
3377        auto tst = peg_tester("models/templates/ibm-granite-granite-4.0.jinja", detailed_debug);
3378
3379        tst.test("Hello, world!\nWhat's up?").expect(message_assist).run();
3380
3381        tst.test(
3382               "<tool_call>\n"
3383               "{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}\n"
3384               "</tool_call>")
3385            .tools({ special_function_tool })
3386            .expect(message_assist_call)
3387            .run();
3388
3389        // Continuation tests
3390        tst.test("world!\nWhat's up?")
3391            .messages({ message_user, message_assist_prefill_content })
3392            .add_generation_prompt(false)
3393            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
3394            .expect_content("Hello, world!\nWhat's up?")
3395            .run();
3396    }
3397
3398    {
3399        // IBM Granite 4.1 (same format as 4.0)
3400        auto tst = peg_tester("models/templates/ibm-granite-granite-4.1.jinja", detailed_debug);
3401
3402        tst.test("Hello, world!\nWhat's up?").expect(message_assist).run();
3403
3404        tst.test(
3405               "<tool_call>\n"
3406               "{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}\n"
3407               "</tool_call>")
3408            .tools({ special_function_tool })
3409            .expect(message_assist_call)
3410            .run();
3411    }
3412
3413    {
3414        // ByteDance-Seed-OSS (reasoning and tool calling model)
3415        auto tst = peg_tester("models/templates/ByteDance-Seed-OSS.jinja", detailed_debug);
3416
3417        tst.test("Hello, world!\nWhat's up?").expect(message_assist).run();
3418
3419        tst.test("<seed:think>I'm thinking about the answer</seed:think>\nHello, world!")
3420            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
3421            .expect(simple_assist_msg("Hello, world!", "I'm thinking about the answer"))
3422            .run();
3423
3424        tst.test(
3425               "<seed:tool_call>\n"
3426               "<function=special_function>\n"
3427               "<parameter=arg1>1</parameter>\n"
3428               "</function>\n"
3429               "</seed:tool_call>")
3430            .tools({ special_function_tool })
3431            .expect(message_assist_call)
3432            .run();
3433
3434        tst.test(
3435               "<seed:tool_call>\n"
3436               "<function=special_function>\n"
3437               "<parameter=arg1>1</parameter>\n"
3438               "</function>\n"
3439               "</seed:tool_call>\n"
3440               "<seed:tool_call>\n"
3441               "<function=special_function_with_opt>\n"
3442               "<parameter=arg1>1</parameter>\n"
3443               "<parameter=arg2>2</parameter>\n"
3444               "</function>\n"
3445               "</seed:tool_call>")
3446            .parallel_tool_calls(true)
3447            .tools({
3448                special_function_tool, special_function_tool_with_optional_param
3449        })
3450            .expect_tool_calls({
3451                { "special_function", R"({"arg1": 1})", {} },
3452                { "special_function_with_opt", R"({"arg1": 1, "arg2": 2})", {} },
3453            })
3454            .run();
3455
3456        tst.test(
3457               "<seed:tool_call>\n"
3458               "<function=todo_list>\n"
3459               "<parameter=todos>[{\"item\": \"Check stuff\", \"selected\": false}, {\"item\": \"Prepare stuff\", \"selected\": true}]</parameter>\n"
3460               "</function>\n"
3461               "</seed:tool_call>")
3462            .tools({
3463                todo_list
3464        })
3465            .expect_tool_calls({
3466                { "todo_list", "{\"todos\": [{\"item\": \"Check stuff\", \"selected\": false}, {\"item\": \"Prepare stuff\", \"selected\": true}]}", {} },
3467            })
3468            .run();
3469
3470        // tool call with inside quotes
3471        tst.test(
3472               "<seed:tool_call>\n"
3473               "<function=edit>\n"
3474               "<parameter=filename>"
3475               "foo.cpp"
3476               "</parameter>\n"
3477               "<parameter=oldString>"
3478               "def foo(arg = \"14\"):\n"
3479               "    return arg + \"bar\"\n"
3480               "</parameter>\n"
3481               "<parameter=newString>"
3482               "def foo(arg = \"15\"):\n"
3483               "    pass\n"
3484               "</parameter>\n"
3485               "</function>\n"
3486               "</seed:tool_call>")
3487            .tools({
3488                edit_tool
3489        })
3490            .expect_tool_calls({
3491                { "edit", "{\"filename\": \"foo.cpp\", "
3492                    "\"oldString\": \"def foo(arg = \\\"14\\\"):\\n    return arg + \\\"bar\\\"\\n\", "
3493                    "\"newString\": \"def foo(arg = \\\"15\\\"):\\n    pass\\n\"}", {}
3494                }
3495            })
3496            .run();
3497
3498        // Continuation tests
3499        tst.test("world!\nWhat's up?")
3500            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
3501            .enable_thinking(true)
3502            .messages({ message_user, message_assist_prefill_content })
3503            .add_generation_prompt(false)
3504            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
3505            .expect_reasoning("I'm thinking")
3506            .expect_content("Hello, world!\nWhat's up?")
3507            .run();
3508
3509        tst.test(" thinking</seed:think>\nHello, world!\nWhat's up?")
3510            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
3511            .enable_thinking(true)
3512            .messages({ message_user, message_assist_prefill_reasoning })
3513            .add_generation_prompt(false)
3514            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
3515            .expect_reasoning("I'm thinking")
3516            .expect_content("Hello, world!\nWhat's up?")
3517            .run();
3518    }
3519
3520    {
3521        // Qwen3-Coder (tool calling with XML-style format)
3522        auto tst = peg_tester("models/templates/Qwen3-Coder.jinja", detailed_debug);
3523
3524        tst.test("Hello, world!\nWhat's up?").expect(message_assist).expect_reconstruction().run();
3525
3526        tst.test(
3527               "<tool_call>\n"
3528               "<function=special_function>\n"
3529               "<parameter=arg1>\n"
3530               "1\n"
3531               "</parameter>\n"
3532               "</function>\n"
3533               "</tool_call>")
3534            .tools({ special_function_tool })
3535            .expect(message_assist_call)
3536            .expect_reconstruction()
3537            .run();
3538
3539        // Some models skip the opening <tool_call> and go straight to <function=>
3540        tst.test(
3541               "<function=special_function>\n"
3542               "<parameter=arg1>\n"
3543               "1\n"
3544               "</parameter>\n"
3545               "</function>\n"
3546               "</tool_call>")
3547            .tools({ special_function_tool })
3548            .expect(message_assist_call)
3549            .run();
3550
3551        tst.test(
3552               "Let me call it.\n"
3553               "<function=special_function>\n"
3554               "<parameter=arg1>\n"
3555               "1\n"
3556               "</parameter>\n"
3557               "</function>\n"
3558               "</tool_call>")
3559            .tools({ special_function_tool })
3560            .expect_content("Let me call it.\n")
3561            .expect_tool_calls({
3562                { "special_function", R"({"arg1": 1})", {} },
3563            })
3564            .run();
3565
3566        // Only the first call may omit it, the rest keep the </tool_call>\n<tool_call> separator
3567        tst.test(
3568               "<function=special_function>\n"
3569               "<parameter=arg1>\n"
3570               "1\n"
3571               "</parameter>\n"
3572               "</function>\n"
3573               "</tool_call>\n"
3574               "<tool_call>\n"
3575               "<function=special_function_with_opt>\n"
3576               "<parameter=arg1>\n"
3577               "1\n"
3578               "</parameter>\n"
3579               "<parameter=arg2>\n"
3580               "2\n"
3581               "</parameter>\n"
3582               "</function>\n"
3583               "</tool_call>")
3584            .parallel_tool_calls(true)
3585            .tools({
3586                special_function_tool, special_function_tool_with_optional_param
3587        })
3588            .expect_tool_calls({
3589                { "special_function", R"({"arg1": 1})", {} },
3590                { "special_function_with_opt", R"({"arg1": 1, "arg2": 2})", {} },
3591            })
3592            .run();
3593
3594        tst.test(
3595               "<tool_call>\n"
3596               "<function=special_function>\n"
3597               "<parameter=arg1>\n"
3598               "1\n"
3599               "</parameter>\n"
3600               "</function>\n"
3601               "</tool_call>\n"
3602               "<tool_call>\n"
3603               "<function=special_function_with_opt>\n"
3604               "<parameter=arg1>\n"
3605               "1\n"
3606               "</parameter>\n"
3607               "<parameter=arg2>\n"
3608               "2\n"
3609               "</parameter>\n"
3610               "</function>\n"
3611               "</tool_call>")
3612            .parallel_tool_calls(true)
3613            .tools({
3614                special_function_tool, special_function_tool_with_optional_param
3615        })
3616            .expect_tool_calls({
3617                { "special_function", R"({"arg1": 1})", {} },
3618                { "special_function_with_opt", R"({"arg1": 1, "arg2": 2})", {} },
3619            })
3620            .expect_reconstruction()
3621            .run();
3622
3623        // Test with code content (multiline)
3624        tst.test(
3625               "<tool_call>\n"
3626               "<function=python>\n"
3627               "<parameter=code>\n"
3628               "def hello():\n"
3629               "    print(\"Hello, world!\")\n"
3630               "\n"
3631               "hello()\n"
3632               "</parameter>\n"
3633               "</function>\n"
3634               "</tool_call>")
3635            .tools({
3636                python_tool
3637        })
3638            .expect_tool_calls({
3639                { "python", "{\"code\": \"def hello():\\n    print(\\\"Hello, world!\\\")\\n\\nhello()\"}", {} },
3640            })
3641            .expect_reconstruction()
3642            .run();
3643
3644        // Test with code content (asian unicode chars)
3645        tst.test(
3646               "<tool_call>\n"
3647               "<function=python>\n"
3648               "<parameter=code>\n"
3649               "格\n"
3650               "</parameter>\n"
3651               "</function>\n"
3652               "</tool_call>")
3653            .tools({
3654                python_tool
3655        })
3656            .expect_tool_calls({
3657                { "python", "{\"code\": \"格\"}", {} },
3658            })
3659            .expect_reconstruction()
3660            .run();
3661
3662        // Test with HTML tag content
3663        tst.test(
3664               "<tool_call>\n"
3665               "<function=html>\n"
3666               "<parameter=markup>\n"
3667               "<html>\n"
3668               " <head>\n"
3669               "  <title>Hello!</title>\n"
3670               " </head>\n"
3671               "</html>\n"
3672               "</parameter>\n"
3673               "</function>\n"
3674               "</tool_call>")
3675            .tools({
3676                html_tool
3677        })
3678            .expect_tool_calls({
3679                { "html", "{\"markup\": \"<html>\\n <head>\\n  <title>Hello!</title>\\n </head>\\n</html>\"}", {} },
3680            })
3681            .expect_reconstruction()
3682            .run();
3683
3684        // Test with TODO list (array of objects)
3685        tst.test(
3686               "<tool_call>\n"
3687               "<function=todo_list>\n"
3688               "<parameter=todos>\n"
3689               "[{\"item\": \"Check stuff\", \"selected\": false}, {\"item\": \"Prepare stuff\", \"selected\": true}]\n"
3690               "</parameter>\n"
3691               "</function>\n"
3692               "</tool_call>")
3693            .tools({
3694                todo_list
3695        })
3696            .expect_tool_calls({
3697                { "todo_list", "{\"todos\": [{\"item\": \"Check stuff\", \"selected\": false}, {\"item\": \"Prepare stuff\", \"selected\": true}]}", {} },
3698            })
3699            .expect_reconstruction()
3700            .run();
3701
3702        // Test flexible required argument ordering (required args still come first, in any order)
3703        tst.test(
3704               "<tool_call>\n"
3705               "<function=edit>\n"
3706               "<parameter=newString>\n#include\n</parameter>\n"
3707               "<parameter=filename>\nfoo.c\n</parameter>\n"
3708               "<parameter=oldString>\n#iclunde\n</parameter>\n"
3709               "</function>\n"
3710               "</tool_call>")
3711            .tools({ edit_tool })
3712            .expect_tool_calls({
3713                { "edit", R"({"newString": "#include", "filename": "foo.c", "oldString": "#iclunde"})", {} },
3714            })
3715            .expect_reconstruction()
3716            .run();
3717
3718        tst.test(
3719               "<tool_call>\n"
3720               "<function=tool_2req_4opt>\n"
3721               "<parameter=req2>\n42\n</parameter>\n"
3722               "<parameter=req1>\nhello\n</parameter>\n"
3723               "<parameter=opt2>\n200\n</parameter>\n"
3724               "</function>\n"
3725               "</tool_call>")
3726            .tools({ tool_2req_4opt })
3727            .expect_tool_calls({
3728                { "tool_2req_4opt", R"({"req2": 42, "req1": "hello", "opt2": 200})", {} },
3729            })
3730            .expect_reconstruction()
3731            .run();
3732
3733        // Test flexible optional argument ordering (2 required + 4 optional, reversed optional order)
3734        tst.test(
3735               "<tool_call>\n"
3736               "<function=tool_2req_4opt>\n"
3737               "<parameter=req1>\nhello\n</parameter>\n"
3738               "<parameter=req2>\n42\n</parameter>\n"
3739               "<parameter=opt4>\n100\n</parameter>\n"
3740               "<parameter=opt2>\n200\n</parameter>\n"
3741               "</function>\n"
3742               "</tool_call>")
3743            .tools({ tool_2req_4opt })
3744            .expect_tool_calls({
3745                { "tool_2req_4opt", R"({"req1": "hello", "req2": 42, "opt4": 100, "opt2": 200})", {} },
3746            })
3747            .expect_reconstruction()
3748            .run();
3749
3750        // Test flexible optional argument ordering (2 required + 5 optional, reversed optional order)
3751        tst.test(
3752               "<tool_call>\n"
3753               "<function=tool_2req_5opt>\n"
3754               "<parameter=req1>\nworld\n</parameter>\n"
3755               "<parameter=req2>\n7\n</parameter>\n"
3756               "<parameter=opt5>\nlast\n</parameter>\n"
3757               "<parameter=opt3>\nmiddle\n</parameter>\n"
3758               "<parameter=opt1>\nfirst\n</parameter>\n"
3759               "</function>\n"
3760               "</tool_call>")
3761            .tools({ tool_2req_5opt })
3762            .expect_tool_calls({
3763                { "tool_2req_5opt", R"({"req1": "world", "req2": 7, "opt5": "last", "opt3": "middle", "opt1": "first"})", {} },
3764            })
3765            .expect_reconstruction()
3766            .run();
3767
3768        // Test flexible optional argument ordering (2 required + 5 optional, all 5 in shuffled order)
3769        tst.test(
3770               "<tool_call>\n"
3771               "<function=tool_2req_5opt>\n"
3772               "<parameter=req1>\ntest\n</parameter>\n"
3773               "<parameter=req2>\n99\n</parameter>\n"
3774               "<parameter=opt3>\nc\n</parameter>\n"
3775               "<parameter=opt1>\na\n</parameter>\n"
3776               "<parameter=opt5>\ne\n</parameter>\n"
3777               "<parameter=opt4>\n4\n</parameter>\n"
3778               "<parameter=opt2>\n2\n</parameter>\n"
3779               "</function>\n"
3780               "</tool_call>")
3781            .tools({ tool_2req_5opt })
3782            .expect_tool_calls({
3783                { "tool_2req_5opt", R"({"req1": "test", "req2": 99, "opt3": "c", "opt1": "a", "opt5": "e", "opt4": 4, "opt2": 2})", {} },
3784            })
3785            .expect_reconstruction()
3786            .run();
3787
3788        // nullable string type ["string", "null"]
3789        tst.test(
3790               "<tool_call>\n"
3791               "<function=set_nullable_str>\n"
3792               "<parameter=name>\nhello world\n</parameter>\n"
3793               "</function>\n"
3794               "</tool_call>")
3795            .tools({ nullable_string_tool })
3796            .expect_tool_calls({
3797                { "set_nullable_str", R"({"name": "hello world"})", {} },
3798            })
3799            .run();
3800
3801        // nullable string with null first in type array ["null", "string"]
3802        tst.test(
3803               "<tool_call>\n"
3804               "<function=set_nullable_str_nf>\n"
3805               "<parameter=name>\nhello world\n</parameter>\n"
3806               "</function>\n"
3807               "</tool_call>")
3808            .tools({ nullable_string_null_first_tool })
3809            .expect_tool_calls({
3810                { "set_nullable_str_nf", R"({"name": "hello world"})", {} },
3811            })
3812            .run();
3813
3814        // nullable integer type ["integer", "null"] - should use JSON value path, not string
3815        tst.test(
3816               "<tool_call>\n"
3817               "<function=set_nullable_int>\n"
3818               "<parameter=count>\n42\n</parameter>\n"
3819               "</function>\n"
3820               "</tool_call>")
3821            .tools({ nullable_int_tool })
3822            .expect_tool_calls({
3823                { "set_nullable_int", R"({"count": 42})", {} },
3824            })
3825            .run();
3826
3827        // nullable string given null - parses as JSON null, not the string "null"
3828        tst.test(
3829               "<tool_call>\n"
3830               "<function=set_nullable_str>\n"
3831               "<parameter=name>\nnull\n</parameter>\n"
3832               "</function>\n"
3833               "</tool_call>")
3834            .tools({ nullable_string_tool })
3835            .expect_tool_calls({
3836                { "set_nullable_str", R"({"name": null})", {} },
3837            })
3838            .run();
3839
3840        // unions with string - JSON values of the other types are typed, everything else is a string
3841        tst.test(
3842               "<tool_call>\n"
3843               "<function=set_union>\n"
3844               "<parameter=value>\n{\"a\": 1}\n</parameter>\n"
3845               "<parameter=amount>\n2 dollars\n</parameter>\n"
3846               "</function>\n"
3847               "</tool_call>")
3848            .tools({ string_union_tool })
3849            .expect_tool_calls({
3850                { "set_union", R"({"value": {"a": 1}, "amount": "2 dollars"})", {} },
3851            })
3852            .run();
3853
3854        tst.test(
3855               "<tool_call>\n"
3856               "<function=set_union>\n"
3857               "<parameter=value>\n{not valid json\n</parameter>\n"
3858               "<parameter=amount>\n42\n</parameter>\n"
3859               "</function>\n"
3860               "</tool_call>")
3861            .tools({ string_union_tool })
3862            .expect_tool_calls({
3863                { "set_union", R"({"value": "{not valid json", "amount": 42})", {} },
3864            })
3865            .run();
3866
3867        // enum without explicit type key - should infer string from enum values
3868        tst.test(
3869               "<tool_call>\n"
3870               "<function=set_unit>\n"
3871               "<parameter=unit>\ncelsius\n</parameter>\n"
3872               "</function>\n"
3873               "</tool_call>")
3874            .tools({ enum_no_type_tool })
3875            .expect_tool_calls({
3876                { "set_unit", R"({"unit": "celsius"})", {} },
3877            })
3878            .run();
3879
3880        // Continuation tests
3881        tst.test("world!\nWhat's up?")
3882            .messages({ message_user, message_assist_prefill_content })
3883            .add_generation_prompt(false)
3884            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
3885            .expect_content("Hello, world!\nWhat's up?")
3886            .run();
3887    }
3888    {
3889        auto tst = peg_tester("models/templates/deepseek-ai-DeepSeek-V3.1.jinja", detailed_debug);
3890        tst.test(
3891               "<|tool▁calls▁begin|><|tool▁call▁begin|>get_time<|tool▁sep|>{\"city\": "
3892               "\"XYZCITY\"}<|tool▁call▁end|><|tool▁calls▁end|>")
3893            .tools({ get_time_tool })
3894            .enable_thinking(false)
3895            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
3896            .expect(message_with_tool_calls("get_time", "{\"city\":\"XYZCITY\"}"))
3897            .run();
3898
3899        // Continuation tests
3900        tst.test("world!\nWhat's up?")
3901            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
3902            .enable_thinking(true)
3903            .messages({ message_user, message_assist_prefill_content })
3904            .add_generation_prompt(false)
3905            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
3906            .expect_reasoning("I'm thinking")
3907            .expect_content("Hello, world!\nWhat's up?")
3908            .run();
3909
3910        tst.test(" thinking</think>Hello, world!\nWhat's up?")
3911            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
3912            .enable_thinking(true)
3913            .messages({ message_user, message_assist_prefill_reasoning })
3914            .add_generation_prompt(false)
3915            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
3916            .expect_reasoning("I'm thinking")
3917            .expect_content("Hello, world!\nWhat's up?")
3918            .run();
3919    }
3920
3921    {
3922        auto tst = peg_tester("models/templates/deepseek-ai-DeepSeek-V3.1.jinja", detailed_debug);
3923        tst.test(
3924               "REASONING</think><|tool▁calls▁begin|><|tool▁call▁begin|>get_time<|tool▁sep|>{\"city\": "
3925               "\"Tokyo\"}<|tool▁call▁end|><|tool▁calls▁end|>")
3926            .enable_thinking(true)
3927            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
3928            .tools({ get_time_tool })
3929            .expect(message_with_tool_calls_and_reasoning("get_time", "{\"city\":\"Tokyo\"}", "REASONING"))
3930            .run();
3931    }
3932
3933    {
3934        auto tst = peg_tester("models/templates/deepseek-ai-DeepSeek-V3.1.jinja", detailed_debug);
3935        tst.test(
3936               "REASONING</think>CONTENT<|tool▁calls▁begin|><|tool▁call▁begin|>get_time<|tool▁sep|>{\"city\": "
3937               "\"Paris\"}<|tool▁call▁end|><|tool▁call▁begin|>get_weather<|tool▁sep|>{\"city\": "
3938               "\"Paris\"}<|tool▁call▁end|><|tool▁calls▁end|>")
3939            .tools({
3940                get_time_tool, get_weather_tool
3941        })
3942            .enable_thinking(true)
3943            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
3944            .parallel_tool_calls(true)
3945            .expect(message_with_reasoning_content_and_multiple_tool_calls(
3946                "REASONING", "CONTENT",
3947                { { "get_time", "{\"city\":\"Paris\"}" }, { "get_weather", "{\"city\":\"Paris\"}" } }))
3948            .run();
3949    }
3950
3951    {
3952        auto tst = peg_tester("models/templates/deepseek-ai-DeepSeek-V3.1.jinja", detailed_debug);
3953        tst.test("REASONING</think>\nCONTENT")
3954            .enable_thinking(true)
3955            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
3956            .expect(simple_assist_msg("CONTENT", "REASONING\n"))
3957            .run();
3958    }
3959
3960    {
3961        auto tst = peg_tester("models/templates/deepseek-ai-DeepSeek-V3.1.jinja", detailed_debug);
3962        tst.test("CONTENT").enable_thinking(false).reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK).
3963            expect(simple_assist_msg("CONTENT", "")).run();
3964    }
3965
3966    // DeepSeek V3.2 tests - format uses DSML markup:
3967    //   <|DSML|function_calls>
3968    //   <|DSML|invoke name="foo">
3969    //   <|DSML|parameter name="bar" string="true|false">value</|DSML|parameter>
3970    //   </|DSML|invoke>
3971    //   </|DSML|function_calls>
3972    // Reasoning uses <think>...</think>. The generation prompt ends in <think> (thinking mode)
3973    // or <think></think> (non-thinking mode).
3974    {
3975        auto tst = peg_tester("models/templates/deepseek-ai-DeepSeek-V3.2.jinja", detailed_debug);
3976
3977        // Pure content (non-thinking mode)
3978        tst.test("Hello, world!\nWhat's up?")
3979            .enable_thinking(false)
3980            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
3981            .expect(message_assist)
3982            .run();
3983
3984        // Thinking + content
3985        tst.test("I'm\nthinking</think>Hello, world!\nWhat's up?")
3986            .enable_thinking(true)
3987            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
3988            .expect(message_assist_thoughts)
3989            .run();
3990
3991        // Thinking + tool call (single, string param)
3992        tst.test(
3993               "Let me check the time</think>\n\n"
3994               "<|DSML|function_calls>\n"
3995               "<|DSML|invoke name=\"get_time\">\n"
3996               "<|DSML|parameter name=\"city\" string=\"true\">Tokyo</|DSML|parameter>\n"
3997               "</|DSML|invoke>\n"
3998               "</|DSML|function_calls>")
3999            .enable_thinking(true)
4000            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4001            .tools({ get_time_tool })
4002            .expect(message_with_tool_calls_and_reasoning("get_time", R"({"city": "Tokyo"})", "Let me check the time"))
4003            .run();
4004
4005        // Tool call without reasoning (non-thinking mode), integer param (string="false")
4006        tst.test(
4007               "<|DSML|function_calls>\n"
4008               "<|DSML|invoke name=\"special_function\">\n"
4009               "<|DSML|parameter name=\"arg1\" string=\"false\">1</|DSML|parameter>\n"
4010               "</|DSML|invoke>\n"
4011               "</|DSML|function_calls>")
4012            .enable_thinking(false)
4013            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4014            .tools({ special_function_tool })
4015            .expect(message_assist_call)
4016            .run();
4017
4018        // Multiple parallel tool calls with reasoning
4019        tst.test(
4020               "Calling both</think>\n\n"
4021               "<|DSML|function_calls>\n"
4022               "<|DSML|invoke name=\"get_time\">\n"
4023               "<|DSML|parameter name=\"city\" string=\"true\">Paris</|DSML|parameter>\n"
4024               "</|DSML|invoke>\n"
4025               "<|DSML|invoke name=\"get_weather\">\n"
4026               "<|DSML|parameter name=\"city\" string=\"true\">Paris</|DSML|parameter>\n"
4027               "</|DSML|invoke>\n"
4028               "</|DSML|function_calls>")
4029            .enable_thinking(true)
4030            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4031            .parallel_tool_calls(true)
4032            .tools({ get_time_tool, get_weather_tool })
4033            .expect(message_with_reasoning_content_and_multiple_tool_calls(
4034                "Calling both", "",
4035                { { "get_time", R"({"city": "Paris"})" }, { "get_weather", R"({"city": "Paris"})" } }))
4036            .run();
4037
4038        // Tool call with content before tool calls
4039        tst.test(
4040               "Thinking about it</think>"
4041               "Let me call the function.\n\n"
4042               "<|DSML|function_calls>\n"
4043               "<|DSML|invoke name=\"special_function\">\n"
4044               "<|DSML|parameter name=\"arg1\" string=\"false\">1</|DSML|parameter>\n"
4045               "</|DSML|invoke>\n"
4046               "</|DSML|function_calls>")
4047            .enable_thinking(true)
4048            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4049            .tools({ special_function_tool })
4050            .expect_reasoning("Thinking about it")
4051            .expect_content("Let me call the function.")
4052            .expect_tool_calls({
4053                { "special_function", R"({"arg1": 1})", {} },
4054            })
4055            .expect_reconstruction()
4056            .run();
4057
4058        // Tool call with negative number
4059        tst.test(
4060               "Test negative</think>\n\n"
4061               "<|DSML|function_calls>\n"
4062               "<|DSML|invoke name=\"magic_int\">\n"
4063               "<|DSML|parameter name=\"ref\" string=\"false\">-14</|DSML|parameter>\n"
4064               "</|DSML|invoke>\n"
4065               "</|DSML|function_calls>")
4066            .enable_thinking(true)
4067            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4068            .tools({ magic_int_tool })
4069            .expect_reasoning("Test negative")
4070            .expect_tool_calls({
4071                { "magic_int", R"({"ref": -14})", {} },
4072            })
4073            .run();
4074
4075        // Tool call with decimal number
4076        tst.test(
4077               "Test decimal</think>\n\n"
4078               "<|DSML|function_calls>\n"
4079               "<|DSML|invoke name=\"amount\">\n"
4080               "<|DSML|parameter name=\"orig\" string=\"false\">3.14</|DSML|parameter>\n"
4081               "</|DSML|invoke>\n"
4082               "</|DSML|function_calls>")
4083            .enable_thinking(true)
4084            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4085            .tools({ amount_tool })
4086            .expect_reasoning("Test decimal")
4087            .expect_tool_calls({
4088                { "amount", R"({"orig": 3.14})", {} },
4089            })
4090            .run();
4091
4092        // Tool call with boolean
4093        tst.test(
4094               "Test boolean</think>\n\n"
4095               "<|DSML|function_calls>\n"
4096               "<|DSML|invoke name=\"toggle\">\n"
4097               "<|DSML|parameter name=\"enabled\" string=\"false\">true</|DSML|parameter>\n"
4098               "</|DSML|invoke>\n"
4099               "</|DSML|function_calls>")
4100            .enable_thinking(true)
4101            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4102            .tools({ toggle_tool })
4103            .expect_reasoning("Test boolean")
4104            .expect_tool_calls({
4105                { "toggle", R"({"enabled": true})", {} },
4106            })
4107            .run();
4108
4109        // Tool call with array parameter (JSON-formatted)
4110        tst.test(
4111               "Test array</think>\n\n"
4112               "<|DSML|function_calls>\n"
4113               "<|DSML|invoke name=\"todo_list\">\n"
4114               "<|DSML|parameter name=\"todos\" string=\"false\">[\"buy milk\",\"walk dog\"]</|DSML|parameter>\n"
4115               "</|DSML|invoke>\n"
4116               "</|DSML|function_calls>")
4117            .enable_thinking(true)
4118            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4119            .tools({ todo_list })
4120            .expect_reasoning("Test array")
4121            .expect_tool_calls({
4122                { "todo_list", R"({"todos": ["buy milk", "walk dog"]})", {} },
4123            })
4124            .run();
4125
4126        // Tool call with object parameter (JSON-formatted)
4127        tst.test(
4128               "Test object</think>\n\n"
4129               "<|DSML|function_calls>\n"
4130               "<|DSML|invoke name=\"set_config\">\n"
4131               "<|DSML|parameter name=\"config\" string=\"false\">{\"theme\":\"dark\",\"level\":2}</|DSML|parameter>\n"
4132               "</|DSML|invoke>\n"
4133               "</|DSML|function_calls>")
4134            .enable_thinking(true)
4135            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4136            .tools({ config_tool })
4137            .expect_reasoning("Test object")
4138            .expect_tool_calls({
4139                { "set_config", R"({"config": {"theme": "dark", "level": 2}})", {} },
4140            })
4141            .run();
4142
4143        // Edge case: empty reasoning
4144        tst.test(
4145               "</think>\n\n"
4146               "<|DSML|function_calls>\n"
4147               "<|DSML|invoke name=\"get_time\">\n"
4148               "<|DSML|parameter name=\"city\" string=\"true\">XYZCITY</|DSML|parameter>\n"
4149               "</|DSML|invoke>\n"
4150               "</|DSML|function_calls>")
4151            .enable_thinking(true)
4152            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4153            .tools({ get_time_tool })
4154            .expect(message_with_tool_calls("get_time", R"({"city": "XYZCITY"})"))
4155            .run();
4156
4157        // Edge case: tool call with multiple params (mixed types, string first)
4158        tst.test(
4159               "Multi-arg call</think>\n\n"
4160               "<|DSML|function_calls>\n"
4161               "<|DSML|invoke name=\"magic_int\">\n"
4162               "<|DSML|parameter name=\"ref\" string=\"false\">42</|DSML|parameter>\n"
4163               "<|DSML|parameter name=\"name\" string=\"true\">foo bar</|DSML|parameter>\n"
4164               "</|DSML|invoke>\n"
4165               "</|DSML|function_calls>")
4166            .enable_thinking(true)
4167            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4168            .tools({ magic_int_tool })
4169            .expect_reasoning("Multi-arg call")
4170            .expect_tool_calls({
4171                { "magic_int", R"({"ref": 42, "name": "foo bar"})", {} },
4172            })
4173            .run();
4174
4175        // Continuation tests
4176        tst.test("world!\nWhat's up?")
4177            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4178            .enable_thinking(true)
4179            .messages({ message_user, message_assist_prefill_content })
4180            .add_generation_prompt(false)
4181            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
4182            .expect_reasoning("I'm thinking")
4183            .expect_content("Hello, world!\nWhat's up?")
4184            .run();
4185
4186        tst.test(" thinking</think>Hello, world!\nWhat's up?")
4187            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4188            .enable_thinking(true)
4189            .messages({ message_user, message_assist_prefill_reasoning })
4190            .add_generation_prompt(false)
4191            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
4192            .expect_reasoning("I'm thinking")
4193            .expect_content("Hello, world!\nWhat's up?")
4194            .run();
4195    }
4196
4197    // DeepSeek V4 tests - same DSML markup as V3.2, but the tool call block is named
4198    // "tool_calls" and the non-thinking generation prompt ends in a bare </think>
4199    // instead of an empty <think></think> pair.
4200    {
4201        auto tst = peg_tester("models/templates/deepseek-ai-DeepSeek-V4.jinja", detailed_debug);
4202
4203        // Pure content (non-thinking mode; generation prompt ends with </think>)
4204        tst.test("Hello, world!\nWhat's up?")
4205            .enable_thinking(false)
4206            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4207            .expect(message_assist)
4208            .run();
4209
4210        // Thinking + content
4211        tst.test("I'm\nthinking</think>Hello, world!\nWhat's up?")
4212            .enable_thinking(true)
4213            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4214            .expect(message_assist_thoughts)
4215            .run();
4216
4217        // Thinking + tool call (single, string param)
4218        tst.test(
4219               "Let me check the time</think>\n\n"
4220               "<|DSML|tool_calls>\n"
4221               "<|DSML|invoke name=\"get_time\">\n"
4222               "<|DSML|parameter name=\"city\" string=\"true\">Tokyo</|DSML|parameter>\n"
4223               "</|DSML|invoke>\n"
4224               "</|DSML|tool_calls>")
4225            .enable_thinking(true)
4226            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4227            .tools({ get_time_tool })
4228            .expect(message_with_tool_calls_and_reasoning("get_time", R"({"city": "Tokyo"})", "Let me check the time"))
4229            .run();
4230
4231        // Tool call without reasoning (non-thinking mode), integer param (string="false")
4232        tst.test(
4233               "<|DSML|tool_calls>\n"
4234               "<|DSML|invoke name=\"special_function\">\n"
4235               "<|DSML|parameter name=\"arg1\" string=\"false\">1</|DSML|parameter>\n"
4236               "</|DSML|invoke>\n"
4237               "</|DSML|tool_calls>")
4238            .enable_thinking(false)
4239            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4240            .tools({ special_function_tool })
4241            .expect(message_assist_call)
4242            .run();
4243
4244        // Multiple parallel tool calls with reasoning
4245        tst.test(
4246               "Calling both</think>\n\n"
4247               "<|DSML|tool_calls>\n"
4248               "<|DSML|invoke name=\"get_time\">\n"
4249               "<|DSML|parameter name=\"city\" string=\"true\">Paris</|DSML|parameter>\n"
4250               "</|DSML|invoke>\n"
4251               "<|DSML|invoke name=\"get_weather\">\n"
4252               "<|DSML|parameter name=\"city\" string=\"true\">Paris</|DSML|parameter>\n"
4253               "</|DSML|invoke>\n"
4254               "</|DSML|tool_calls>")
4255            .enable_thinking(true)
4256            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4257            .parallel_tool_calls(true)
4258            .tools({ get_time_tool, get_weather_tool })
4259            .expect(message_with_reasoning_content_and_multiple_tool_calls(
4260                "Calling both", "",
4261                { { "get_time", R"({"city": "Paris"})" }, { "get_weather", R"({"city": "Paris"})" } }))
4262            .run();
4263
4264        // Tool call with content before tool calls
4265        tst.test(
4266               "Thinking about it</think>"
4267               "Let me call the function.\n\n"
4268               "<|DSML|tool_calls>\n"
4269               "<|DSML|invoke name=\"special_function\">\n"
4270               "<|DSML|parameter name=\"arg1\" string=\"false\">1</|DSML|parameter>\n"
4271               "</|DSML|invoke>\n"
4272               "</|DSML|tool_calls>")
4273            .enable_thinking(true)
4274            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4275            .tools({ special_function_tool })
4276            .expect_reasoning("Thinking about it")
4277            .expect_content("Let me call the function.")
4278            .expect_tool_calls({
4279                { "special_function", R"({"arg1": 1})", {} },
4280            })
4281            .expect_reconstruction()
4282            .run();
4283
4284        // Tool call with multiple params (mixed types)
4285        tst.test(
4286               "Multi-arg call</think>\n\n"
4287               "<|DSML|tool_calls>\n"
4288               "<|DSML|invoke name=\"magic_int\">\n"
4289               "<|DSML|parameter name=\"ref\" string=\"false\">42</|DSML|parameter>\n"
4290               "<|DSML|parameter name=\"name\" string=\"true\">foo bar</|DSML|parameter>\n"
4291               "</|DSML|invoke>\n"
4292               "</|DSML|tool_calls>")
4293            .enable_thinking(true)
4294            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4295            .tools({ magic_int_tool })
4296            .expect_reasoning("Multi-arg call")
4297            .expect_tool_calls({
4298                { "magic_int", R"({"ref": 42, "name": "foo bar"})", {} },
4299            })
4300            .run();
4301
4302        // Continuation tests
4303        tst.test("world!\nWhat's up?")
4304            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4305            .enable_thinking(true)
4306            .messages({ message_user, message_assist_prefill_content })
4307            .add_generation_prompt(false)
4308            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
4309            .expect_reasoning("I'm thinking")
4310            .expect_content("Hello, world!\nWhat's up?")
4311            .run();
4312
4313        tst.test(" thinking</think>Hello, world!\nWhat's up?")
4314            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4315            .enable_thinking(true)
4316            .messages({ message_user, message_assist_prefill_reasoning })
4317            .add_generation_prompt(false)
4318            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
4319            .expect_reasoning("I'm thinking")
4320            .expect_content("Hello, world!\nWhat's up?")
4321            .run();
4322
4323        tst.test(
4324            "Let me check the time\n\n"
4325            "<|DSML|tool_calls>\n"
4326            "<|DSML|invoke name=\"get_time\">\n"
4327            "<|DSML|parameter name=\"city\" string=\"true\">Tokyo</|DSML|parameter>\n"
4328            "</|DSML|invoke>\n"
4329            "</|DSML|tool_calls>") // no </think> after the TC close because the grammar will immediately constrain it to end
4330            .enable_thinking(true)
4331            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4332            .tools({ get_time_tool })
4333            .expect_reasoning("Let me check the time")
4334            .expect_tool_calls({ { "get_time", R"({"city": "Tokyo"})", {} } })
4335            .run();
4336    }
4337
4338    {
4339        // The DSML separator belongs to the tool call block, not assistant content.
4340        auto tst = peg_tester("models/templates/deepseek-ai-DeepSeek-V4-Flash-0731.jinja", detailed_debug);
4341        tst.test(
4342               "\n\n"
4343               "<|DSML|tool_calls>\n"
4344               "<|DSML|invoke name=\"special_function\">\n"
4345               "<|DSML|parameter name=\"arg1\" string=\"false\">1</|DSML|parameter>\n"
4346               "</|DSML|invoke>\n"
4347               "</|DSML|tool_calls>")
4348            .enable_thinking(false)
4349            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4350            .tools({ special_function_tool })
4351            .expect(message_assist_call)
4352            .expect_reconstruction()
4353            .run();
4354    }
4355
4356    // GLM-4.6 tests - format: <tool_call>function_name\n<arg_key>...</arg_key>\n<arg_value>...</arg_value>\n</tool_call>
4357    {
4358        auto tst = peg_tester("models/templates/GLM-4.6.jinja", detailed_debug);
4359        tst.test(
4360               "<tool_call>special_function\n"
4361               "<arg_key>arg1</arg_key>\n<arg_value>1</arg_value>\n"
4362               "</tool_call>")
4363            .tools({ special_function_tool })
4364            .expect(message_assist_call)
4365            .run();
4366    }
4367
4368    // GLM-4.7-Flash tests - format: <tool_call>function_name<arg_key>...</arg_key><arg_value>...</arg_value></tool_call>
4369    // Note: Template uses forced-open thinking mode (prompt ends with <think>)
4370    {
4371        auto tst = peg_tester("models/templates/GLM-4.7-Flash.jinja", detailed_debug);
4372
4373        // Pure content (no reasoning)
4374        tst.test("Hello, world!\nWhat's up?")
4375            .enable_thinking(false)
4376            .expect(message_assist)
4377            .expect_reconstruction()
4378            .run();
4379
4380        // Reasoning with content (forced-open mode - input starts after <think>)
4381        tst.test("I'm\nthinking</think>Hello, world!\nWhat's up?")
4382            .enable_thinking(true)
4383            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4384            .expect(message_assist_thoughts)
4385            .expect_reconstruction()
4386            .run();
4387
4388        // Tool call without reasoning
4389        tst.test(
4390               "<tool_call>special_function"
4391               "<arg_key>arg1</arg_key><arg_value>1</arg_value>"
4392               "</tool_call>")
4393            .enable_thinking(false)
4394            .tools({ special_function_tool })
4395            .expect(message_assist_call)
4396            .expect_reconstruction()
4397            .run();
4398
4399        // Tool call with reasoning (forced-open mode)
4400        tst.test(
4401               "I'm\nthinking</think>"
4402               "<tool_call>special_function"
4403               "<arg_key>arg1</arg_key><arg_value>1</arg_value>"
4404               "</tool_call>")
4405            .enable_thinking(true)
4406            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4407            .tools({ special_function_tool })
4408            .expect(message_assist_call_thoughts)
4409            .expect_reconstruction()
4410            .run();
4411
4412        tst.test(
4413               "<tool_call>special_function"
4414               "<arg_key>arg1</arg_key><arg_value>1</arg_value>"
4415               "</tool_call>"
4416               "<tool_call>special_function_with_opt"
4417               "<arg_key>arg1</arg_key><arg_value>1</arg_value>"
4418               "<arg_key>arg2</arg_key><arg_value>2</arg_value>"
4419               "</tool_call>")
4420            .enable_thinking(false)
4421            .parallel_tool_calls(true)
4422            .tools({
4423                special_function_tool, special_function_tool_with_optional_param
4424        })
4425            .expect_tool_calls({
4426                { "special_function", R"({"arg1": 1})", {} },
4427                { "special_function_with_opt", R"({"arg1": 1, "arg2": 2})", {} },
4428            })
4429            .expect_reconstruction()
4430            .run();
4431
4432        // #20650: tool with no required args, model emits <tool_call>name</tool_call> with no arg tags.
4433        {
4434            static common_chat_tool no_args_tool{
4435                "read_file_diff_md", "Reads a file diff",
4436                R"({"type":"object","properties":{"review_id":{"type":"string"},"file_id":{"type":"string"}}})",
4437            };
4438            tst.test(
4439                   "Let me read the diff content."
4440                   "</think>"
4441                   "<tool_call>read_file_diff_md</tool_call>")
4442                .enable_thinking(true)
4443                .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4444                .tools({ no_args_tool })
4445                .expect_reasoning("Let me read the diff content.")
4446                .expect_tool_calls({{ "read_file_diff_md", "{}", {} }})
4447                .expect_reconstruction()
4448                .run();
4449        }
4450
4451        // Continuation tests
4452        tst.test("world!\nWhat's up?")
4453            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4454            .enable_thinking(true)
4455            .messages({ message_user, message_assist_prefill_content })
4456            .add_generation_prompt(false)
4457            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
4458            .expect_reasoning("I'm thinking")
4459            .expect_content("Hello, world!\nWhat's up?")
4460            .run();
4461
4462        tst.test(" thinking</think>Hello, world!\nWhat's up?")
4463            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4464            .enable_thinking(true)
4465            .messages({ message_user, message_assist_prefill_reasoning })
4466            .add_generation_prompt(false)
4467            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
4468            .expect_reasoning("I'm thinking")
4469            .expect_content("Hello, world!\nWhat's up?")
4470            .run();
4471    }
4472
4473    // Spark2.5 uses tagged arguments with forced-open thinking.
4474    {
4475        auto tst = peg_tester("models/templates/Spark2.5.jinja", detailed_debug);
4476
4477        tst.test("Hello, world!\nWhat's up?")
4478            .enable_thinking(false)
4479            .expect(message_assist)
4480            .expect_reconstruction()
4481            .run();
4482
4483        tst.test("I'm\nthinking</think>Hello, world!\nWhat's up?")
4484            .enable_thinking(true)
4485            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4486            .expect(message_assist_thoughts)
4487            .expect_reconstruction()
4488            .run();
4489
4490        tst.test(
4491               "<tool_call>special_function"
4492               "<arg_key>arg1</arg_key><arg_value>1</arg_value>"
4493               "</tool_call>")
4494            .enable_thinking(false)
4495            .tools({ special_function_tool })
4496            .expect(message_assist_call)
4497            .expect_reconstruction()
4498            .run();
4499
4500        tst.test(
4501               "I'm\nthinking</think>"
4502               "<tool_call>special_function"
4503               "<arg_key>arg1</arg_key><arg_value>1</arg_value>"
4504               "</tool_call>")
4505            .enable_thinking(true)
4506            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
4507            .tools({ special_function_tool })
4508            .expect(message_assist_call_thoughts)
4509            .expect_reconstruction()
4510            .run();
4511
4512        tst.test(
4513               "<tool_call>special_function"
4514               "<arg_key>arg1</arg_key><arg_value>1</arg_value>"
4515               "</tool_call>"
4516               "<tool_call>special_function_with_opt"
4517               "<arg_key>arg1</arg_key><arg_value>1</arg_value>"
4518               "<arg_key>arg2</arg_key><arg_value>2</arg_value>"
4519               "</tool_call>")
4520            .enable_thinking(false)
4521            .parallel_tool_calls(true)
4522            .tools({ special_function_tool, special_function_tool_with_optional_param })
4523            .expect_tool_calls({
4524                { "special_function", R"({"arg1": 1})", {} },
4525                { "special_function_with_opt", R"({"arg1": 1, "arg2": 2})", {} },
4526            })
4527            .expect_reconstruction()
4528            .run();
4529
4530        tst.test(
4531               "Preparing updates."
4532               "<tool_call>magic_int"
4533               "<arg_key>ref</arg_key><arg_value>42</arg_value>"
4534               "<arg_key>name</arg_key><arg_value>上海</arg_value>"
4535               "</tool_call>"
4536               "<tool_call>amount"
4537               "<arg_key>orig</arg_key><arg_value>2.5</arg_value>"
4538               "</tool_call>"
4539               "<tool_call>toggle"
4540               "<arg_key>enabled</arg_key><arg_value>true</arg_value>"
4541               "</tool_call>"
4542               "<tool_call>set_config"
4543               "<arg_key>config</arg_key><arg_value>{\"source\": \"spark\", \"options\": {\"strict\": true}}</arg_value>"
4544               "</tool_call>"
4545               "<tool_call>nested_args"
4546               "<arg_key>tags</arg_key><arg_value>[\"alpha\", \"测试\"]</arg_value>"
4547               "<arg_key>entries</arg_key><arg_value>[{\"id\": 1, \"label\": \"first\"}, {\"id\": 2, \"label\": \"第二\"}]</arg_value>"
4548               "</tool_call>"
4549               "<tool_call>empty_args"
4550               "</tool_call>")
4551            .enable_thinking(false)
4552            .parallel_tool_calls(true)
4553            .tools({ magic_int_tool, amount_tool, toggle_tool, config_tool, nested_args_tool, empty_args_tool })
4554            .expect_content("Preparing updates.")
4555            .expect_tool_calls({
4556                { "magic_int", R"({"ref": 42, "name": "上海"})", {} },
4557                { "amount", R"({"orig": 2.5})", {} },
4558                { "toggle", R"({"enabled": true})", {} },
4559                { "set_config", R"({"config": {"source": "spark", "options": {"strict": true}}})", {} },
4560                { "nested_args", R"({"tags": ["alpha", "测试"], "entries": [{"id": 1, "label": "first"}, {"id": 2, "label": "第二"}]})", {} },
4561                { "empty_args", "{}", {} },
4562            })
4563            .expect_reconstruction()
4564            .run();
4565    }
4566
4567    // Verify the throw path produces a readable error message, not std::out_of_range.
4568    // #20424 introduced effective_input = generation_prompt + input, but the throw
4569    // uses input.substr(result.end) where result.end is in effective_input space.
4570    {
4571        if (!g_template_filter.empty() && std::string("models/templates/GLM-4.7-Flash.jinja").find(g_template_filter) != std::string::npos) {
4572            auto tmpls = common_chat_templates_ptr(
4573                common_chat_templates_init(nullptr, read_file("models/templates/GLM-4.7-Flash.jinja")));
4574
4575            static common_chat_tool weather_tool{
4576                "get_weather", "Get weather",
4577                R"({"type":"object","properties":{"city":{"type":"string"}},"required":["city"]})",
4578            };
4579
4580            common_chat_templates_inputs inputs;
4581            inputs.tools = { weather_tool };
4582            inputs.enable_thinking = true;
4583            inputs.reasoning_format = COMMON_REASONING_FORMAT_AUTO;
4584            inputs.add_generation_prompt = true;
4585            inputs.use_jinja = true;
4586            common_chat_msg msg;
4587            msg.role = "user";
4588            msg.content = "get_weather";
4589            inputs.messages = { msg };
4590
4591            auto params = common_chat_templates_apply(tmpls.get(), inputs);
4592            common_peg_arena arena;
4593            arena.load(params.parser);
4594            common_chat_parser_params pp(params);
4595
4596            // generation_prompt is non-empty for thinking models, so result.end
4597            // will be offset by generation_prompt.size() into effective_input space.
4598            assert(!pp.generation_prompt.empty());
4599
4600            std::string bad_input =
4601                "Thinking.\n"
4602                "</think>"
4603                "<tool_call>get_weather"
4604                "<arg_key>city</arg_key><arg_value>Tokyo</arg_value>"
4605                "</tool_call>\n";
4606
4607            bool got_runtime_error = false;
4608            bool got_out_of_range = false;
4609            std::string error_msg;
4610            try {
4611                common_chat_peg_parse(arena, bad_input, /*is_partial=*/false, pp);
4612            } catch (const std::out_of_range & e) {
4613                got_out_of_range = true;
4614                error_msg = e.what();
4615            } catch (const std::runtime_error & e) {
4616                got_runtime_error = true;
4617                error_msg = e.what();
4618            }
4619            GGML_ASSERT(!got_out_of_range && "throw path crashed with out_of_range (input.substr in effective_input space)");
4620            GGML_ASSERT(got_runtime_error  && "throw path should produce std::runtime_error with parse position");
4621        }
4622    }
4623
4624    // Kimi-K3 tests - custom parser
4625    // Unique feature: XTML tags built from <|open|>/<|close|>/<|sep|>, and a
4626    // generation prompt that leaves the think section already open.
4627    {
4628        auto tst = peg_tester("models/templates/Kimi-K3.jinja", detailed_debug);
4629
4630        // Content only. The response section is explicit even with no reasoning.
4631        tst.test("<|open|>response<|sep|>Hello, world!\nWhat's up?<|close|>response<|sep|>"
4632                 "<|close|>message<|sep|>")
4633            .expect(message_assist)
4634            .run();
4635
4636        // Reasoning with no opening tag - the generation prompt already opened it
4637        tst.test("I'm thinking about this<|close|>think<|sep|>"
4638                 "<|open|>response<|sep|>Hello, world!\nWhat's up?<|close|>response<|sep|>"
4639                 "<|close|>message<|sep|>")
4640            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
4641            .expect(simple_assist_msg("Hello, world!\nWhat's up?", "I'm thinking about this"))
4642            .run();
4643
4644        // Prose that mentions the tag names must survive intact.
4645        tst.test("<|open|>response<|sep|>Use the response tag, then message the handler."
4646                 "<|close|>response<|sep|><|close|>message<|sep|>")
4647            .expect(simple_assist_msg("Use the response tag, then message the handler."))
4648            .run();
4649
4650        // Truncated mid-reasoning (hit the token budget): keep the reasoning.
4651        tst.test("I was still thinking when the budget ran out")
4652            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
4653            .expect_reasoning("I was still thinking when the budget ran out")
4654            .run();
4655
4656        // Single tool call, one argument.
4657        tst.test("<|open|>response<|sep|><|close|>response<|sep|>"
4658                 "<|open|>tools<|sep|>"
4659                 "<|open|>call tool=\"special_function\" index=\"1\"<|sep|>"
4660                 "<|open|>argument key=\"arg1\" type=\"number\"<|sep|>1<|close|>argument<|sep|>"
4661                 "<|close|>call<|sep|><|close|>tools<|sep|><|close|>message<|sep|>")
4662            .tools({ special_function_tool })
4663            .expect_tool_calls({
4664                { "special_function", R"({"arg1":1})", "" },
4665            })
4666            .run();
4667
4668        // Tool call preceded by reasoning (no opening think tag) and content.
4669        tst.test("I should call it<|close|>think<|sep|>"
4670                 "<|open|>response<|sep|>On it.<|close|>response<|sep|>"
4671                 "<|open|>tools<|sep|>"
4672                 "<|open|>call tool=\"special_function\" index=\"1\"<|sep|>"
4673                 "<|open|>argument key=\"arg1\" type=\"number\"<|sep|>1<|close|>argument<|sep|>"
4674                 "<|close|>call<|sep|><|close|>tools<|sep|><|close|>message<|sep|>")
4675            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
4676            .tools({ special_function_tool })
4677            .expect(simple_assist_msg("On it.", "I should call it", "special_function",
4678                                      R"({"arg1":1})", ""))
4679            .run();
4680
4681        // Multiple typed arguments: values must come back as JSON numbers, not strings
4682        tst.test("<|open|>response<|sep|><|close|>response<|sep|>"
4683                 "<|open|>tools<|sep|>"
4684                 "<|open|>call tool=\"special_function_with_opt\" index=\"1\"<|sep|>"
4685                 "<|open|>argument key=\"arg1\" type=\"number\"<|sep|>1<|close|>argument<|sep|>"
4686                 "<|open|>argument key=\"arg2\" type=\"number\"<|sep|>2<|close|>argument<|sep|>"
4687                 "<|close|>call<|sep|><|close|>tools<|sep|><|close|>message<|sep|>")
4688            .tools({ special_function_tool_with_optional_param })
4689            .expect_tool_calls({
4690                { "special_function_with_opt", R"({"arg1":1,"arg2":2})", "" },
4691            })
4692            .run();
4693
4694        // Parallel tool calls in one <|open|>tools<|sep|> section.
4695        tst.test("<|open|>response<|sep|><|close|>response<|sep|>"
4696                 "<|open|>tools<|sep|>"
4697                 "<|open|>call tool=\"special_function\" index=\"1\"<|sep|>"
4698                 "<|open|>argument key=\"arg1\" type=\"number\"<|sep|>1<|close|>argument<|sep|>"
4699                 "<|close|>call<|sep|>"
4700                 "<|open|>call tool=\"special_function_with_opt\" index=\"2\"<|sep|>"
4701                 "<|open|>argument key=\"arg1\" type=\"number\"<|sep|>1<|close|>argument<|sep|>"
4702                 "<|open|>argument key=\"arg2\" type=\"number\"<|sep|>2<|close|>argument<|sep|>"
4703                 "<|close|>call<|sep|><|close|>tools<|sep|><|close|>message<|sep|>")
4704            .parallel_tool_calls(true)
4705            .tools({ special_function_tool, special_function_tool_with_optional_param })
4706            .expect_tool_calls({
4707                { "special_function", R"({"arg1":1})", "" },
4708                { "special_function_with_opt", R"({"arg1":1,"arg2":2})", "" },
4709            })
4710            .run();
4711
4712        // String-typed argument keeps its literal text (no JSON coercion).
4713        tst.test("<|open|>response<|sep|><|close|>response<|sep|>"
4714                 "<|open|>tools<|sep|>"
4715                 "<|open|>call tool=\"python\" index=\"1\"<|sep|>"
4716                 "<|open|>argument key=\"code\" type=\"string\"<|sep|>print('hey')"
4717                 "<|close|>argument<|sep|>"
4718                 "<|close|>call<|sep|><|close|>tools<|sep|><|close|>message<|sep|>")
4719            .tools({ python_tool })
4720            .expect_tool_calls({
4721                // custom delimiter: the payload itself contains )"
4722                { "python", R"JSON({"code":"print('hey')"})JSON", "" },
4723            })
4724            .run();
4725    }
4726
4727    // Kimi-K2-Thinking tests - custom parser
4728    // Unique feature: tool call ID embeds function name as functions.<name>:<counter>
4729    {
4730        auto tst = peg_tester("models/templates/Kimi-K2-Thinking.jinja", detailed_debug);
4731
4732        // Basic content only
4733        tst.test("Hello, world!\nWhat's up?").expect(message_assist).run();
4734
4735        // Single tool call
4736        tst.test(
4737               "<|tool_calls_section_begin|><|tool_call_begin|>functions.special_function:0<|tool_call_argument_begin|>"
4738               "{\"arg1\": 1}<|tool_call_end|><|tool_calls_section_end|>")
4739            .tools({ special_function_tool })
4740            .expect(simple_assist_msg("", "", "special_function", "{\"arg1\": 1}", "functions.special_function:0"))
4741            .run();
4742
4743        // Single tool call with reasoning
4744        tst.test(
4745               "<think>I'm thinking about this</think>"
4746               "<|tool_calls_section_begin|><|tool_call_begin|>functions.special_function:0<|tool_call_argument_begin|>"
4747               "{\"arg1\": 1}<|tool_call_end|><|tool_calls_section_end|>")
4748            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
4749            .tools({ special_function_tool })
4750            .expect(simple_assist_msg("", "I'm thinking about this", "special_function", "{\"arg1\": 1}", "functions.special_function:0"))
4751            .run();
4752
4753        // Tool call with content
4754        tst.test(
4755               "Hello, world!\nWhat's up?"
4756               "<|tool_calls_section_begin|><|tool_call_begin|>functions.special_function:0<|tool_call_argument_begin|>"
4757               "{\"arg1\": 1}<|tool_call_end|><|tool_calls_section_end|>")
4758            .tools({ special_function_tool })
4759            .expect(simple_assist_msg("Hello, world!\nWhat's up?", "", "special_function", "{\"arg1\": 1}", "functions.special_function:0"))
4760            .run();
4761
4762        // Multiple tool calls (parallel) - tests the indexing behavior
4763        tst.test(
4764               "<|tool_calls_section_begin|>"
4765               "<|tool_call_begin|>functions.special_function:0<|tool_call_argument_begin|>{\"arg1\": 1}<|tool_call_end|>"
4766               "<|tool_call_begin|>functions.special_function_with_opt:1<|tool_call_argument_begin|>{\"arg1\": 1, \"arg2\": 2}<|tool_call_end|>"
4767               "<|tool_calls_section_end|>")
4768            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
4769            .parallel_tool_calls(true)
4770            .tools({
4771                special_function_tool, special_function_tool_with_optional_param
4772        })
4773            .expect_tool_calls({
4774                { "special_function", R"({"arg1": 1})", "functions.special_function:0" },
4775                { "special_function_with_opt", R"({"arg1": 1, "arg2": 2})", "functions.special_function_with_opt:1" },
4776            })
4777            .run();
4778
4779        // Multiple tool calls with reasoning
4780        tst.test(
4781               "<think>I need to call two functions</think>"
4782               "<|tool_calls_section_begin|>"
4783               "<|tool_call_begin|>functions.special_function:0<|tool_call_argument_begin|>{\"arg1\": 1}<|tool_call_end|>"
4784               "<|tool_call_begin|>functions.python:1<|tool_call_argument_begin|>{\"code\": \"print('hey')\"}<|tool_call_end|>"
4785               "<|tool_calls_section_end|>")
4786            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
4787            .parallel_tool_calls(true)
4788            .tools({
4789                special_function_tool, python_tool
4790        })
4791            .expect_reasoning("I need to call two functions")
4792            .expect_tool_calls({
4793                { "special_function", R"({"arg1": 1})", "functions.special_function:0" },
4794                { "python", "{\"code\": \"print('hey')\"}", "functions.python:1" },
4795            })
4796            .run();
4797
4798        // Python tool with multiline code
4799        tst.test(
4800               "<|tool_calls_section_begin|><|tool_call_begin|>functions.python:0<|tool_call_argument_begin|>"
4801               "{\"code\": \"def hello():\\n    print(\\\"Hello, world!\\\")\\n\\nhello()\"}<|tool_call_end|><|tool_calls_section_end|>")
4802            .tools({ python_tool })
4803            .expect_tool_calls({
4804                { "python", "{\"code\": \"def hello():\\n    print(\\\"Hello, world!\\\")\\n\\nhello()\"}", "functions.python:0" },
4805            })
4806            .run();
4807
4808        // Tool call with empty arguments
4809        tst.test(
4810               "<|tool_calls_section_begin|><|tool_call_begin|>functions.empty_args:0<|tool_call_argument_begin|>"
4811               "{}<|tool_call_end|><|tool_calls_section_end|>")
4812            .tools({ empty_args_tool })
4813            .expect(simple_assist_msg("", "", "empty_args", "{}", "functions.empty_args:0"))
4814            .run();
4815
4816        // Partial tool call (streaming)
4817        tst.test(
4818               "<|tool_calls_section_begin|><|tool_call_begin|>functions.special_function:0<|tool_call_argument_begin|>"
4819               "{\"arg1\": ")
4820            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
4821            .tools({ special_function_tool })
4822            .is_partial(true)
4823            .expect(simple_assist_msg("", "", "special_function", "{\"arg1\": ", "functions.special_function:0"))
4824            .run();
4825
4826        // Three tool calls to verify counter continues incrementing
4827        tst.test(
4828               "<|tool_calls_section_begin|>"
4829               "<|tool_call_begin|>functions.special_function:0<|tool_call_argument_begin|>{\"arg1\": 1}<|tool_call_end|>"
4830               "<|tool_call_begin|>functions.python:1<|tool_call_argument_begin|>{\"code\": \"print(1)\"}<|tool_call_end|>"
4831               "<|tool_call_begin|>functions.html:2<|tool_call_argument_begin|>{\"markup\": \"<p>test</p>\"}<|tool_call_end|>"
4832               "<|tool_calls_section_end|>")
4833            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
4834            .parallel_tool_calls(true)
4835            .tools({
4836                special_function_tool, python_tool, html_tool
4837        })
4838            .expect_tool_calls({
4839                { "special_function", R"({"arg1": 1})", "functions.special_function:0" },
4840                { "python", "{\"code\": \"print(1)\"}", "functions.python:1" },
4841                { "html", "{\"markup\": \"<p>test</p>\"}", "functions.html:2" },
4842            })
4843            .run();
4844
4845        // Multiple tool calls with reasoning, call *inside thinking block*
4846        tst.test(
4847               "<think>I need to call two functions"
4848               "<|tool_calls_section_begin|>"
4849               "<|tool_call_begin|>functions.special_function:0<|tool_call_argument_begin|>{\"arg1\": 1}<|tool_call_end|>"
4850               "<|tool_call_begin|>functions.python:1<|tool_call_argument_begin|>{\"code\": \"print('hey')\"}<|tool_call_end|>"
4851               "<|tool_calls_section_end|>")
4852            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
4853            .parallel_tool_calls(true)
4854            .tools({
4855                special_function_tool, python_tool
4856        })
4857            .expect_reasoning("I need to call two functions")
4858            .expect_tool_calls({
4859                { "special_function", R"({"arg1": 1})", "functions.special_function:0" },
4860                { "python", "{\"code\": \"print('hey')\"}", "functions.python:1" },
4861            })
4862            .run();
4863
4864        // Multiple tool calls with reasoning, call *inside thinking block* and *without section markers or end markers
4865        tst.test(
4866               "<think>I need to call two functions"
4867               "<|tool_call_begin|>functions.special_function:0<|tool_call_argument_begin|>{\"arg1\": 1}"
4868               "<|tool_call_begin|>functions.python:1<|tool_call_argument_begin|>{\"code\": \"print('hey')\"}")
4869            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
4870            .parallel_tool_calls(true)
4871            .tools({
4872                special_function_tool, python_tool
4873        })
4874            .expect_reasoning("I need to call two functions")
4875            .expect_tool_calls({
4876                { "special_function", R"({"arg1": 1})", "functions.special_function:0" },
4877                { "python", "{\"code\": \"print('hey')\"}", "functions.python:1" },
4878            })
4879            .run();
4880
4881        // Real life test - execute_command
4882        tst.test("<|tool_call_begin|>functions.execute_command:0<|tool_call_argument_begin|>{\"command\": \"ls -lah\""
4883            ", \"cwd\": \"/home/user/development/exllamav3\", \"timeout\": 10}")
4884            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
4885            .parallel_tool_calls(true)
4886            .tools({
4887                {
4888                    /* .name = */ "execute_command",
4889                    /* .description = */ "Execute shell command",
4890                    /* .parameters = */ R"({
4891                        "type": "object",
4892                        "properties": {
4893                            "command": {
4894                                "type": "string",
4895                                "description": "Shell command to execute"
4896                            },
4897                            "cwd": {
4898                                "type": "string",
4899                                "description": "Working directory"
4900                            },
4901                            "timeout": {
4902                                "type": "integer",
4903                                "description": "The timeout in seconds"
4904                            }
4905                        },
4906                        "required": ["command"]
4907                    })"
4908                }
4909            }).
4910            expect_tool_calls({
4911                {
4912                    "execute_command",
4913                    R"({"command": "ls -lah", "cwd": "/home/user/development/exllamav3", "timeout": 10})",
4914                    "functions.execute_command:0"
4915                }
4916            })
4917            .run();
4918
4919        // Continuation tests
4920        tst.test("world!\nWhat's up?")
4921            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
4922            .enable_thinking(true)
4923            .messages({ message_user, message_assist_prefill_content })
4924            .add_generation_prompt(false)
4925            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
4926            .expect_reasoning("I'm thinking")
4927            .expect_content("Hello, world!\nWhat's up?")
4928            .run();
4929
4930        tst.test(" thinking</think>Hello, world!\nWhat's up?")
4931            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
4932            .enable_thinking(true)
4933            .messages({ message_user, message_assist_prefill_reasoning })
4934            .add_generation_prompt(false)
4935            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
4936            .expect_reasoning("I'm thinking")
4937            .expect_content("Hello, world!\nWhat's up?")
4938            .run();
4939    }
4940
4941    {
4942        auto kimi_id_special_func_tool_call =
4943            simple_assist_msg("", "", "special_function", "{\"arg1\": 1}", "functions.special_function:0");
4944
4945        // Kimi-K2 old template
4946        auto tst = peg_tester("models/templates/moonshotai-Kimi-K2.jinja", detailed_debug);
4947        tst.test("Hello, world!\nWhat's up?").expect(message_assist).expect_reconstruction().run();
4948        tst.test(
4949               "<|tool_calls_section_begin|><|tool_call_begin|>functions.special_function:0<|tool_call_argument_begin|>"
4950               "{\"arg1\": 1}<|tool_call_end|><|tool_calls_section_end|>")
4951            .tools({ special_function_tool })
4952            .expect(kimi_id_special_func_tool_call)
4953            .expect_reconstruction()
4954            .run();
4955
4956        // Kimi-K2-Instruct
4957        auto tst2 = peg_tester("models/templates/Kimi-K2-Instruct.jinja", detailed_debug);
4958        tst2.test("Hello, world!\nWhat's up?").expect(message_assist).expect_reconstruction().run();
4959        tst2.test(
4960               "<|tool_calls_section_begin|><|tool_call_begin|>functions.special_function:0<|tool_call_argument_begin|>"
4961               "{\"arg1\": 1}<|tool_call_end|><|tool_calls_section_end|>")
4962            .tools({ special_function_tool })
4963            .expect(kimi_id_special_func_tool_call)
4964            .expect_reconstruction()
4965            .run();
4966
4967        // Continuation tests
4968        tst.test("world!\nWhat's up?")
4969            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
4970            .enable_thinking(true)
4971            .messages({ message_user, message_assist_prefill_content })
4972            .add_generation_prompt(false)
4973            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
4974            .expect_reasoning("I'm thinking")
4975            .expect_content("Hello, world!\nWhat's up?")
4976            .run();
4977
4978        tst.test(" thinking</think>Hello, world!\nWhat's up?")
4979            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
4980            .enable_thinking(true)
4981            .messages({ message_user, message_assist_prefill_reasoning })
4982            .add_generation_prompt(false)
4983            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
4984            .expect_reasoning("I'm thinking")
4985            .expect_content("Hello, world!\nWhat's up?")
4986            .run();
4987    }
4988
4989    for (const char * tmpl : {
4990             "models/templates/LFM2-8B-A1B.jinja",
4991             "models/templates/LFM2.5-Instruct.jinja",
4992             "models/templates/LFM2.5-8B-A1B.jinja",
4993         }) {
4994        test_lfm2_parser(tmpl, detailed_debug);
4995    }
4996
4997    // Thinking cases only apply to LFM2.5-8B-A1B, the one LFM2 template that emits <think>
4998    {
4999        auto tst = peg_tester("models/templates/LFM2.5-8B-A1B.jinja", detailed_debug);
5000
5001        // Reasoning is parsed independent of enable_thinking
5002
5003        // Tool call with reasoning
5004        tst.test("<think>I'm\nthinking</think><|tool_call_start|>[special_function(arg1=1)]<|tool_call_end|>")
5005            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5006            .tools({ special_function_tool })
5007            .expect(message_assist_call_thoughts)
5008            .run();
5009
5010        // Tool call with reasoning and content
5011        tst.test("<think>I need to call a function</think>"
5012                 "Let me check the time.<|tool_call_start|>[get_time(city=\"Paris\")]<|tool_call_end|>")
5013            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5014            .tools({ get_time_tool })
5015            .expect(message_with_reasoning_content_and_multiple_tool_calls(
5016                "I need to call a function", "Let me check the time.", { { "get_time", "{\"city\":\"Paris\"}" } }
5017            ))
5018            .run();
5019
5020        // Fake tool call marker inside reasoning is not parsed as a call
5021        tst.test("<think>Let me think about <|tool_call_start|>[special_function(arg1=1)]<|tool_call_end|> hmm</think>"
5022                 "<|tool_call_start|>[special_function(arg1=1)]<|tool_call_end|>")
5023            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5024            .tools({ special_function_tool })
5025            .expect_reasoning("Let me think about <|tool_call_start|>[special_function(arg1=1)]<|tool_call_end|> hmm")
5026            .expect_tool_calls({
5027                { "special_function", R"({"arg1": 1})", {} },
5028            })
5029            .run();
5030
5031        // enable_thinking=false still captures emitted reasoning
5032        tst.test("<think>I'm\nthinking</think>Hello, world!\nWhat's up?")
5033            .enable_thinking(false)
5034            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5035            .expect(message_assist_thoughts)
5036            .run();
5037
5038        tst.test("<think>I'm\nthinking</think><|tool_call_start|>[special_function(arg1=1)]<|tool_call_end|>")
5039            .enable_thinking(false)
5040            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5041            .tools({ special_function_tool })
5042            .expect(message_assist_call_thoughts)
5043            .run();
5044
5045        // Continuation: prefill content
5046        tst.test("world!\nWhat's up?")
5047            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5048            .enable_thinking(true)
5049            .messages({ message_user, message_assist_prefill_content })
5050            .add_generation_prompt(false)
5051            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5052            .expect_reasoning("I'm thinking")
5053            .expect_content("Hello, world!\nWhat's up?")
5054            .run();
5055
5056        // Continuation: prefill reasoning
5057        tst.test(" thinking</think>Hello, world!\nWhat's up?")
5058            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5059            .enable_thinking(true)
5060            .messages({ message_user, message_assist_prefill_reasoning })
5061            .add_generation_prompt(false)
5062            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
5063            .expect_reasoning("I'm thinking")
5064            .expect_content("Hello, world!\nWhat's up?")
5065            .run();
5066    }
5067
5068    // Reka-Edge tests - uses native JSON format with per-call wrapper
5069    {
5070        auto tst = peg_tester("models/templates/Reka-Edge.jinja", detailed_debug);
5071
5072        // Basic content only
5073        tst.test("Hello, world!\nWhat's up?").enable_thinking(false).expect(message_assist).run();
5074
5075        // Single tool call without reasoning
5076        tst.test("<tool_call>\n{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}</tool_call>")
5077            .enable_thinking(false)
5078            .tools({ special_function_tool })
5079            .expect(message_assist_call)
5080            .run();
5081
5082        // Tool call with string argument
5083        tst.test("<tool_call>\n{\"name\": \"get_time\", \"arguments\": {\"city\": \"XYZCITY\"}}</tool_call>")
5084            .enable_thinking(false)
5085            .tools({ get_time_tool })
5086            .expect(message_with_tool_calls("get_time", "{\"city\":\"XYZCITY\"}"))
5087            .run();
5088
5089        // Tool call with reasoning (enable_thinking=true)
5090        tst.test("I'm\nthinking\n</think>\n\n<tool_call>\n{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}</tool_call>")
5091            .enable_thinking(true)
5092            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5093            .tools({ special_function_tool })
5094            .expect(message_assist_call_thoughts)
5095            .run();
5096
5097        // Multiple tool calls (parallel)
5098        tst.test(
5099            "<tool_call>\n{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}</tool_call>"
5100            "<tool_call>\n{\"name\": \"special_function_with_opt\", \"arguments\": {\"arg1\": 1, \"arg2\": 2}}</tool_call>"
5101        )
5102            .enable_thinking(false)
5103            .parallel_tool_calls(true)
5104            .tools({
5105                special_function_tool, special_function_tool_with_optional_param
5106            })
5107            .expect_tool_calls({
5108                { "special_function", R"({"arg1": 1})", {} },
5109                { "special_function_with_opt", R"({"arg1": 1, "arg2": 2})", {} },
5110            })
5111            .run();
5112
5113        // Tool call with reasoning and content
5114        tst.test("I need to call a function\n</think>\n\n"
5115                 "Let me check the time.<tool_call>\n{\"name\": \"get_time\", \"arguments\": {\"city\": \"XYZCITY\"}}</tool_call>")
5116            .enable_thinking(true)
5117            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5118            .tools({ get_time_tool })
5119            .expect(message_with_reasoning_content_and_multiple_tool_calls(
5120                "I need to call a function", "Let me check the time.", { { "get_time", "{\"city\":\"XYZCITY\"}" } }
5121            ))
5122            .run();
5123
5124        // Partial tool call (streaming)
5125        tst.test("<tool_call>\n{\"name\": \"special_function\", \"arguments\": {\"arg1\":")
5126            .tools({ special_function_tool })
5127            .enable_thinking(false)
5128            .is_partial(true)
5129            .expect(simple_assist_msg("", "", "special_function", "{\"arg1\": "))
5130            .run();
5131
5132        // Tool call with empty arguments
5133        tst.test("<tool_call>\n{\"name\": \"empty_args\", \"arguments\": {}}</tool_call>")
5134            .enable_thinking(false)
5135            .tools({ empty_args_tool })
5136            .expect(simple_assist_msg("", "", "empty_args", "{}"))
5137            .run();
5138
5139        // Tool call with no parameters schema, {} means no arguments
5140        tst.test("<tool_call>\n{\"name\": \"empty_args_no_schema\", \"arguments\": {}}</tool_call>")
5141            .enable_thinking(false)
5142            .tools({ empty_args_tool_no_schema })
5143            .expect(simple_assist_msg("", "", "empty_args_no_schema", "{}"))
5144            .run();
5145
5146        // fake tool call marker in reasoning
5147        tst.test(
5148               "Let me think about <tool_call>\n{\"name\": \"special_function\", \"arguments\": {\"arg1\": 2}}</tool_call> hmm\n</think>\n\n"
5149               "<tool_call>\n{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}</tool_call>")
5150            .enable_thinking(true)
5151            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5152            .tools({ special_function_tool })
5153            .expect_reasoning("Let me think about <tool_call>\n{\"name\": \"special_function\", \"arguments\": {\"arg1\": 2}}</tool_call> hmm")
5154            .expect_tool_calls({
5155                { "special_function", R"({"arg1": 1})", {} },
5156            })
5157            .run();
5158
5159        // Continuation tests
5160        tst.test("world!\nWhat's up?")
5161            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5162            .enable_thinking(true)
5163            .messages({ message_user, message_assist_prefill_content })
5164            .add_generation_prompt(false)
5165            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5166            .expect_reasoning("I'm thinking")
5167            .expect_content("Hello, world!\nWhat's up?")
5168            .run();
5169
5170        tst.test(" thinking\n</think>\n\nHello, world!\nWhat's up?")
5171            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5172            .enable_thinking(true)
5173            .messages({ message_user, message_assist_prefill_reasoning })
5174            .add_generation_prompt(false)
5175            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
5176            .expect_reasoning("I'm thinking")
5177            .expect_content("Hello, world!\nWhat's up?")
5178            .run();
5179    }
5180
5181
5182    // Apertus-8B-Instruct tests - FUNC_NAME_AS_KEY format
5183    // Format: <|tools_prefix|>[{"function_name": {...arguments...}}]<|tools_suffix|>
5184    {
5185        auto tst = peg_tester("models/templates/Apertus-8B-Instruct.jinja", detailed_debug);
5186        tst.test("<|tools_prefix|>[{\"special_function\": {\"arg1\": 1}}]<|tools_suffix|>")
5187            .tools({ special_function_tool })
5188            .expect(message_assist_call)
5189            .expect_reconstruction()
5190            .run();
5191
5192        // Continuation tests
5193        tst.test("world!\nWhat's up?")
5194            .messages({ message_user, message_assist_prefill_content })
5195            .add_generation_prompt(false)
5196            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5197            .expect_content("Hello, world!\nWhat's up?")
5198            .run();
5199    }
5200
5201    // MiniMax-M2 tests - XML invoke format with parameter tags
5202    // Format: <minimax:tool_call><invoke name="func"><parameter name="key">value</parameter></invoke></minimax:tool_call>
5203    {
5204        auto tst = peg_tester("models/templates/MiniMax-M2.jinja", detailed_debug);
5205        tst.test("\n</think>\n\nHello, world!\nWhat's up?").enable_thinking(true).reasoning_format(COMMON_REASONING_FORMAT_AUTO).expect(message_assist).run();
5206
5207        tst.test("I'm\nthinking\n</think>\n\nHello, world!\nWhat's up?").enable_thinking(true).reasoning_format(COMMON_REASONING_FORMAT_AUTO).expect(message_assist_thoughts).run();
5208
5209        tst.test("Let's call a tool:\n</think>\n\n<minimax:tool_call>\n<invoke name=\"empty_args\">\n</invoke>\n</minimax:tool_call>").
5210            enable_thinking(true).
5211            reasoning_format(COMMON_REASONING_FORMAT_AUTO).
5212            tools({ empty_args_tool }).
5213            expect(message_with_reasoning_and_tool_call("Let's call a tool:", "empty_args", "{}")).
5214            run();
5215
5216        tst.test(
5217               "\n</think>\n\n<minimax:tool_call>\n<invoke name=\"special_function\">\n<parameter "
5218               "name=\"arg1\">1</parameter>\n</invoke>\n</minimax:tool_call>")
5219            .tools({ special_function_tool })
5220            .expect(message_assist_call)
5221            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5222            .run();
5223
5224        // Continuation tests
5225        tst.test("world!\nWhat's up?")
5226            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5227            .enable_thinking(true)
5228            .messages({ message_user, message_assist_prefill_content })
5229            .add_generation_prompt(false)
5230            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5231            .expect_reasoning("I'm thinking")
5232            .expect_content("Hello, world!\nWhat's up?")
5233            .run();
5234
5235        tst.test(" thinking\n</think>\n\nHello, world!\nWhat's up?")
5236            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5237            .enable_thinking(true)
5238            .messages({ message_user, message_assist_prefill_reasoning })
5239            .add_generation_prompt(false)
5240            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
5241            .expect_reasoning("I'm thinking")
5242            .expect_content("Hello, world!\nWhat's up?")
5243            .run();
5244    }
5245
5246    // MiniMax-M3 tests - namespaced XML invoke format, the parameter name is the tag
5247    // Format:
5248    //   ]<]minimax[>[<tool_call>
5249    //   ]<]minimax[>[<invoke name="get_time">]<]minimax[>[<city>Tokyo]<]minimax[>[</city>]<]minimax[>[</invoke>
5250    //   ]<]minimax[>[</tool_call>
5251    // Reasoning uses <mm:think>...</mm:think>. The generation prompt is only "]~b]ai\n", so the model
5252    // opens the thinking block itself; a turn without reasoning is prefixed with a bare </mm:think>.
5253    {
5254        auto tst = peg_tester("models/templates/MiniMax-M3.jinja", detailed_debug);
5255
5256        // Content only (bare </mm:think> prefix)
5257        tst.test("</mm:think>Hello, world!\nWhat's up?")
5258            .enable_thinking(true)
5259            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5260            .expect(message_assist)
5261            .expect_reconstruction()
5262            .run();
5263
5264        // Thinking + content
5265        tst.test("<mm:think>I'm\nthinking</mm:think>Hello, world!\nWhat's up?")
5266            .enable_thinking(true)
5267            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5268            .expect(message_assist_thoughts)
5269            .expect_reconstruction()
5270            .run();
5271
5272        // Thinking + tool call (single, string param)
5273        tst.test(
5274               "<mm:think>Let me check the time</mm:think>"
5275               "]<]minimax[>[<tool_call>\n"
5276               "]<]minimax[>[<invoke name=\"get_time\">"
5277               "]<]minimax[>[<city>Tokyo]<]minimax[>[</city>"
5278               "]<]minimax[>[</invoke>\n"
5279               "]<]minimax[>[</tool_call>")
5280            .enable_thinking(true)
5281            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5282            .tools({ get_time_tool })
5283            .expect(message_with_tool_calls_and_reasoning("get_time", R"({"city": "Tokyo"})", "Let me check the time"))
5284            .expect_reconstruction()
5285            .run();
5286
5287        // Tool call without reasoning, integer param
5288        tst.test(
5289               "</mm:think>"
5290               "]<]minimax[>[<tool_call>\n"
5291               "]<]minimax[>[<invoke name=\"special_function\">"
5292               "]<]minimax[>[<arg1>1]<]minimax[>[</arg1>"
5293               "]<]minimax[>[</invoke>\n"
5294               "]<]minimax[>[</tool_call>")
5295            .enable_thinking(true)
5296            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5297            .tools({ special_function_tool })
5298            .expect(message_assist_call)
5299            .expect_reconstruction()
5300            .run();
5301
5302        // Tool call with no parameters
5303        tst.test(
5304               "<mm:think>Let's call a tool:</mm:think>"
5305               "]<]minimax[>[<tool_call>\n"
5306               "]<]minimax[>[<invoke name=\"empty_args\">"
5307               "]<]minimax[>[</invoke>\n"
5308               "]<]minimax[>[</tool_call>")
5309            .enable_thinking(true)
5310            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5311            .tools({ empty_args_tool })
5312            .expect(message_with_reasoning_and_tool_call("Let's call a tool:", "empty_args", "{}"))
5313            .expect_reconstruction()
5314            .run();
5315
5316        // Multiple parallel tool calls in one block
5317        tst.test(
5318               "<mm:think>Calling both</mm:think>"
5319               "]<]minimax[>[<tool_call>\n"
5320               "]<]minimax[>[<invoke name=\"get_time\">"
5321               "]<]minimax[>[<city>Paris]<]minimax[>[</city>"
5322               "]<]minimax[>[</invoke>\n"
5323               "]<]minimax[>[<invoke name=\"get_weather\">"
5324               "]<]minimax[>[<city>Paris]<]minimax[>[</city>"
5325               "]<]minimax[>[</invoke>\n"
5326               "]<]minimax[>[</tool_call>")
5327            .enable_thinking(true)
5328            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5329            .parallel_tool_calls(true)
5330            .tools({ get_time_tool, get_weather_tool })
5331            .expect(message_with_reasoning_content_and_multiple_tool_calls(
5332                "Calling both", "",
5333                { { "get_time", R"({"city": "Paris"})" }, { "get_weather", R"({"city": "Paris"})" } }))
5334            .expect_reconstruction()
5335            .run();
5336
5337        // Content before the tool call block
5338        tst.test(
5339               "<mm:think>Thinking about it</mm:think>"
5340               "Let me call the function."
5341               "]<]minimax[>[<tool_call>\n"
5342               "]<]minimax[>[<invoke name=\"special_function\">"
5343               "]<]minimax[>[<arg1>1]<]minimax[>[</arg1>"
5344               "]<]minimax[>[</invoke>\n"
5345               "]<]minimax[>[</tool_call>")
5346            .enable_thinking(true)
5347            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5348            .tools({ special_function_tool })
5349            .expect_reasoning("Thinking about it")
5350            .expect_content("Let me call the function.")
5351            .expect_tool_calls({
5352                { "special_function", R"({"arg1": 1})", {} },
5353            })
5354            .expect_reconstruction()
5355            .run();
5356
5357        // Negative number
5358        tst.test(
5359               "<mm:think>Test negative</mm:think>"
5360               "]<]minimax[>[<tool_call>\n"
5361               "]<]minimax[>[<invoke name=\"magic_int\">"
5362               "]<]minimax[>[<ref>-14]<]minimax[>[</ref>"
5363               "]<]minimax[>[</invoke>\n"
5364               "]<]minimax[>[</tool_call>")
5365            .enable_thinking(true)
5366            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5367            .tools({ magic_int_tool })
5368            .expect_reasoning("Test negative")
5369            .expect_tool_calls({
5370                { "magic_int", R"({"ref": -14})", {} },
5371            })
5372            .expect_reconstruction()
5373            .run();
5374
5375        // Decimal number
5376        tst.test(
5377               "<mm:think>Test decimal</mm:think>"
5378               "]<]minimax[>[<tool_call>\n"
5379               "]<]minimax[>[<invoke name=\"amount\">"
5380               "]<]minimax[>[<orig>3.14]<]minimax[>[</orig>"
5381               "]<]minimax[>[</invoke>\n"
5382               "]<]minimax[>[</tool_call>")
5383            .enable_thinking(true)
5384            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5385            .tools({ amount_tool })
5386            .expect_reasoning("Test decimal")
5387            .expect_tool_calls({
5388                { "amount", R"({"orig": 3.14})", {} },
5389            })
5390            .expect_reconstruction()
5391            .run();
5392
5393        // Boolean
5394        tst.test(
5395               "<mm:think>Test boolean</mm:think>"
5396               "]<]minimax[>[<tool_call>\n"
5397               "]<]minimax[>[<invoke name=\"toggle\">"
5398               "]<]minimax[>[<enabled>true]<]minimax[>[</enabled>"
5399               "]<]minimax[>[</invoke>\n"
5400               "]<]minimax[>[</tool_call>")
5401            .enable_thinking(true)
5402            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5403            .tools({ toggle_tool })
5404            .expect_reasoning("Test boolean")
5405            .expect_tool_calls({
5406                { "toggle", R"({"enabled": true})", {} },
5407            })
5408            .expect_reconstruction()
5409            .run();
5410
5411        // Multiple params of mixed types (required int first, then optional string)
5412        tst.test(
5413               "<mm:think>Multi-arg call</mm:think>"
5414               "]<]minimax[>[<tool_call>\n"
5415               "]<]minimax[>[<invoke name=\"magic_int\">"
5416               "]<]minimax[>[<ref>42]<]minimax[>[</ref>"
5417               "]<]minimax[>[<name>foo bar]<]minimax[>[</name>"
5418               "]<]minimax[>[</invoke>\n"
5419               "]<]minimax[>[</tool_call>")
5420            .enable_thinking(true)
5421            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5422            .tools({ magic_int_tool })
5423            .expect_reasoning("Multi-arg call")
5424            .expect_tool_calls({
5425                { "magic_int", R"({"ref": 42, "name": "foo bar"})", {} },
5426            })
5427            .expect_reconstruction()
5428            .run();
5429
5430        // Nested object param, expanded into one element per key
5431        tst.test(
5432               "<mm:think>Nested object</mm:think>"
5433               "]<]minimax[>[<tool_call>\n"
5434               "]<]minimax[>[<invoke name=\"imaginary_number\">"
5435               "]<]minimax[>[<number>"
5436               "]<]minimax[>[<real>1.5]<]minimax[>[</real>"
5437               "]<]minimax[>[<imaginary>-2.5]<]minimax[>[</imaginary>"
5438               "]<]minimax[>[</number>"
5439               "]<]minimax[>[</invoke>\n"
5440               "]<]minimax[>[</tool_call>")
5441            .enable_thinking(true)
5442            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5443            .tools({ imaginary_number_tool })
5444            .expect_reasoning("Nested object")
5445            .expect_tool_calls({
5446                { "imaginary_number", R"({"number": {"real": 1.5, "imaginary": -2.5}})", {} },
5447            })
5448            .expect_reconstruction()
5449            .run();
5450
5451        // Array params, expanded into <item> elements (of scalars and of objects)
5452        tst.test(
5453               "<mm:think>Nested arrays</mm:think>"
5454               "]<]minimax[>[<tool_call>\n"
5455               "]<]minimax[>[<invoke name=\"nested_args\">"
5456               "]<]minimax[>[<tags>"
5457               "]<]minimax[>[<item>alpha]<]minimax[>[</item>"
5458               "]<]minimax[>[<item>beta]<]minimax[>[</item>"
5459               "]<]minimax[>[</tags>"
5460               "]<]minimax[>[<entries>"
5461               "]<]minimax[>[<item>"
5462               "]<]minimax[>[<id>1]<]minimax[>[</id>"
5463               "]<]minimax[>[<label>one]<]minimax[>[</label>"
5464               "]<]minimax[>[</item>"
5465               "]<]minimax[>[</entries>"
5466               "]<]minimax[>[</invoke>\n"
5467               "]<]minimax[>[</tool_call>")
5468            .enable_thinking(true)
5469            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5470            .tools({ nested_args_tool })
5471            .expect_reasoning("Nested arrays")
5472            .expect_tool_calls({
5473                { "nested_args", R"({"tags": ["alpha", "beta"], "entries": [{"id": 1, "label": "one"}]})", {} },
5474            })
5475            .expect_reconstruction()
5476            .run();
5477
5478        // Union params (anyOf/oneOf), expanded as a choice of the alternatives
5479        tst.test(
5480               "<mm:think>Union array</mm:think>"
5481               "]<]minimax[>[<tool_call>\n"
5482               "]<]minimax[>[<invoke name=\"union_args\">"
5483               "]<]minimax[>[<filter>"
5484               "]<]minimax[>[<item>alpha]<]minimax[>[</item>"
5485               "]<]minimax[>[<item>beta]<]minimax[>[</item>"
5486               "]<]minimax[>[</filter>"
5487               "]<]minimax[>[</invoke>\n"
5488               "]<]minimax[>[</tool_call>")
5489            .enable_thinking(true)
5490            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5491            .tools({ union_args_tool })
5492            .expect_reasoning("Union array")
5493            .expect_tool_calls({
5494                { "union_args", R"({"filter": ["alpha", "beta"]})", {} },
5495            })
5496            .expect_reconstruction()
5497            .run();
5498
5499        // oneOf between a scalar and an object
5500        tst.test(
5501               "<mm:think>Union scalar</mm:think>"
5502               "]<]minimax[>[<tool_call>\n"
5503               "]<]minimax[>[<invoke name=\"union_args\">"
5504               "]<]minimax[>[<limit>5]<]minimax[>[</limit>"
5505               "]<]minimax[>[</invoke>\n"
5506               "]<]minimax[>[</tool_call>")
5507            .enable_thinking(true)
5508            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5509            .tools({ union_args_tool })
5510            .expect_reasoning("Union scalar")
5511            .expect_tool_calls({
5512                { "union_args", R"({"limit": 5})", {} },
5513            })
5514            .expect_reconstruction()
5515            .run();
5516
5517        tst.test(
5518               "<mm:think>Union nested</mm:think>"
5519               "]<]minimax[>[<tool_call>\n"
5520               "]<]minimax[>[<invoke name=\"union_args\">"
5521               "]<]minimax[>[<limit>"
5522               "]<]minimax[>[<max>10]<]minimax[>[</max>"
5523               "]<]minimax[>[</limit>"
5524               "]<]minimax[>[</invoke>\n"
5525               "]<]minimax[>[</tool_call>")
5526            .enable_thinking(true)
5527            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5528            .tools({ union_args_tool })
5529            .expect_reasoning("Union nested")
5530            .expect_tool_calls({
5531                { "union_args", R"({"limit": {"max": 10}})", {} },
5532            })
5533            .expect_reconstruction()
5534            .run();
5535
5536        // A union with a string alternative is a string
5537        tst.test(
5538               "<mm:think>Union string</mm:think>"
5539               "]<]minimax[>[<tool_call>\n"
5540               "]<]minimax[>[<invoke name=\"union_args\">"
5541               "]<]minimax[>[<label>hi]<]minimax[>[</label>"
5542               "]<]minimax[>[</invoke>\n"
5543               "]<]minimax[>[</tool_call>")
5544            .enable_thinking(true)
5545            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5546            .tools({ union_args_tool })
5547            .expect_reasoning("Union string")
5548            .expect_tool_calls({
5549                { "union_args", R"({"label": "hi"})", {} },
5550            })
5551            .expect_reconstruction()
5552            .run();
5553
5554        // ... even when the value looks structured
5555        tst.test(
5556               "<mm:think>Union string</mm:think>"
5557               "]<]minimax[>[<tool_call>\n"
5558               "]<]minimax[>[<invoke name=\"union_args\">"
5559               "]<]minimax[>[<label>"
5560               "]<]minimax[>[<text>hi]<]minimax[>[</text>"
5561               "]<]minimax[>[</label>"
5562               "]<]minimax[>[</invoke>\n"
5563               "]<]minimax[>[</tool_call>")
5564            .enable_thinking(true)
5565            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5566            .tools({ union_args_tool })
5567            .expect_reasoning("Union string")
5568            .expect_tool_calls({
5569                { "union_args", R"({"label": "]<]minimax[>[<text>hi]<]minimax[>[</text>"})", {} },
5570            })
5571            .expect_reconstruction()
5572            .run();
5573
5574        // Edge case: empty reasoning followed by a tool call
5575        tst.test(
5576               "<mm:think></mm:think>"
5577               "]<]minimax[>[<tool_call>\n"
5578               "]<]minimax[>[<invoke name=\"get_time\">"
5579               "]<]minimax[>[<city>XYZCITY]<]minimax[>[</city>"
5580               "]<]minimax[>[</invoke>\n"
5581               "]<]minimax[>[</tool_call>")
5582            .enable_thinking(true)
5583            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5584            .tools({ get_time_tool })
5585            .expect(message_with_tool_calls("get_time", R"({"city": "XYZCITY"})"))
5586            .run();
5587
5588        // Continuation tests
5589        tst.test("world!\nWhat's up?")
5590            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5591            .enable_thinking(true)
5592            .messages({ message_user, message_assist_prefill_content })
5593            .add_generation_prompt(false)
5594            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5595            .expect_reasoning("I'm thinking")
5596            .expect_content("Hello, world!\nWhat's up?")
5597            .run();
5598
5599        tst.test(" thinking</mm:think>Hello, world!\nWhat's up?")
5600            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5601            .enable_thinking(true)
5602            .messages({ message_user, message_assist_prefill_reasoning })
5603            .add_generation_prompt(false)
5604            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
5605            .expect_reasoning("I'm thinking")
5606            .expect_content("Hello, world!\nWhat's up?")
5607            .run();
5608    }
5609
5610    // NVIDIA-Nemotron-Nano-v2 tests - <TOOLCALL>...</TOOLCALL> format
5611    // Format: <TOOLCALL>[{"name": "func", "arguments": {...}}]</TOOLCALL>
5612    {
5613        auto tst = peg_tester("models/templates/NVIDIA-Nemotron-Nano-v2.jinja", detailed_debug);
5614        tst.test("I'm\nthinking\n</think>\n<TOOLCALL>[{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}]</TOOLCALL>")
5615            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5616            .tools({ special_function_tool })
5617            .expect(message_assist_call_thoughts)
5618            .run();
5619
5620        tst.test("I'm\nthinking\n</think>\n\n<TOOLCALL>[{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}]</TOOLCALL>\n")
5621            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5622            .tools({ special_function_tool })
5623            .expect(message_assist_call_thoughts)
5624            .run();
5625
5626        // Continuation tests
5627        tst.test("world!\nWhat's up?")
5628            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5629            .enable_thinking(true)
5630            .messages({ message_user, message_assist_prefill_content })
5631            .add_generation_prompt(false)
5632            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5633            .expect_reasoning("I'm thinking")
5634            .expect_content("Hello, world!\nWhat's up?")
5635            .run();
5636    }
5637
5638    // CohereForAI-c4ai-command-r7b (uses START_RESPONSE/END_RESPONSE, START_THINKING/END_THINKING, START_ACTION/END_ACTION)
5639    {
5640        auto tst = peg_tester("models/templates/CohereForAI-c4ai-command-r7b-12-2024-tool_use.jinja", detailed_debug);
5641        tst.test("<|START_RESPONSE|>Hello, world!\nWhat's up?<|END_RESPONSE|>").expect(message_assist).run();
5642        tst.test(
5643               "<|START_THINKING|>I'm\nthinking<|END_THINKING|>"
5644               "<|START_ACTION|>[\n"
5645               "    {\"tool_call_id\": \"0\", \"tool_name\": \"special_function\", \"parameters\": {\"arg1\": 1}}\n"
5646               "]<|END_ACTION|>")
5647            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5648            .tools({ special_function_tool })
5649            .expect(message_assist_thoughts_call_idx)
5650            .run();
5651    }
5652    // CohereForAI-c4ai-command-r-plus (uses markdown code block format)
5653    {
5654        auto tst = peg_tester("models/templates/CohereForAI-c4ai-command-r-plus-tool_use.jinja", detailed_debug);
5655        tst.test("<|CHATBOT_TOKEN|>Hello, world!\nWhat's up?<|END_OF_TURN_TOKEN|>").expect(message_assist).run();
5656        // Tool calls: Action: followed by JSON code block
5657        tst.test(
5658               "Action:\n"
5659               "```json\n"
5660               "[{\"tool_name\": \"special_function\", \"parameters\": {\"arg1\": 1}}]\n"
5661               "```")
5662            .tools({ special_function_tool })
5663            .expect(message_assist_call)
5664            .run();
5665
5666        // Continuation tests
5667        tst.test("world!\nWhat's up?")
5668            .messages({ message_user, message_assist_prefill_content })
5669            .add_generation_prompt(false)
5670            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5671            .expect_content("Hello, world!\nWhat's up?")
5672            .run();
5673    }
5674
5675    // mistralai-Mistral-Nemo-Instruct-2407.jinja
5676    {
5677        auto tst = peg_tester("models/templates/mistralai-Mistral-Nemo-Instruct-2407.jinja", detailed_debug);
5678        tst.test("Hello, world!\nWhat's up?").expect(message_assist).expect_reconstruction().run();
5679        tst.test("[TOOL_CALLS][{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}, \"id\": \"123456789\"}]")
5680            .tools({ special_function_tool })
5681            .expect(message_assist_call_id)
5682            .expect_reconstruction()
5683            .run();
5684
5685        // Continuation tests
5686        tst.test("world!\nWhat's up?")
5687            .messages({ message_user, message_assist_prefill_content })
5688            .add_generation_prompt(false)
5689            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5690            .expect_content("Hello, world!\nWhat's up?")
5691            .run();
5692    }
5693    {
5694        auto tst = peg_tester("models/templates/meetkai-functionary-medium-v3.1.jinja", detailed_debug);
5695        tst.test("Hello, world!\nWhat's up?").expect(message_assist).expect_reconstruction().run();
5696        tst.test("<function=special_function>{\"arg1\": 1}</function>")
5697            .tools({ special_function_tool })
5698            .expect(message_assist_call)
5699            .expect_reconstruction()
5700            .run();
5701
5702        // Continuation tests
5703        tst.test("world!\nWhat's up?")
5704            .messages({ message_user, message_assist_prefill_content })
5705            .add_generation_prompt(false)
5706            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5707            .expect_content("Hello, world!\nWhat's up?")
5708            .run();
5709    }
5710    // Functionary v3.2 - recipient-based format: >>>recipient\n{content}
5711    {
5712        auto tst = peg_tester("models/templates/meetkai-functionary-medium-v3.2.jinja", detailed_debug);
5713        tst.test("all\nHello, world!\nWhat's up?").expect(message_assist).expect_reconstruction().run();
5714        tst.test("special_function\n{\"arg1\": 1}")
5715            .tools({ special_function_tool })
5716            .expect(message_assist_call)
5717            .expect_reconstruction()
5718            .run();
5719
5720        // Continuation tests
5721        tst.test("world!\nWhat's up?")
5722            .messages({ message_user, message_assist_prefill_content })
5723            .add_generation_prompt(false)
5724            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5725            .expect_content("Hello, world!\nWhat's up?")
5726            .run();
5727    }
5728
5729    // FireFunction
5730    {
5731        auto tst = peg_tester("models/templates/fireworks-ai-llama-3-firefunction-v2.jinja", detailed_debug);
5732        tst.test("Hello, world!\nWhat's up?").expect(message_assist).expect_reconstruction().run();
5733        tst.test(" functools[{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}]")
5734            .tools({ special_function_tool })
5735            .expect(message_assist_call)
5736            .expect_reconstruction()
5737            .run();
5738
5739        // Continuation tests
5740        tst.test("world!\nWhat's up?")
5741            .messages({ message_user, message_assist_prefill_content })
5742            .add_generation_prompt(false)
5743            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5744            .expect_content("Hello, world!\nWhat's up?")
5745            .run();
5746    }
5747
5748    // DeepSeek R1 Distill Llama 8B - reasoning tests only (forced open thinking)
5749    // Note: Template uses forced-open mode (prompt ends with <think>), so input shouldn't include opening tag
5750    {
5751        auto tst = peg_tester("models/templates/deepseek-ai-DeepSeek-R1-Distill-Llama-8B.jinja", detailed_debug);
5752        tst.test("</think>Hello, world!\nWhat's up?")
5753            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5754            .expect(message_assist)
5755            .run();
5756        tst.test("I'm\nthinking</think>Hello, world!\nWhat's up?")
5757            .enable_thinking(true)
5758            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5759            .expect(message_assist_thoughts)
5760            .run();
5761
5762        // Continuation tests
5763        tst.test("world!\nWhat's up?")
5764            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5765            .enable_thinking(true)
5766            .messages({ message_user, message_assist_prefill_content })
5767            .add_generation_prompt(false)
5768            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5769            .expect_reasoning("I'm thinking")
5770            .expect_content("Hello, world!\nWhat's up?")
5771            .run();
5772
5773        tst.test(" thinking</think>Hello, world!\nWhat's up?")
5774            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5775            .enable_thinking(true)
5776            .messages({ message_user, message_assist_prefill_reasoning })
5777            .add_generation_prompt(false)
5778            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
5779            .expect_reasoning("I'm thinking")
5780            .expect_content("Hello, world!\nWhat's up?")
5781            .run();
5782    }
5783    // llama-cpp DeepSeek R1 template (always forced-open thinking)
5784    {
5785        auto tst = peg_tester("models/templates/llama-cpp-deepseek-r1.jinja", detailed_debug);
5786        tst.test("</think>Hello, world!\nWhat's up?").expect(message_assist).reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK).run();
5787        tst.test("I'm\nthinking</think>Hello, world!\nWhat's up?")
5788            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5789            .expect(message_assist_thoughts)
5790            .run();
5791        tst.test(
5792               "</think><|tool▁calls▁begin|><|tool▁call▁begin|>function<|tool▁sep|>special_function\n"
5793               "```json\n{\"arg1\": 1}```<|tool▁call▁end|><|tool▁calls▁end|>")
5794            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5795            .tools({ special_function_tool })
5796            .parallel_tool_calls(true)
5797            .expect(message_assist_call)
5798            .run();
5799
5800        // Continuation tests
5801        tst.test("world!\nWhat's up?")
5802            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5803            .enable_thinking(true)
5804            .messages({ message_user, message_assist_prefill_content })
5805            .add_generation_prompt(false)
5806            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5807            .expect_reasoning("I'm thinking")
5808            .expect_content("Hello, world!\nWhat's up?")
5809            .run();
5810
5811        tst.test(" thinking</think>Hello, world!\nWhat's up?")
5812            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5813            .enable_thinking(true)
5814            .messages({ message_user, message_assist_prefill_reasoning })
5815            .add_generation_prompt(false)
5816            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
5817            .expect_reasoning("I'm thinking")
5818            .expect_content("Hello, world!\nWhat's up?")
5819            .run();
5820    }
5821    // DeepSeek R1 Distill Qwen 32B - reasoning tests only (forced open thinking)
5822    // Note: Template uses forced-open mode (prompt ends with <think>), so input shouldn't include opening tag
5823    {
5824        auto tst = peg_tester("models/templates/deepseek-ai-DeepSeek-R1-Distill-Qwen-32B.jinja", detailed_debug);
5825        tst.test("</think>Hello, world!\nWhat's up?").enable_thinking(true).
5826            reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK).
5827            expect(message_assist).run();
5828        tst.test("I'm\nthinking</think>Hello, world!\nWhat's up?")
5829            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5830            .expect(message_assist_thoughts)
5831            .run();
5832        tst.test(
5833               "<|tool▁calls▁begin|><|tool▁call▁begin|>function<|tool▁sep|>special_function\n"
5834               "```json\n{\"arg1\": 1}```<|tool▁call▁end|><|tool▁calls▁end|>")
5835            .tools({ special_function_tool })
5836            .enable_thinking(false)
5837            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5838            .expect(message_assist_call)
5839            .run();
5840
5841        // Continuation tests
5842        tst.test("world!\nWhat's up?")
5843            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5844            .enable_thinking(true)
5845            .messages({ message_user, message_assist_prefill_content })
5846            .add_generation_prompt(false)
5847            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5848            .expect_reasoning("I'm thinking")
5849            .expect_content("Hello, world!\nWhat's up?")
5850            .run();
5851
5852        tst.test(" thinking</think>Hello, world!\nWhat's up?")
5853            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5854            .enable_thinking(true)
5855            .messages({ message_user, message_assist_prefill_reasoning })
5856            .add_generation_prompt(false)
5857            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
5858            .expect_reasoning("I'm thinking")
5859            .expect_content("Hello, world!\nWhat's up?")
5860            .run();
5861    }
5862
5863    // MiMo-VL / Hermes 3 / Qwen 2.5 (Common <tool_call> JSON format)
5864    for (const auto & path :
5865         { "models/templates/MiMo-VL.jinja", "models/templates/NousResearch-Hermes-3-Llama-3.1-8B-tool_use.jinja",
5866           "models/templates/Qwen-Qwen2.5-7B-Instruct.jinja" }) {
5867        auto tst = peg_tester(path, detailed_debug);
5868        tst.test("Hello, world!\nWhat's up?").expect(message_assist).expect_reconstruction().run();
5869        tst.test("<tool_call>\n{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}\n</tool_call>")
5870            .tools({ special_function_tool })
5871            .expect(message_assist_call)
5872            .expect_reconstruction()
5873            .run();
5874
5875        // Continuation tests
5876        tst.test("world!\nWhat's up?")
5877            .messages({ message_user, message_assist_prefill_content })
5878            .add_generation_prompt(false)
5879            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5880            .expect_content("Hello, world!\nWhat's up?")
5881            .run();
5882    }
5883
5884    // Reka Edge
5885    {
5886        auto tst = peg_tester("models/templates/Reka-Edge.jinja", detailed_debug);
5887        tst.test("Hello, world!\nWhat's up?")
5888            .enable_thinking(false)
5889            .expect(message_assist)
5890            .run();
5891        tst.test("I'm\nthinking\n</think>\n\nHello, world!\nWhat's up?")
5892            .enable_thinking(true)
5893            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5894            .expect(message_assist_thoughts)
5895            .run();
5896        tst.test("<tool_call>\n{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}\n</tool_call>")
5897            .enable_thinking(false)
5898            .tools({ special_function_tool })
5899            .expect(message_assist_call)
5900            .run();
5901        tst.test("Hello, world!\nWhat's up?\n<tool_call>\n{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}\n</tool_call>")
5902            .enable_thinking(false)
5903            .tools({ special_function_tool })
5904            .expect(message_assist_call_content)
5905            .run();
5906        tst.test("I'm\nthinking\n</think>\n\n<tool_call>\n{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}\n</tool_call>")
5907            .enable_thinking(true)
5908            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5909            .tools({ special_function_tool })
5910            .expect(message_assist_call_thoughts)
5911            .run();
5912        tst.test("<tool_call>\n{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}\n</tool_call>\n<tool_call>\n{\"name\": \"special_function_with_opt\", \"arguments\": {\"arg1\": 1, \"arg2\": 2}}\n</tool_call>")
5913            .enable_thinking(false)
5914            .parallel_tool_calls(true)
5915            .tools({ special_function_tool, special_function_tool_with_optional_param })
5916            .expect_tool_calls({
5917                { "special_function", R"({"arg1": 1})", {} },
5918                { "special_function_with_opt", R"({"arg1": 1, "arg2": 2})", {} },
5919            })
5920            .run();
5921        tst.test("<tool_call>\n{\"name\": \"special_function\", \"arguments\": {\"arg")
5922            .enable_thinking(false)
5923            .tools({ special_function_tool })
5924            .is_partial(true)
5925            .expect(message_assist_call_cutoff_args)
5926            .run();
5927
5928        // Continuation tests
5929        tst.test("world!\nWhat's up?")
5930            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5931            .enable_thinking(true)
5932            .messages({ message_user, message_assist_prefill_content })
5933            .add_generation_prompt(false)
5934            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5935            .expect_reasoning("I'm thinking")
5936            .expect_content("Hello, world!\nWhat's up?")
5937            .run();
5938
5939        tst.test(" thinking\n</think>\n\nHello, world!\nWhat's up?")
5940            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
5941            .enable_thinking(true)
5942            .messages({ message_user, message_assist_prefill_reasoning })
5943            .add_generation_prompt(false)
5944            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
5945            .expect_reasoning("I'm thinking")
5946            .expect_content("Hello, world!\nWhat's up?")
5947            .run();
5948    }
5949
5950    // Apriel 1.5
5951    {
5952        auto tst = peg_tester("models/templates/unsloth-Apriel-1.5.jinja", detailed_debug);
5953        tst.test("Hello, world!\nWhat's up?").expect(message_assist).run();
5954        tst.test("<tool_calls>[{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}]</tool_calls>")
5955            .tools({ special_function_tool })
5956            .expect(message_assist_call)
5957            .run();
5958
5959        // Continuation tests
5960        tst.test("world!\nWhat's up?")
5961            .messages({ message_user, message_assist_prefill_content })
5962            .add_generation_prompt(false)
5963            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5964            .expect_content("Hello, world!\nWhat's up?")
5965            .run();
5966    }
5967
5968    // Apriel 1.6 Thinker (reasoning-only support)
5969    {
5970        auto tst = peg_tester("models/templates/Apriel-1.6-15b-Thinker-fixed.jinja", detailed_debug);
5971
5972        // Implicit reasoning start (forced open)
5973        tst.test("I'm\nthinking\n[BEGIN FINAL RESPONSE]\nHello, world!\nWhat's up?")
5974            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5975            .enable_thinking(true)
5976            .expect(simple_assist_msg("Hello, world!\nWhat's up?", "Here are my reasoning steps:\nI'm\nthinking"))
5977            .expect_reconstruction()
5978            .run();
5979
5980        // Reasoning + Tool calls
5981        tst.test(
5982               "I'm\nthinking\n[BEGIN FINAL RESPONSE]\n<tool_calls>[{\"name\": \"special_function\", \"arguments\": "
5983               "{\"arg1\": 1}}]</tool_calls>")
5984            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5985            .enable_thinking(true)
5986            .tools({ special_function_tool })
5987            .expect(simple_assist_msg("", "Here are my reasoning steps:\nI'm\nthinking", "special_function", "{\"arg1\":1}"))
5988            .run();
5989
5990        // Continuation tests
5991        tst.test("world!\nWhat's up?")
5992            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
5993            .enable_thinking(true)
5994            .messages({ message_user, message_assist_prefill_content })
5995            .add_generation_prompt(false)
5996            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
5997            .expect_reasoning("I'm thinking")
5998            .expect_content("Hello, world!\nWhat's up?")
5999            .run();
6000
6001        tst.test(" thinking\n[BEGIN FINAL RESPONSE]\nHello, world!\nWhat's up?")
6002            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6003            .enable_thinking(true)
6004            .messages({ message_user, message_assist_prefill_reasoning })
6005            .add_generation_prompt(false)
6006            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
6007            .expect_reasoning("I'm thinking")
6008            .expect_content("Hello, world!\nWhat's up?")
6009            .run();
6010    }
6011
6012    // Mistral Small 3.2 - FUNC_BRACKET_TAG format: [TOOL_CALLS]func_name[CALL_ID]id[ARGS]{...}
6013    {
6014        auto tst = peg_tester("models/templates/Mistral-Small-3.2-24B-Instruct-2506.jinja", detailed_debug);
6015        tst.test("Hello, world!\nWhat's up?").expect(message_assist).expect_reconstruction().run();
6016        tst.test("[TOOL_CALLS]special_function[CALL_ID]123456789[ARGS]{\"arg1\": 1}")
6017            .tools({ special_function_tool })
6018            .expect(message_assist_call_id)
6019            .expect_reconstruction()
6020            .run();
6021
6022        tst.test("[TOOL_CALLS]special_function[CALL_ID]000000001[ARGS]{\"arg1\": 1}"
6023            "[TOOL_CALLS]special_function_with_opt[CALL_ID]000000002[ARGS]{\"arg1\": 1, \"arg2\": 2}")
6024            .parallel_tool_calls(true)
6025            .tools({
6026                special_function_tool, special_function_tool_with_optional_param
6027            })
6028            .expect_tool_calls({
6029                { "special_function", R"({"arg1": 1})", "000000001" },
6030                { "special_function_with_opt", R"({"arg1": 1, "arg2": 2})", "000000002" },
6031            })
6032            .expect_reconstruction()
6033            .run();
6034
6035        // Continuation tests
6036        tst.test("world!\nWhat's up?")
6037            .messages({ message_user, message_assist_prefill_content })
6038            .add_generation_prompt(false)
6039            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
6040            .expect_content("Hello, world!\nWhat's up?")
6041            .run();
6042    }
6043    // Devstral
6044    {
6045        auto tst = peg_tester("models/templates/unsloth-mistral-Devstral-Small-2507.jinja", detailed_debug);
6046        tst.test("Hello, world!\nWhat's up?").expect(message_assist).expect_reconstruction().run();
6047        tst.test("[TOOL_CALLS]special_function[ARGS]{\"arg1\": 1}")
6048            .tools({ special_function_tool })
6049            .expect(message_assist_call)
6050            .expect_reconstruction()
6051            .run();
6052        tst.test("Hello, world!\nWhat's up?[TOOL_CALLS]special_function[ARGS]{\"arg1\": 1}")
6053            .tools({ special_function_tool })
6054            .expect(message_assist_call_content)
6055            .expect_reconstruction()
6056            .run();
6057    }
6058
6059    {
6060        // Llama 3.1
6061        auto tst = peg_tester("models/templates/meta-llama-Llama-3.1-8B-Instruct.jinja", detailed_debug);
6062        tst.test("Hello, world!\nWhat's up?").tools({ special_function_tool }).expect(message_assist).expect_reconstruction().run();
6063
6064        tst.test(
6065             "```json\n\"42\"\n```")
6066            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6067            .json_schema(const_schema)
6068            .expect_content(R"("42")")
6069            .run();
6070
6071        tst.test(
6072             "\"42\"\n")
6073            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6074            .json_schema(const_schema)
6075            .expect_content(R"("42")")
6076            .run();
6077
6078        // Continuation tests
6079        tst.test("world!\nWhat's up?")
6080            .messages({ message_user, message_assist_prefill_content })
6081            .add_generation_prompt(false)
6082            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
6083            .expect_content("Hello, world!\nWhat's up?")
6084            .run();
6085    }
6086
6087    {
6088        // Llama 3.2
6089        auto tst = peg_tester("models/templates/meta-llama-Llama-3.2-3B-Instruct.jinja", detailed_debug);
6090        tst.test("Hello, world!\nWhat's up?").tools({ special_function_tool }).expect(message_assist).expect_reconstruction().run();
6091
6092        // Continuation tests
6093        tst.test("world!\nWhat's up?")
6094            .messages({ message_user, message_assist_prefill_content })
6095            .add_generation_prompt(false)
6096            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
6097            .expect_content("Hello, world!\nWhat's up?")
6098            .run();
6099    }
6100
6101    {
6102        // Llama 3.3
6103        auto tst = peg_tester("models/templates/meta-llama-Llama-3.3-70B-Instruct.jinja", detailed_debug);
6104        tst.test("Hello, world!\nWhat's up?").tools({ python_tool }).expect(message_assist).expect_reconstruction().run();
6105
6106        // Continuation tests
6107        tst.test("world!\nWhat's up?")
6108            .messages({ message_user, message_assist_prefill_content })
6109            .add_generation_prompt(false)
6110            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
6111            .expect_content("Hello, world!\nWhat's up?")
6112            .run();
6113    }
6114
6115    // Muse Glimmer format tests
6116    {
6117        auto tst = peg_tester("models/templates/muse-glimmer.jinja", detailed_debug);
6118
6119        const std::string call_markup =
6120            "<atem:function_calls>\n"
6121            "<atem:invoke name=\"special_function\">\n"
6122            "<atem:parameter name=\"arg1\">1</atem:parameter>\n"
6123            "</atem:invoke>\n"
6124            "</atem:function_calls>";
6125
6126        // A plain answer is unaffected
6127        tst.test(" to=user<|message|>Hello, world!\nWhat's up?<|eot|>")
6128            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6129            .expect(message_assist)
6130            .run();
6131
6132        // "Inform then act": the model answers the user and calls a tool in ONE generation,
6133        // closing the answer with <|eom|>. The answer must stop there rather than swallow it.
6134        tst.test(" to=user<|message|>Hello, world!\nWhat's up?<|eom|>"
6135                 "<|start|>assistant to=special_function<|message|>" +
6136                 call_markup)
6137            .tools({ special_function_tool })
6138            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6139            .expect(message_with_content_and_tool_call("Hello, world!\nWhat's up?", "special_function",
6140                                                       "{\"arg1\":1}"))
6141            .run();
6142
6143        // Markup quoted in an answer has no preceding <|eom|>, so it stays content instead of
6144        // becoming an invocation the user never asked for
6145        tst.test(" to=user<|message|>You invoke it like this:\n" + call_markup + "<|eot|>")
6146            .tools({ special_function_tool })
6147            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6148            .expect_content("You invoke it like this:\n" + call_markup)
6149            .run();
6150
6151        // Tool markup inside the analysis channel is reasoning, not a call
6152        tst.test(" to=self<|message|>I could use " + call_markup + " here<|eom|>"
6153                 "<|start|>assistant to=user<|message|>Hello!<|eot|>")
6154            .tools({ special_function_tool })
6155            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6156            .expect_reasoning("I could use " + call_markup + " here")
6157            .expect_content("Hello!")
6158            .run();
6159    }
6160
6161    // GPT-OSS format tests
6162    {
6163        auto tst = peg_tester("models/templates/openai-gpt-oss-120b.jinja", detailed_debug);
6164
6165        // Basic content only - final channel
6166        tst.test("<|channel|>final<|message|>Hello, world!\nWhat's up?").expect(message_assist).run();
6167
6168        // Basic content only - commentary channel
6169        tst.test("<|channel|>commentary<|message|>Hello, world!\nWhat's up?").expect(message_assist).run();
6170
6171        // Analysis channel (reasoning) with final channel (content)
6172        tst.test(
6173               "<|channel|>analysis<|message|>I'm\nthinking<|end|><|start|>assistant<|channel|>final<|message|>Hello, world!\nWhat's "
6174               "up?")
6175            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6176            .expect(message_assist_thoughts)
6177            .run();
6178
6179        // Analysis channel (reasoning) with final channel (content) with reasoning_format = none
6180        tst.test(
6181               "<|channel|>analysis<|message|>I'm\nthinking<|end|><|start|>assistant<|channel|>final<|message|>Hello, world!\nWhat's "
6182               "up?")
6183            .reasoning_format(COMMON_REASONING_FORMAT_NONE)
6184            .expect_content("<|channel|>analysis<|message|>I'm\nthinking<|end|>Hello, world!\nWhat's up?")
6185            .run();
6186
6187        // Analysis channel only (partial) - still works when reasoning format is set
6188        tst.test("<|channel|>analysis<|message|>I'm\nthinking")
6189            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6190            .is_partial(true)
6191            .expect_reasoning("I'm\nthinking")
6192            .run();
6193
6194        // Tool call with recipient in role header: " to=functions.NAME<|channel|>analysis<|message|>JSON"
6195        tst.test(" to=functions.special_function<|channel|>analysis<|message|>{\"arg1\": 1}")
6196            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6197            .tools({ special_function_tool })
6198            .expect(message_assist_call)
6199            .run();
6200
6201        // Tool call with recipient in channel header: "<|channel|>analysis to=functions.NAME<|message|>JSON"
6202        tst.test("<|channel|>analysis to=functions.special_function<|message|>{\"arg1\": 1}")
6203            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6204            .tools({ special_function_tool })
6205            .expect(message_assist_call)
6206            .run();
6207
6208        // Tool call with constraint: " to=functions.NAME<|channel|>analysis <|constrain|>json<|message|>JSON"
6209        tst.test(" to=functions.special_function<|channel|>analysis <|constrain|>json<|message|>{\"arg1\": 1}")
6210            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6211            .tools({ special_function_tool })
6212            .expect(message_assist_call)
6213            .run();
6214
6215        // Tool call in commentary channel (channel header variant)
6216        tst.test("<|channel|>commentary to=functions.special_function<|message|>{\"arg1\": 1}")
6217            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6218            .tools({ special_function_tool })
6219            .expect(message_assist_call)
6220            .run();
6221
6222        // Tool call with reasoning + content (analysis first, then tool call)
6223        tst.test(
6224               "<|channel|>analysis<|message|>I'm\nthinking<|end|>"
6225               "<|start|>assistant to=functions.special_function<|channel|>analysis<|message|>{\"arg1\": 1}")
6226            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6227            .tools({ special_function_tool })
6228            .expect(message_assist_call_thoughts)
6229            .run();
6230
6231        // Complex tool calling
6232        tst.test(
6233            "<|channel|>analysis<|message|>Thinking about edit...<|end|>"
6234            "<|start|>assistant<|channel|>commentary to=functions.edit <|constrain|>json"
6235            "<|message|>{\"oldString\": \"if (part < railCount - 1) {\", \"newString\": \"if (part < 4) {\", \"replaceAll\": false}"
6236            )
6237            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6238            .tools({
6239                {
6240                    /* .name = */ "edit",
6241                    /* .description = */ "Edit a file",
6242                    /* .parameters = */ R"({
6243                        "type": "object",
6244                        "properties": {
6245                            "oldString": {
6246                                "type": "string",
6247                                "description": "Old string to replace."
6248                            },
6249                            "newString": {
6250                                "type": "string",
6251                                "description": "New replacement string."
6252                            },
6253                            "replaceAll": {
6254                                "type": "boolean",
6255                                "description": "Whether to replace all occurrences."
6256                            }
6257                        },
6258                        "required": ["oldString", "newString"]
6259                    })",
6260                }
6261            })
6262            .expect_reasoning("Thinking about edit...")
6263            .expect_tool_calls({
6264                { "edit", R"({"oldString": "if (part < railCount - 1) {", "newString": "if (part < 4) {", "replaceAll": false})", {} }
6265            })
6266            .run();
6267
6268        // Structured output
6269        tst.test(
6270            "<|channel|>analysis<|message|>I need to output the invoice details in JSON<|end|>"
6271            "<|start|>assistant<|channel|>final <|constrain|>json"
6272            "<|message|>"
6273            R"({"amount": 123.45, "date": "2025-12-03"})"
6274            )
6275            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6276            .json_schema(invoice_schema)
6277            .expect_reasoning("I need to output the invoice details in JSON")
6278            .expect_content(R"({"amount": 123.45, "date": "2025-12-03"})")
6279            .run();
6280
6281
6282        // Unsolicited tool calls. There is no good way to handle these, so we return empty content.
6283
6284        // Builtin function - recipient in role
6285        tst.test(
6286               "<|channel|>analysis<|message|>I will execute python to say hello<|end|>"
6287               "<|start|>assistant to=container.exec<|channel|>commentary<|message|>python3 -c 'print(\"hello\")'")
6288            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6289            .expect_reasoning("I will execute python to say hello")
6290            .expect_content("")
6291            .run();
6292
6293        // Builtin function - recipient in channel
6294        tst.test(
6295               "<|channel|>analysis<|message|>I will execute python to say hello<|end|>"
6296               "<|start|>assistant<|channel|>commentary to=python <|constrain|>code<|message|>print(\"hello\")")
6297            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6298            .expect_reasoning("I will execute python to say hello")
6299            .expect_content("")
6300            .run();
6301
6302        // Edge cases
6303
6304        // "<|channel|>commentary to=assistant" before reasoning
6305        tst.test(
6306               "<|channel|>commentary to=assistant<|channel|>analysis<|message|>I'm\nthinking<|end|><|start|>assistant<|channel|>final<|message|>Hello, world!\nWhat's "
6307               "up?")
6308            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6309            .expect(message_assist_thoughts)
6310            .run();
6311
6312        // "<|channel|>commentary to=assistant" before final message
6313        tst.test(
6314               "<|channel|>analysis<|message|>I'm\nthinking<|end|><|start|>assistant<|channel|>commentary to=assistant<|channel|>final<|message|>Hello, world!\nWhat's "
6315               "up?")
6316            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6317            .expect(message_assist_thoughts)
6318            .run();
6319
6320        // Continuation tests
6321        tst.test("world!\nWhat's up?")
6322            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6323            .enable_thinking(true)
6324            .messages({ message_user, message_assist_prefill_content })
6325            .add_generation_prompt(false)
6326            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
6327            .expect_reasoning("I'm thinking")
6328            .expect_content("Hello, world!\nWhat's up?")
6329            .run();
6330
6331        tst.test(" thinking<|end|><|start|>assistant<|channel|>final<|message|>Hello, world!\nWhat's up?")
6332            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6333            .enable_thinking(true)
6334            .messages({ message_user, message_assist_prefill_reasoning })
6335            .add_generation_prompt(false)
6336            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
6337            .expect_reasoning("I'm thinking")
6338            .expect_content("Hello, world!\nWhat's up?")
6339            .run();
6340    }
6341
6342    {
6343        auto tst = peg_tester("models/templates/StepFun3.5-Flash.jinja", detailed_debug);
6344
6345        tst.test("I was thinking\n</think>\nNow I'm not.").
6346            enable_thinking(true).
6347            reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK).
6348            expect_reasoning("I was thinking").
6349            expect_content("Now I'm not.")
6350        .run();
6351
6352        // Test that numeric-looking string values are coerced to strings per the schema
6353        tst.test(
6354               "Let me call the magic tool\n"
6355               "</think>\n"
6356               "<tool_call>\n"
6357               "<function=magic>\n"
6358               "<parameter=name>\nfooBar\n</parameter>\n"
6359               "<parameter=ref>\n5123123\n</parameter>\n"
6360               "</function>\n"
6361               "</tool_call>")
6362            .enable_thinking(true)
6363            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
6364            .tools({ magic_tool })
6365            .expect_reasoning("Let me call the magic tool")
6366            .expect_tool_calls({
6367                { "magic", R"({"name": "fooBar", "ref": "5123123"})", {} },
6368            })
6369            .run();
6370
6371        // Test that numeric values are correctly interpreted as numbers when schema calls for number
6372        tst.test(
6373               "Let me call the special function\n"
6374               "</think>\n"
6375               "<tool_call>\n"
6376               "<function=special_function>\n"
6377               "<parameter=arg1>\n42555916\n</parameter>\n"
6378               "</function>\n"
6379               "</tool_call>")
6380            .enable_thinking(true)
6381            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
6382            .tools({ special_function_tool })
6383            .expect_reasoning("Let me call the special function")
6384            .expect_tool_calls({
6385                { "special_function", R"({"arg1": 42555916})", {} },
6386            })
6387            .run();
6388
6389        tst.test(
6390               "Let me call the special function with opt\n"
6391               "</think>\n"
6392               "<tool_call>\n"
6393               "<function=special_function_with_opt>\n"
6394               "<parameter=arg1>\n42555916\n</parameter>\n"
6395               "</function>\n"
6396               "</tool_call>")
6397            .enable_thinking(true)
6398            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
6399            .tools({ special_function_tool_with_optional_param })
6400            .expect_reasoning("Let me call the special function with opt")
6401            .expect_tool_calls({
6402                { "special_function_with_opt", R"({"arg1": 42555916})", {} },
6403            })
6404            .run();
6405
6406        tst.test(
6407               "Let me call the magic_int function\n"
6408               "</think>\n"
6409               "<tool_call>\n"
6410               "<function=magic_int>\n"
6411               "<parameter=ref>\n42555916\n</parameter>\n"
6412               "<parameter=name>\nbaz\n</parameter>\n"
6413               "</function>\n"
6414               "</tool_call>")
6415            .enable_thinking(true)
6416            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
6417            .tools({ magic_int_tool })
6418            .expect_reasoning("Let me call the magic_int function")
6419            .expect_tool_calls({
6420                { "magic_int", R"({"ref": 42555916, "name": "baz"})", {} },
6421            })
6422            .run();
6423
6424        tst.test(
6425               "Call string_param with empty text\n"
6426               "</think>\n"
6427               "<tool_call>\n"
6428               "<function=string_param>\n"
6429               "<parameter=text>\n\n</parameter>\n"
6430               "</function>\n"
6431               "</tool_call>")
6432            .enable_thinking(true)
6433            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
6434            .tools({ string_param_tool })
6435            .expect_reasoning("Call string_param with empty text")
6436            .expect_tool_calls({
6437                { "string_param", R"({"text": ""})", {} },
6438            })
6439            .run();
6440
6441        tst.test(
6442               "Test simple quoted unquoted\n"
6443               "</think>\n"
6444               "<tool_call>\n"
6445               "<function=quoted_unquoted>\n"
6446               "<parameter=quoted>\n\"foo\"\n</parameter>\n"
6447               "<parameter=unquoted>\nfoo\n</parameter>\n"
6448               "</function>\n"
6449               "</tool_call>")
6450            .enable_thinking(true)
6451            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
6452            .tools({ quoted_unquoted_tool })
6453            .expect_reasoning("Test simple quoted unquoted")
6454            .expect_tool_calls({
6455                { "quoted_unquoted", R"({"quoted": "\"foo\"", "unquoted": "foo"})", {} },
6456            })
6457            .run();
6458
6459        tst.test(
6460               "Test complex quoted unquoted\n"
6461               "</think>\n"
6462               "<tool_call>\n"
6463               "<function=quoted_unquoted>\n"
6464               "<parameter=quoted>\n\"printf(\\\"foo\\\");\"\n</parameter>\n"
6465               "<parameter=unquoted>\nprintf(\"foo\");\n</parameter>\n"
6466               "</function>\n"
6467               "</tool_call>")
6468            .enable_thinking(true)
6469            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
6470            .tools({ quoted_unquoted_tool })
6471            .expect_reasoning("Test complex quoted unquoted")
6472            .expect_tool_calls({
6473                { "quoted_unquoted", R"({ "quoted" : "\"printf(\\\"foo\\\");\"", "unquoted": "printf(\"foo\");" })", {} }
6474            })
6475            .run();
6476
6477            tst.test(
6478               "Test negative number\n"
6479               "</think>\n"
6480               "<tool_call>\n"
6481               "<function=magic_int>\n"
6482               "<parameter=ref>\n-14\n</parameter>\n"
6483               "</function>\n"
6484               "</tool_call>")
6485            .enable_thinking(true)
6486            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
6487            .tools({ magic_int_tool })
6488            .expect_reasoning("Test negative number")
6489            .expect_tool_calls({
6490                { "magic_int", R"({ "ref" : -14 })", {} }
6491            })
6492            .run();
6493
6494            tst.test(
6495               "Test decimal number\n"
6496               "</think>\n"
6497               "<tool_call>\n"
6498               "<function=amount>\n"
6499               "<parameter=orig>\n3.14\n</parameter>\n"
6500               "</function>\n"
6501               "</tool_call>")
6502            .enable_thinking(true)
6503            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
6504            .tools({ amount_tool })
6505            .expect_reasoning("Test decimal number")
6506            .expect_tool_calls({
6507                { "amount", R"({ "orig" : 3.14 })", {} }
6508            })
6509            .run();
6510
6511            tst.test(
6512               "Test imaginary number\n"
6513               "</think>\n"
6514               "<tool_call>\n"
6515               "<function=imaginary_number>\n"
6516               "<parameter=number>\n"
6517               "{ \"real\": 3.14, \"imaginary\": 2.71 }\n"
6518               "</parameter>\n"
6519               "</function>\n"
6520               "</tool_call>")
6521            .enable_thinking(true)
6522            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
6523            .tools({ imaginary_number_tool })
6524            .expect_reasoning("Test imaginary number")
6525            .expect_tool_calls({
6526                { "imaginary_number", R"({ "number" : {"real":3.14,"imaginary":2.71 } })", {} }
6527            })
6528            .run();
6529
6530        // Continuation tests
6531        tst.test("world!\nWhat's up?")
6532            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
6533            .enable_thinking(true)
6534            .messages({ message_user, message_assist_prefill_content })
6535            .add_generation_prompt(false)
6536            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
6537            .expect_reasoning("I'm thinking")
6538            .expect_content("Hello, world!\nWhat's up?")
6539            .run();
6540
6541        tst.test(" thinking\n</think>\nHello, world!\nWhat's up?")
6542            .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK)
6543            .enable_thinking(true)
6544            .messages({ message_user, message_assist_prefill_reasoning })
6545            .add_generation_prompt(false)
6546            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
6547            .expect_reasoning("I'm thinking")
6548            .expect_content("Hello, world!\nWhat's up?")
6549            .run();
6550    }
6551
6552    // GigaChat V3
6553    {
6554        auto tst = peg_tester("models/templates/GigaChat3-10B-A1.8B.jinja", detailed_debug);
6555        tst.test("Hello, world!\nWhat's up?").expect(message_assist).expect_reconstruction().run();
6556        tst.test("<|message_sep|>\n\nfunction call<|role_sep|>\n{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}")
6557            .tools({ special_function_tool })
6558            .expect(message_assist_call)
6559            .expect_reconstruction()
6560            .run();
6561
6562        tst.test(
6563            "Hello, world!\nWhat's up?"
6564            "<|message_sep|>\n\nfunction call<|role_sep|>\n{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}"
6565        )
6566            .tools({ special_function_tool })
6567            .expect(message_assist_call_content)
6568            .expect_reconstruction()
6569            .run();
6570
6571        // Continuation tests
6572        tst.test("world!\nWhat's up?")
6573            .messages({ message_user, message_assist_prefill_content })
6574            .add_generation_prompt(false)
6575            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
6576            .expect_content("Hello, world!\nWhat's up?")
6577            .run();
6578    }
6579
6580    // GigaChat V3.1
6581    {
6582        auto tst = peg_tester("models/templates/GigaChat3.1-10B-A1.8B.jinja", detailed_debug);
6583        tst.test("Hello, world!\nWhat's up?").expect(message_assist).expect_reconstruction().run();
6584        tst.test("<|function_call|>{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}")
6585            .tools({ special_function_tool })
6586            .expect(message_assist_call)
6587            .expect_reconstruction()
6588            .run();
6589
6590        tst.test(
6591            "Hello, world!\nWhat's up?"
6592            "<|function_call|>{\"name\": \"special_function\", \"arguments\": {\"arg1\": 1}}"
6593        )
6594            .tools({ special_function_tool })
6595            .expect(message_assist_call_content)
6596            .expect_reconstruction()
6597            .run();
6598
6599        // Continuation tests
6600        tst.test("world!\nWhat's up?")
6601            .messages({ message_user, message_assist_prefill_content })
6602            .add_generation_prompt(false)
6603            .continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
6604            .expect_content("Hello, world!\nWhat's up?")
6605            .run();
6606    }
6607
6608    // MiniCPM5 - XML tool calls with <function name="..."><param name="...">...</param></function>
6609    {
6610        auto tst = peg_tester("models/templates/openbmb-MiniCPM5-1B.jinja", detailed_debug);
6611
6612        tst.test("Hello, world!\nWhat's up?")
6613            .enable_thinking(false)
6614            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6615            .expect(message_assist)
6616            .run();
6617
6618        tst.test(R"(<function name="python"><param name="code">print('Hello, World!')</param></function>)")
6619            .enable_thinking(false)
6620            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6621            .tools({ python_tool })
6622            .expect_tool_calls({ { "python", R"#({"code": "print('Hello, World!')"})#", {} } })
6623            .run();
6624
6625        tst.test(R"(<function name="empty_args"></function>)")
6626            .enable_thinking(false)
6627            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6628            .tools({ empty_args_tool })
6629            .expect(simple_assist_msg("", "", "empty_args", "{}"))
6630            .run();
6631
6632        tst.test(R"(<function name="python"><param name="code">print('x')</param></function>)")
6633            .enable_thinking(false)
6634            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6635            .parallel_tool_calls(true)
6636            .tools({ python_tool })
6637            .expect_tool_calls({ { "python", R"#({"code": "print('x')"})#", {} } })
6638            .run();
6639
6640        // CDATA lets a string value carry characters that would otherwise close the tag.
6641        tst.test(R"(<function name="html"><param name="markup"><![CDATA[<a href="/x">hi</a> </param>]]></param></function>)")
6642            .enable_thinking(false)
6643            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6644            .tools({ html_tool })
6645            .expect_tool_calls({ { "html", R"#({"markup": "<a href=\"/x\">hi</a> </param>"})#", {} } })
6646            .run();
6647
6648        tst.test(R"(I'm thinking</think><function name="python"><param name="code">print('hey')</param></function>)")
6649            .enable_thinking(true)
6650            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6651            .tools({ python_tool })
6652            .expect_reasoning("I'm thinking")
6653            .expect_tool_calls({ { "python", R"#({"code": "print('hey')"})#", {} } })
6654            .run();
6655
6656        tst.test(R"(<function name="python"><param name="code">print('x')</param></function>
6657<function name="python"><param name="code">print('y')</param></function>)")
6658            .enable_thinking(false)
6659            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6660            .parallel_tool_calls(true)
6661            .tools({ python_tool })
6662            .expect_tool_calls({
6663                { "python", R"#({"code": "print('x')"})#", {} },
6664                { "python", R"#({"code": "print('y')"})#", {} },
6665            })
6666            .run();
6667
6668        tst.test(" thinking</think>Hello, world!\nWhat's up?")
6669            .enable_thinking(true)
6670            .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
6671            .messages({ message_user, message_assist_prefill_reasoning })
6672            .add_generation_prompt(false)
6673            .continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
6674            .expect_reasoning("I'm thinking")
6675            .expect_content("Hello, world!\nWhat's up?")
6676            .run();
6677    }
6678}
6679
6680static void test_template_generation_prompt() {
6681    common_chat_msg system_msg;
6682    system_msg.role = "system";
6683    system_msg.content ="You are a helpful assistant.";
6684
6685    common_chat_msg tool_call_msg = simple_assist_msg("", "", "special_function", "{\"arg1\": 1}");
6686
6687    common_chat_msg tool_msg;
6688    tool_msg.role = "tool";
6689    tool_msg.tool_name = "special_function";
6690    tool_msg.tool_call_id = "call0";
6691    tool_msg.content = "Sunny";
6692
6693    struct test_case_options {
6694        std::vector<common_chat_msg> messages;
6695        bool                         add_generation_prompt  = true;
6696        common_chat_continuation     continue_final_message = COMMON_CHAT_CONTINUATION_NONE;
6697        bool                         enable_thinking        = true;
6698    };
6699
6700    auto basic = [&]() {
6701        test_case_options opts;
6702        opts.messages = { system_msg, message_user };
6703        return opts;
6704    };
6705
6706    auto continuation_content = [&]() {
6707        test_case_options opts;
6708        opts.messages               = { system_msg, message_user, message_assist_prefill_content };
6709        opts.add_generation_prompt  = false;
6710        opts.continue_final_message = COMMON_CHAT_CONTINUATION_CONTENT;
6711        return opts;
6712    };
6713
6714    auto continuation_reasoning = [&]() {
6715        test_case_options opts;
6716        opts.messages               = { system_msg, message_user, message_assist_prefill_reasoning };
6717        opts.add_generation_prompt  = false;
6718        opts.continue_final_message = COMMON_CHAT_CONTINUATION_REASONING;
6719        return opts;
6720    };
6721
6722    auto check = [&](const common_chat_templates_ptr & tmpls,
6723                     const test_case_options & opts,
6724                     const std::string & expected_generation_prompt) {
6725        common_chat_templates_inputs inputs;
6726        inputs.messages               = opts.messages;
6727        inputs.add_generation_prompt  = opts.add_generation_prompt;
6728        inputs.continue_final_message = opts.continue_final_message;
6729        inputs.enable_thinking        = opts.enable_thinking;
6730
6731        auto params = common_chat_templates_apply(tmpls.get(), inputs);
6732
6733        assert_contains(params.prompt, system_msg.content);
6734        assert_contains(params.prompt, message_user.content);
6735        assert_equals(expected_generation_prompt, params.generation_prompt);
6736        assert_ends_with(params.prompt, expected_generation_prompt);
6737    };
6738
6739    {
6740        auto tmpls = read_templates("models/templates/Qwen3.5-4B.jinja");
6741        check(tmpls, basic(),                  "<|im_start|>assistant\n<think>\n");
6742        check(tmpls, continuation_content(),   "<|im_start|>assistant\n<think>\nI'm thinking\n</think>\n\nHello, ");
6743        check(tmpls, continuation_reasoning(), "<|im_start|>assistant\n<think>\nI'm");
6744    }
6745
6746    {
6747        auto tmpls = read_templates("models/templates/openai-gpt-oss-120b.jinja");
6748        check(tmpls, basic(),                  "<|start|>assistant");
6749        check(tmpls, continuation_content(),   "<|start|>assistant<|channel|>analysis<|message|>I'm thinking<|end|><|start|>assistant<|channel|>final<|message|>Hello, ");
6750        check(tmpls, continuation_reasoning(), "<|start|>assistant<|channel|>analysis<|message|>I'm");
6751    }
6752
6753    {
6754        auto tmpls = read_templates("models/templates/mistralai-Ministral-3-14B-Reasoning-2512.jinja");
6755        check(tmpls, basic(),                  "");
6756        check(tmpls, continuation_content(),   "[THINK]I'm thinking[/THINK]Hello, ");
6757        check(tmpls, continuation_reasoning(), "[THINK]I'm");
6758    }
6759
6760    {
6761        auto tmpls = read_templates("models/templates/google-gemma-4-31B-it.jinja");
6762        check(tmpls, basic(),                  "<|turn>model\n");
6763        check(tmpls, continuation_content(),   "<|turn>model\n<|channel>thought\nI'm thinking<channel|>Hello, ");
6764        check(tmpls, continuation_reasoning(), "<|turn>model\n<|channel>thought\nI'm");
6765
6766        // Special case when last message is a tool response
6767        test_case_options after_tool_call = continuation_reasoning();
6768        after_tool_call.messages          = { system_msg, message_user, tool_call_msg, tool_msg, message_assist_prefill_reasoning };
6769        check(tmpls, after_tool_call, "<|channel>thought\nI'm");
6770    }
6771
6772    {
6773        auto tmpls = read_templates("models/templates/meetkai-functionary-medium-v3.2.jinja");
6774        check(tmpls, basic(),                  "<|start_header_id|>assistant<|end_header_id|>\n\n>>>");
6775        check(tmpls, continuation_content(),   "<|start_header_id|>assistant<|end_header_id|>\n\n>>>all\nHello, ");
6776        check(tmpls, continuation_reasoning(), "<|start_header_id|>assistant<|end_header_id|>\n\n>>>all\n");
6777    }
6778
6779    {
6780        auto tmpls = read_templates("models/templates/Reka-Edge.jinja");
6781        check(tmpls, basic(),                  "assistant: <think>\n");
6782        check(tmpls, continuation_content(),   "assistant: <think>\nI'm thinking\n</think>\n\nHello, ");
6783        check(tmpls, continuation_reasoning(), "assistant: <think>\nI'm");
6784    }
6785
6786    {
6787        auto tmpls = read_templates("models/templates/moonshotai-Kimi-K2.jinja");
6788        check(tmpls, basic(),                  "<|im_assistant|>assistant<|im_middle|>");
6789        check(tmpls, continuation_content(),   "<|im_assistant|>assistant<|im_middle|><think>I'm thinking</think>Hello, ");
6790        check(tmpls, continuation_reasoning(), "<|im_assistant|>assistant<|im_middle|><think>I'm");
6791    }
6792
6793    for (const char * tmpl : {
6794             "models/templates/LFM2-8B-A1B.jinja",
6795             "models/templates/LFM2.5-Instruct.jinja",
6796             "models/templates/LFM2.5-8B-A1B.jinja",
6797         }) {
6798        auto tmpls = read_templates(tmpl);
6799        check(tmpls, basic(),                  "<|im_start|>assistant\n");
6800        check(tmpls, continuation_content(),   "<|im_start|>assistant\n<think>I'm thinking</think>Hello, ");
6801        check(tmpls, continuation_reasoning(), "<|im_start|>assistant\n<think>I'm");
6802    }
6803
6804    {
6805        // 8B-A1B renders prior-turn reasoning via the "thinking" field
6806        auto tmpls = read_templates("models/templates/LFM2.5-8B-A1B.jinja");
6807        common_chat_templates_inputs inputs;
6808        inputs.messages              = { message_user, message_assist_call_thoughts, tool_msg };
6809        inputs.add_generation_prompt = true;
6810        auto params = common_chat_templates_apply(tmpls.get(), inputs);
6811        assert_contains(params.prompt, "<think>I'm\nthinking</think>");
6812    }
6813
6814    {
6815        auto tmpls = read_templates("models/templates/GigaChat3-10B-A1.8B.jinja");
6816        check(tmpls, basic(),                  "assistant<|role_sep|>\n");
6817        check(tmpls, continuation_content(),   "assistant<|role_sep|>\nHello, ");
6818        check(tmpls, continuation_reasoning(), "assistant<|role_sep|>\n");
6819    }
6820
6821    {
6822        auto tmpls = read_templates("models/templates/deepseek-ai-DeepSeek-V3.2.jinja");
6823        check(tmpls, basic(),                  "<|Assistant|><think>");
6824        check(tmpls, continuation_content(),   "<|Assistant|><think>I'm thinking</think>Hello, ");
6825        check(tmpls, continuation_reasoning(), "<|Assistant|><think>I'm");
6826    }
6827
6828    const std::string deepseek_v4_reasoning_effort_max = "Reasoning Effort: Absolute maximum";
6829    const std::string deepseek_v4_flash_0731_reasoning_effort_max = "Reasoning Effort: Beyond maximum";
6830
6831    {
6832        auto tmpls = read_templates("models/templates/deepseek-ai-DeepSeek-V4.jinja");
6833        check(tmpls, basic(),                  "<|Assistant|><think>");
6834        check(tmpls, continuation_content(),   "<|Assistant|><think>I'm thinking</think>Hello, ");
6835        check(tmpls, continuation_reasoning(), "<|Assistant|><think>I'm");
6836
6837        auto continuation_content_no_thinking = continuation_content();
6838        continuation_content_no_thinking.messages = { system_msg, message_user, simple_assist_msg("Hello, ") };
6839        continuation_content_no_thinking.enable_thinking = false;
6840        check(tmpls, continuation_content_no_thinking, "<|Assistant|></think>Hello, ");
6841
6842        common_chat_templates_inputs max_inputs;
6843        max_inputs.messages = { system_msg, message_user };
6844        max_inputs.chat_template_kwargs["reasoning_effort"] = R"("max")";
6845        auto max_params = common_chat_templates_apply(tmpls.get(), max_inputs);
6846        assert_contains(max_params.prompt, deepseek_v4_reasoning_effort_max);
6847
6848        auto high_inputs = max_inputs;
6849        high_inputs.chat_template_kwargs["reasoning_effort"] = R"("high")";
6850        auto high_params = common_chat_templates_apply(tmpls.get(), high_inputs);
6851        assert_not_contains(high_params.prompt, deepseek_v4_reasoning_effort_max);
6852
6853        auto low_inputs = max_inputs;
6854        low_inputs.chat_template_kwargs["reasoning_effort"] = R"("low")";
6855        auto low_params = common_chat_templates_apply(tmpls.get(), low_inputs);
6856        assert_not_contains(low_params.prompt, deepseek_v4_reasoning_effort_max);
6857
6858        common_chat_templates_inputs default_effort_inputs;
6859        default_effort_inputs.messages = { system_msg, message_user };
6860        auto default_effort_params = common_chat_templates_apply(tmpls.get(), default_effort_inputs);
6861        assert_not_contains(default_effort_params.prompt, deepseek_v4_reasoning_effort_max);
6862
6863        auto non_thinking_max_inputs = max_inputs;
6864        non_thinking_max_inputs.enable_thinking = false;
6865        auto non_thinking_max_params = common_chat_templates_apply(tmpls.get(), non_thinking_max_inputs);
6866        assert_not_contains(non_thinking_max_params.prompt, deepseek_v4_reasoning_effort_max);
6867
6868        common_chat_templates_inputs response_format_inputs;
6869        response_format_inputs.messages = { system_msg, message_user };
6870        response_format_inputs.tools = { get_time_tool };
6871        response_format_inputs.json_schema =
6872            R"({"type":"object","properties":{"answer":{"type":"string"}}})";
6873        auto response_format_params = common_chat_templates_apply(tmpls.get(), response_format_inputs);
6874        const auto tools_pos = response_format_params.prompt.find("## Tools");
6875        const auto response_format_pos = response_format_params.prompt.find(
6876            "## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n");
6877        if (tools_pos == std::string::npos || response_format_pos == std::string::npos || tools_pos > response_format_pos) {
6878            LOG_ERR("Expected response format after tools\nActual: %s\n", response_format_params.prompt.c_str());
6879            common_log_flush(common_log_main());
6880            throw std::runtime_error("Test failed");
6881        }
6882        assert_contains(response_format_params.prompt, R"("answer": {"type": "string"})");
6883
6884        response_format_inputs.json_schema = "{}";
6885        auto json_object_params = common_chat_templates_apply(tmpls.get(), response_format_inputs);
6886        assert_contains(json_object_params.prompt,
6887                        "## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n{}");
6888
6889        common_chat_msg assistant_history;
6890        assistant_history.role              = "assistant";
6891        assistant_history.content           = "Previous answer";
6892        assistant_history.reasoning_content = "Previous reasoning";
6893
6894        common_chat_msg user_followup;
6895        user_followup.role    = "user";
6896        user_followup.content = "Follow up";
6897
6898        common_chat_templates_inputs default_history_inputs;
6899        default_history_inputs.messages = { message_user, assistant_history, user_followup };
6900        auto default_history_params = common_chat_templates_apply(tmpls.get(), default_history_inputs);
6901        assert_contains(default_history_params.prompt, "<|Assistant|></think>Previous answer");
6902
6903        auto drop_thinking_inputs = default_history_inputs;
6904        drop_thinking_inputs.chat_template_kwargs["drop_thinking"] = "false";
6905        auto drop_thinking_params = common_chat_templates_apply(tmpls.get(), drop_thinking_inputs);
6906        assert_contains(drop_thinking_params.prompt, "<|Assistant|><think>Previous reasoning</think>Previous answer");
6907
6908        auto preserve_reasoning_inputs = default_history_inputs;
6909        preserve_reasoning_inputs.chat_template_kwargs["preserve_reasoning"] = "true";
6910        auto preserve_reasoning_params = common_chat_templates_apply(tmpls.get(), preserve_reasoning_inputs);
6911        assert_contains(preserve_reasoning_params.prompt, "<|Assistant|><think>Previous reasoning</think>Previous answer");
6912        assert_equals(true, common_chat_templates_get_caps(tmpls.get()).at("supports_preserve_reasoning"));
6913
6914        auto no_preserve_reasoning_inputs = default_history_inputs;
6915        no_preserve_reasoning_inputs.chat_template_kwargs["preserve_reasoning"] = "false";
6916        auto no_preserve_reasoning_params = common_chat_templates_apply(tmpls.get(), no_preserve_reasoning_inputs);
6917        assert_contains(no_preserve_reasoning_params.prompt, "<|Assistant|></think>Previous answer");
6918
6919        common_chat_msg empty_tool_call = simple_assist_msg("", "", "empty_args", "{}");
6920        common_chat_templates_inputs empty_tool_inputs;
6921        empty_tool_inputs.messages = { message_user, empty_tool_call };
6922        empty_tool_inputs.tools    = { empty_args_tool };
6923        auto empty_tool_params = common_chat_templates_apply(tmpls.get(), empty_tool_inputs);
6924        assert_contains(empty_tool_params.prompt,
6925                        "<|DSML|invoke name=\"empty_args\">\n\n</|DSML|invoke>");
6926    }
6927
6928    {
6929        auto tmpls = read_templates("models/templates/deepseek-ai-DeepSeek-V4-Flash-0731.jinja");
6930        check(tmpls, basic(),                  "<|Assistant|><think>");
6931        check(tmpls, continuation_content(),   "<|Assistant|><think>I'm thinking</think>Hello, ");
6932        check(tmpls, continuation_reasoning(), "<|Assistant|><think>I'm");
6933
6934        auto continuation_content_no_thinking = continuation_content();
6935        continuation_content_no_thinking.messages = { system_msg, message_user, simple_assist_msg("Hello, ") };
6936        continuation_content_no_thinking.enable_thinking = false;
6937        check(tmpls, continuation_content_no_thinking, "<|Assistant|></think>Hello, ");
6938
6939        common_chat_templates_inputs high_inputs;
6940        high_inputs.messages = { system_msg, message_user };
6941        high_inputs.chat_template_kwargs["reasoning_effort"] = R"("high")";
6942        auto high_params = common_chat_templates_apply(tmpls.get(), high_inputs);
6943        assert_contains(high_params.prompt, deepseek_v4_reasoning_effort_max);
6944
6945        auto max_inputs = high_inputs;
6946        max_inputs.chat_template_kwargs["reasoning_effort"] = R"("max")";
6947        auto max_params = common_chat_templates_apply(tmpls.get(), max_inputs);
6948        assert_contains(max_params.prompt, deepseek_v4_flash_0731_reasoning_effort_max);
6949
6950        auto low_inputs = high_inputs;
6951        low_inputs.chat_template_kwargs["reasoning_effort"] = R"("low")";
6952        auto low_params = common_chat_templates_apply(tmpls.get(), low_inputs);
6953        assert_not_contains(low_params.prompt, deepseek_v4_reasoning_effort_max);
6954        assert_not_contains(low_params.prompt, deepseek_v4_flash_0731_reasoning_effort_max);
6955
6956        common_chat_templates_inputs default_effort_inputs;
6957        default_effort_inputs.messages = { system_msg, message_user };
6958        auto default_effort_params = common_chat_templates_apply(tmpls.get(), default_effort_inputs);
6959        assert_not_contains(default_effort_params.prompt, deepseek_v4_reasoning_effort_max);
6960        assert_not_contains(default_effort_params.prompt, deepseek_v4_flash_0731_reasoning_effort_max);
6961
6962        auto non_thinking_max_inputs = max_inputs;
6963        non_thinking_max_inputs.enable_thinking = false;
6964        auto non_thinking_max_params = common_chat_templates_apply(tmpls.get(), non_thinking_max_inputs);
6965        assert_not_contains(non_thinking_max_params.prompt, deepseek_v4_flash_0731_reasoning_effort_max);
6966
6967        common_chat_templates_inputs response_format_inputs;
6968        response_format_inputs.messages = { system_msg, message_user };
6969        response_format_inputs.tools = { get_time_tool };
6970        response_format_inputs.json_schema =
6971            R"({"type":"object","properties":{"answer":{"type":"string"}}})";
6972        auto response_format_params = common_chat_templates_apply(tmpls.get(), response_format_inputs);
6973        assert_contains(response_format_params.prompt,
6974                        "## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n");
6975        assert_contains(response_format_params.prompt, R"("answer": {"type": "string"})");
6976    }
6977
6978    {
6979        auto tmpls = read_templates("models/templates/openbmb-MiniCPM5-1B.jinja");
6980        check(tmpls, basic(),                  "<|im_start|>assistant\n<think>\n");
6981        check(tmpls, continuation_content(),   "<|im_start|>assistant\n<think>\nI'm thinking\n</think>\n\nHello, ");
6982        check(tmpls, continuation_reasoning(), "<|im_start|>assistant\n<think>\nI'm");
6983    }
6984}
6985
6986// Test the developer role to system workaround with a simple mock template
6987static void test_developer_role_to_system_workaround() {
6988    LOG_DBG("%s\n", __func__);
6989
6990    // Simple mock template that supports system role
6991    const std::string mock_template =
6992        "{%- for message in messages -%}\n"
6993        "  {{- '<|' + message.role + '|>' + message.content + '<|end|>' -}}\n"
6994        "{%- endfor -%}\n"
6995        "{%- if add_generation_prompt -%}\n"
6996        "  {{- '<|assistant|>' -}}\n"
6997        "{%- endif -%}";
6998
6999    auto tmpls = common_chat_templates_ptr(common_chat_templates_init(/* model= */ nullptr, mock_template));
7000
7001    // Test case 1: Developer message - should be changed to system
7002    // After simplification we only test this case
7003    {
7004        common_chat_templates_inputs inputs;
7005        common_chat_msg developer_msg;
7006        developer_msg.role = "developer";
7007        developer_msg.content = "You are a helpful developer assistant.";
7008        inputs.messages = { developer_msg };
7009        inputs.add_generation_prompt = false;
7010
7011        auto params = common_chat_templates_apply(tmpls.get(), inputs);
7012
7013        // The developer role should have been changed to system
7014        if (params.prompt.find("<|developer|>") != std::string::npos) {
7015            throw std::runtime_error("Test failed: developer role was not changed to system");
7016        }
7017        if (params.prompt.find("<|system|>You are a helpful developer assistant.<|end|>") == std::string::npos) {
7018            throw std::runtime_error("Test failed: system message not found in output");
7019        }
7020        LOG_ERR("Test 1 passed: developer role changed to system\n");
7021    }
7022}
7023
7024// Verify reasoning-trace retention rules in the DeepSeek-V4 template:
7025// all traces are retained unless drop_thinking is true AND the conversation
7026// has no tool calls, in which case only the last (after-final-user) trace is
7027// kept and earlier ones are dropped.
7028static void test_deepseek_v4_thinking_retention() {
7029    LOG_DBG("%s\n", __func__);
7030
7031    auto tmpls = read_templates("models/templates/deepseek-ai-DeepSeek-V4.jinja");
7032
7033    common_chat_msg user_q1; user_q1.role = "user"; user_q1.content = "Question 1";
7034    common_chat_msg user_q2; user_q2.role = "user"; user_q2.content = "Question 2";
7035    common_chat_msg asst_a1 = simple_assist_msg("Answer 1", "thinking A1");
7036    common_chat_msg asst_a2 = simple_assist_msg("Answer 2", "thinking A2");
7037
7038    common_chat_msg tool_assist = message_with_tool_calls("special_function", "{\"arg1\": 1}");
7039    common_chat_msg tool_result; tool_result.role = "tool";
7040    tool_result.tool_name = "special_function"; tool_result.tool_call_id = "0"; tool_result.content = "result";
7041
7042    // The template uses U+FF5C as the role separator and literal think tags
7043    // for the reasoning block.
7044    const std::string asst_marker   = "<\xef\xbd\x9c" "Assistant" "\xef\xbd\x9c>";
7045    // Built via concatenation so the thinking tokens are not interpreted by
7046    // tooling processing this source file.
7047    const std::string think_start   = "<" "think" ">";
7048    const std::string think_end     = "</" "think" ">";
7049
7050    const std::string think_a1      = asst_marker + think_start + "thinking A1" + think_end;
7051    const std::string think_a2      = asst_marker + think_start + "thinking A2" + think_end;
7052    const std::string asst_no_think = asst_marker + think_end;
7053
7054    auto render = [&](const std::vector<common_chat_msg> & messages, bool drop_thinking) {
7055        common_chat_templates_inputs inputs;
7056        inputs.messages = messages;
7057        inputs.add_generation_prompt = false;
7058        inputs.chat_template_kwargs["thinking"]     = "true";
7059        inputs.chat_template_kwargs["drop_thinking"] = drop_thinking ? "true" : "false";
7060        return common_chat_templates_apply(tmpls.get(), inputs).prompt;
7061    };
7062
7063    // No tools, drop_thinking=false: all reasoning is retained.
7064    {
7065        auto prompt = render({ user_q1, asst_a1, user_q2, asst_a2 }, /* drop_thinking = */ false);
7066        assert_contains(prompt, think_a1);
7067        assert_contains(prompt, think_a2);
7068    }
7069
7070    // No tools, drop_thinking=true: only the last reasoning trace is kept,
7071    // earlier ones are dropped (the assistant block emits just the end token).
7072    {
7073        auto prompt = render({ user_q1, asst_a1, user_q2, asst_a2 }, /* drop_thinking = */ true);
7074        assert_not_contains(prompt, think_a1);
7075        assert_contains(prompt, think_a2);
7076        // The dropped assistant turn still opens with the marker + bare end token.
7077        assert_contains(prompt, asst_no_think + "Answer 1");
7078    }
7079
7080    // Single assistant turn, drop_thinking=true: the only trace is the last
7081    // one, so it must be retained even with drop_thinking set.
7082    {
7083        auto prompt = render({ user_q1, asst_a1 }, /* drop_thinking = */ true);
7084        assert_contains(prompt, think_a1);
7085    }
7086
7087    // Single assistant turn, drop_thinking=false: reasoning is retained.
7088    {
7089        auto prompt = render({ user_q1, asst_a1 }, /* drop_thinking = */ false);
7090        assert_contains(prompt, think_a1);
7091    }
7092
7093    // With tool calls, drop_thinking=true: tool presence forces all reasoning
7094    // to be retained, including the pre-tool-call trace.
7095    {
7096        auto prompt = render({ user_q1, asst_a1, user_q2, tool_assist, tool_result, asst_a2 },
7097                             /* drop_thinking = */ true);
7098        assert_contains(prompt, think_a1);
7099        assert_contains(prompt, think_a2);
7100    }
7101
7102    // With tool calls, drop_thinking=false: all reasoning retained.
7103    {
7104        auto prompt = render({ user_q1, asst_a1, user_q2, tool_assist, tool_result, asst_a2 },
7105                             /* drop_thinking = */ false);
7106        assert_contains(prompt, think_a1);
7107        assert_contains(prompt, think_a2);
7108    }
7109}
7110
7111// Verify that consecutive tool results are rendered in the tool call order of the
7112// preceding assistant message (matched by tool call id), as required by the reference
7113// DeepSeek-V4 implementation.
7114static void test_deepseek_v4_tool_result_ordering() {
7115    LOG_DBG("%s\n", __func__);
7116
7117    auto tmpls = read_templates("models/templates/deepseek-ai-DeepSeek-V4.jinja");
7118
7119    common_chat_msg user_q; user_q.role = "user"; user_q.content = "Question";
7120
7121    common_chat_msg assist_calls;
7122    assist_calls.role = "assistant";
7123    assist_calls.tool_calls.push_back({ "get_time",    "{\"city\": \"Paris\"}", "call_1" });
7124    assist_calls.tool_calls.push_back({ "get_weather", "{\"city\": \"Paris\"}", "call_2" });
7125
7126    common_chat_msg time_result; time_result.role = "tool";
7127    time_result.tool_name = "get_time"; time_result.tool_call_id = "call_1"; time_result.content = "12:00";
7128    common_chat_msg weather_result; weather_result.role = "tool";
7129    weather_result.tool_name = "get_weather"; weather_result.tool_call_id = "call_2"; weather_result.content = "sunny";
7130
7131    auto render = [&](const std::vector<common_chat_msg> & messages) {
7132        common_chat_templates_inputs inputs;
7133        inputs.messages = messages;
7134        inputs.add_generation_prompt = false;
7135        return common_chat_templates_apply(tmpls.get(), inputs).prompt;
7136    };
7137
7138    // Results sent out of order are reordered to match the tool call order.
7139    {
7140        auto prompt = render({ user_q, assist_calls, weather_result, time_result });
7141        assert_contains(prompt, "<tool_result>12:00</tool_result>\n\n<tool_result>sunny</tool_result>");
7142    }
7143
7144    // Results already in call order stay put.
7145    {
7146        auto prompt = render({ user_q, assist_calls, time_result, weather_result });
7147        assert_contains(prompt, "<tool_result>12:00</tool_result>\n\n<tool_result>sunny</tool_result>");
7148    }
7149
7150    // Without tool call ids there is nothing to match against; order is preserved.
7151    {
7152        auto no_id_calls = assist_calls;
7153        no_id_calls.tool_calls[0].id = "";
7154        no_id_calls.tool_calls[1].id = "";
7155        auto no_id_weather = weather_result; no_id_weather.tool_call_id = "";
7156        auto no_id_time    = time_result;    no_id_time.tool_call_id = "";
7157        auto prompt = render({ user_q, no_id_calls, no_id_weather, no_id_time });
7158        assert_contains(prompt, "<tool_result>sunny</tool_result>\n\n<tool_result>12:00</tool_result>");
7159    }
7160}
7161
7162static void test_reasoning_budget_tokens_per_request() {
7163    LOG_DBG("%s\n", __func__);
7164    // Use Qwen3 template which has <think>...</think> reasoning markers.
7165    // The autoparser detects them and sets thinking_start/end_tag, which enables
7166    // the reasoning-budget code path in oaicompat_chat_params_parse.
7167    auto tmpls = read_templates("models/templates/Qwen-Qwen3-0.6B.jinja");
7168
7169    server_chat_params opt;
7170    opt.tmpls            = std::move(tmpls);
7171    opt.use_jinja        = true;
7172    opt.enable_thinking  = true;
7173    opt.reasoning_budget = -1;
7174    opt.reasoning_format = COMMON_REASONING_FORMAT_NONE;
7175
7176    // Body with per-request reasoning_budget_tokens=0 (suppress thinking).
7177    json body = {
7178        {"messages", json::array({json{{"role", "user"}, {"content", "hello"}}})},
7179        {"reasoning_budget_tokens", 0},
7180    };
7181    std::vector<raw_buffer> out_files;
7182    auto llama_params = oaicompat_chat_params_parse(body, opt, out_files);
7183
7184    // The per-request value must win over the server default (-1).
7185    if (!llama_params.contains("reasoning_budget_tokens")) {
7186        throw std::runtime_error("reasoning_budget_tokens missing from llama_params (thinking_end_tag may be empty for this template)");
7187    }
7188    int got = llama_params["reasoning_budget_tokens"].get<int>();
7189    if (got != 0) {
7190        throw std::runtime_error(std::string("Expected reasoning_budget_tokens=0, got ") + std::to_string(got));
7191    }
7192}
7193
7194static void test_reasoning_budget_message_per_request() {
7195    LOG_DBG("%s\n", __func__);
7196    // Same code path as test_reasoning_budget_tokens_per_request: the Qwen3 template's
7197    // <think>...</think> markers enable the reasoning-budget block in oaicompat_chat_params_parse.
7198    auto tmpls = read_templates("models/templates/Qwen-Qwen3-0.6B.jinja");
7199
7200    server_chat_params opt;
7201    opt.tmpls                   = std::move(tmpls);
7202    opt.use_jinja               = true;
7203    opt.enable_thinking         = true;
7204    opt.reasoning_budget        = -1;
7205    opt.reasoning_format        = COMMON_REASONING_FORMAT_NONE;
7206    opt.reasoning_budget_message = "server default";
7207
7208    // Body with a per-request reasoning_budget_message override.
7209    const std::string per_request_message = "per-request message";
7210    json body = {
7211        {"messages", json::array({json{{"role", "user"}, {"content", "hello"}}})},
7212        {"reasoning_budget_message", per_request_message},
7213    };
7214    std::vector<raw_buffer> out_files;
7215    auto llama_params = oaicompat_chat_params_parse(body, opt, out_files);
7216
7217    // The per-request value must win over the server default.
7218    if (!llama_params.contains("reasoning_budget_message")) {
7219        throw std::runtime_error("reasoning_budget_message missing from llama_params (thinking_end_tag may be empty for this template)");
7220    }
7221    std::string got = llama_params["reasoning_budget_message"].get<std::string>();
7222    if (got != per_request_message) {
7223        throw std::runtime_error("Expected reasoning_budget_message='" + per_request_message + "', got '" + got + "'");
7224    }
7225}
7226
7227static void test_reasoning_effort_caps() {
7228    LOG_DBG("%s\n", __func__);
7229
7230    auto assert_supports_effort = [](const std::string & path, bool expected) {
7231        auto tmpls = read_templates(path);
7232        assert_equals(expected, common_chat_templates_get_caps(tmpls.get()).at("supports_reasoning_effort"));
7233    };
7234
7235    assert_supports_effort("models/templates/deepseek-ai-DeepSeek-V4.jinja", true);
7236    assert_supports_effort("models/templates/muse-glimmer.jinja", true);
7237    assert_supports_effort("models/templates/tencent-Hy3.jinja", true);
7238    assert_supports_effort("models/templates/openai-gpt-oss-120b.jinja", true);
7239    assert_supports_effort("models/templates/upstage-Solar-Open-100B.jinja", true);
7240    assert_supports_effort("models/templates/Cohere2MoE.jinja", true);
7241    assert_supports_effort("models/templates/meta-llama-Llama-3.1-8B-Instruct.jinja", false);
7242    assert_supports_effort("models/templates/Qwen-Qwen3-0.6B.jinja", false);
7243}
7244
7245static void test_msg_diffs_compute() {
7246    LOG_DBG("%s\n", __func__);
7247    {
7248        common_chat_msg msg1;
7249
7250        common_chat_msg msg2;
7251        msg2.content = "Hello, world!";
7252
7253        common_chat_msg_diff diff;
7254        diff.content_delta = "Hello, world!";
7255
7256        assert_equals({ diff }, common_chat_msg_diff::compute_diffs(msg1, msg2));
7257    }
7258    {
7259        common_chat_msg msg1;
7260        msg1.content = "Hello,";
7261
7262        common_chat_msg msg2;
7263        msg2.content = "Hello, world!";
7264
7265        common_chat_msg_diff diff;
7266        diff.content_delta = " world!";
7267
7268        assert_equals({ diff }, common_chat_msg_diff::compute_diffs(msg1, msg2));
7269    }
7270    {
7271        common_chat_msg msg0;
7272
7273        common_chat_msg msg1;
7274        msg1.tool_calls = {
7275            { "special_function", "{\"ar", /* .id = */ "123" }
7276        };
7277
7278        common_chat_msg msg2;
7279        msg2.tool_calls = {
7280            { "special_function", "{\"arg1\": 1}", /* .id = */ "123" }
7281        };
7282
7283        common_chat_msg_diff diff01;
7284        diff01.tool_call_index           = 0;
7285        diff01.tool_call_delta.name      = "special_function";
7286        diff01.tool_call_delta.id        = "123";
7287        diff01.tool_call_delta.arguments = "{\"ar";
7288
7289        assert_equals({ diff01 }, common_chat_msg_diff::compute_diffs(msg0, msg1));
7290
7291        common_chat_msg_diff diff12;
7292        diff12.tool_call_index           = 0;
7293        // Note: neither id nor name change here.
7294        diff12.tool_call_delta.arguments = "g1\": 1}";
7295
7296        assert_equals({ diff12 }, common_chat_msg_diff::compute_diffs(msg1, msg2));
7297    }
7298    {
7299        common_chat_msg msg0;
7300
7301        common_chat_msg msg2;
7302        msg2.tool_calls = {
7303            { "f1", "{\"arg1\": 1}", /* .id = */ "123" },
7304            { "f2", "{\"arg2\": 2}", /* .id = */ "222" },
7305        };
7306
7307        common_chat_msg_diff diff1;
7308        diff1.tool_call_index           = 0;
7309        diff1.tool_call_delta.name      = "f1";
7310        diff1.tool_call_delta.id        = "123";
7311        diff1.tool_call_delta.arguments = "{\"arg1\": 1}";
7312
7313        common_chat_msg_diff diff2;
7314        diff2.tool_call_index           = 1;
7315        diff2.tool_call_delta.name      = "f2";
7316        diff2.tool_call_delta.id        = "222";
7317        diff2.tool_call_delta.arguments = "{\"arg2\": 2}";
7318
7319        assert_equals({ diff1, diff2 }, common_chat_msg_diff::compute_diffs(msg0, msg2));
7320    }
7321}
7322
7323int main(int argc, char ** argv) {
7324    bool detailed_debug    = false;
7325    bool only_run_filtered = false;
7326
7327    // Check for --template and --detailed flags
7328    for (int i = 1; i < argc; i++) {
7329        std::string arg = argv[i];
7330        if (arg == "--template" && i + 1 < argc) {
7331            g_template_filter = argv[++i];
7332            // Only run PEG parser tests with the filter
7333            only_run_filtered = true;
7334        }
7335        if (arg == "--detailed") {
7336            detailed_debug = true;
7337            common_log_set_verbosity_thold(999);
7338        }
7339        if (arg == "--force-reconstruction-test") {
7340            g_force_reconstruction_test = true;
7341            only_run_filtered          = true;
7342        }
7343    }
7344
7345    if (only_run_filtered) {
7346        test_template_output_peg_parsers(detailed_debug);
7347        std::cout << "\n[chat] All template tests passed!" << '\n';
7348        return 0;
7349    }
7350
7351#ifndef _WIN32
7352    // Check if any argument is a .jinja file (for template format detection mode)
7353    bool has_jinja_files = false;
7354    for (int i = 1; i < argc; i++) {
7355        std::string arg = argv[i];
7356        if (arg == "--detailed") {
7357            continue;
7358        }
7359        if (arg.size() >= 6 && arg.rfind(".jinja") == arg.size() - 6) {
7360            has_jinja_files = true;
7361            break;
7362        }
7363    }
7364
7365    if (has_jinja_files) {
7366        common_chat_templates_inputs inputs;
7367        common_chat_msg              msg;
7368        msg.role        = "user";
7369        msg.content     = "Hey";
7370        inputs.messages = { msg };
7371        inputs.tools    = { special_function_tool };
7372
7373        std::cout << "| Template | Format |\n";
7374        std::cout << "|----------|--------|\n";
7375
7376        for (int i = 1; i < argc; i++) {
7377            try {
7378                std::string path = argv[i];
7379                if (path.rfind(".jinja") != path.size() - 6) {
7380                    std::cerr << "Skipping non-jinja file: " << path << '\n';
7381                    continue;
7382                }
7383                auto         tmpls  = read_templates(path);
7384                auto         parts  = string_split(path, "/");
7385                const auto & name   = parts[parts.size() - 1];
7386                const auto * format = common_chat_format_name(common_chat_templates_apply(tmpls.get(), inputs).format);
7387                std::cout << "| " << name << " | " << format << " |\n";
7388            } catch (const std::exception & e) {
7389                std::cerr << "Failed to process " << argv[i] << ": " << e.what() << '\n';
7390            }
7391        }
7392    } else
7393#endif
7394    {
7395        test_msg_diffs_compute();
7396        test_msgs_oaicompat_json_conversion();
7397        test_msg_token_delimiters_split();
7398        test_tools_oaicompat_json_conversion();
7399        test_convert_responses_to_chatcmpl();
7400        test_developer_role_to_system_workaround();
7401        test_deepseek_v4_thinking_retention();
7402        test_deepseek_v4_tool_result_ordering();
7403        test_template_generation_prompt();
7404        test_reasoning_effort_caps();
7405        test_reasoning_budget_tokens_per_request();
7406        test_reasoning_budget_message_per_request();
7407        test_template_output_peg_parsers(detailed_debug);
7408        std::cout << "\n[chat] All tests passed!" << '\n';
7409    }
7410    return 0;
7411}