Commit dd099a6bf for llama.cpp

commit dd099a6bf30c50627f6ae18915fbb687539a704d
Author: Xuan-Son Nguyen <son@huggingface.co>
Date:   Sun Nov 15 09:41:44 2026 +0100

    chat: fix inkling 2 begin/end of image tokens placement (#32294)

    * chat: fix inkling 2 begin/end of image tokens placement

    * add test

diff --git a/common/chat.cpp b/common/chat.cpp
index 997c135d9..e060362d2 100644
--- a/common/chat.cpp
+++ b/common/chat.cpp
@@ -1609,6 +1609,9 @@ static common_chat_params common_chat_templates_apply_jinja(const struct common_
                 auto_params.thinking_end_tags = {std::move(end_tag)};
             }
         }
+        if (is_inkling2_template(src)) {
+            common_chat_inkling2_split_media(auto_params.prompt);
+        }
         const auto & arena = auto_params.parser;
         LOG_DBG("%s: generated parser:\n%s\n\nparser generation prompt: %s\n", __func__, arena.dump(arena.root()).c_str(), auto_params.generation_prompt.c_str());
         return auto_params;
diff --git a/common/parsers/inkling2.cpp b/common/parsers/inkling2.cpp
new file mode 100644
index 000000000..4e2b01c63
--- /dev/null
+++ b/common/parsers/inkling2.cpp
@@ -0,0 +1,46 @@
+#include "parsers.h"
+
+bool is_inkling2_template(const std::string & src) {
+    return src.find("<|message_user|>") != std::string::npos &&
+           src.find("<|content_image|>") != std::string::npos;
+}
+
+// the template makes one message block per content part, but a media marker comes inside the text of a message
+// <header><|content_text|>{media}text<|end_message|> --> <header>{media}<|end_message|><header><|content_text|>text<|end_message|>
+void common_chat_inkling2_split_media(common_chat_prompt & prompt) {
+    const std::string TEXT      = "<|content_text|>";
+    const std::string END       = "<|end_message|>";
+    const std::string MEDIA_BEG = "<__media_";
+    const std::string MEDIA_END = "__>";
+
+    std::vector<common_chat_prompt_part> out;
+    for (size_t i = 0; i < prompt.parts.size(); i++) {
+        const auto & part = prompt.parts[i];
+        const bool in_text_block = part.is_input && part.text.find(MEDIA_BEG) != std::string::npos &&
+            !out.empty() && !out.back().is_input && string_ends_with(out.back().text, TEXT) &&
+            i + 1 < prompt.parts.size() && !prompt.parts[i + 1].is_input && string_starts_with(prompt.parts[i + 1].text, END);
+        const size_t header_pos = in_text_block ? out.back().text.rfind("<|message_") : std::string::npos;
+        if (header_pos == std::string::npos) {
+            out.push_back(part);
+            continue;
+        }
+        const std::string header = out.back().text.substr(header_pos, out.back().text.size() - TEXT.size() - header_pos);
+
+        std::string rest = part.text;
+        for (bool first = true; !rest.empty(); first = false) {
+            const size_t beg      = rest.find(MEDIA_BEG);
+            const size_t end      = beg == std::string::npos ? beg : rest.find(MEDIA_END, beg);
+            const bool   is_media = beg == 0 && end != std::string::npos;
+            const size_t len      = is_media ? end + MEDIA_END.size() : (end == std::string::npos ? rest.size() : beg);
+            if (!first) {
+                out.push_back({ END + header + (is_media ? "" : TEXT), false });
+            } else if (is_media) {
+                // mtmd opens the media block
+                out.back().text.resize(out.back().text.size() - TEXT.size());
+            }
+            out.push_back({ rest.substr(0, len), true });
+            rest.erase(0, len);
+        }
+    }
+    prompt.parts = std::move(out);
+}
diff --git a/common/parsers/parsers.h b/common/parsers/parsers.h
index b1b6d166a..35dc04ac3 100644
--- a/common/parsers/parsers.h
+++ b/common/parsers/parsers.h
@@ -41,6 +41,11 @@ std::string common_chat_template_generation_prompt_impl(

 bool is_lfm2_template(const std::string & src);

+bool is_inkling2_template(const std::string & src);
+
+// give each media marker of a text block its own message block
+void common_chat_inkling2_split_media(common_chat_prompt & prompt);
+
 common_chat_params common_chat_params_init_cohere2moe(const common_chat_template & tmpl, const autoparser::generation_params & inputs);

 common_chat_params common_chat_params_init_deepseek_v3_2(const common_chat_template & tmpl, const autoparser::generation_params & inputs);
diff --git a/common/parsers/sources.cmake b/common/parsers/sources.cmake
index 2174f4519..97dd490e8 100644
--- a/common/parsers/sources.cmake
+++ b/common/parsers/sources.cmake
@@ -10,6 +10,7 @@ set(LLAMA_CHAT_PARSERS_SOURCES
     ${CMAKE_CURRENT_LIST_DIR}/gigachat-v3.cpp
     ${CMAKE_CURRENT_LIST_DIR}/gpt-oss.cpp
     ${CMAKE_CURRENT_LIST_DIR}/harmony-xml.cpp
+    ${CMAKE_CURRENT_LIST_DIR}/inkling2.cpp
     ${CMAKE_CURRENT_LIST_DIR}/k2-horizon.cpp
     ${CMAKE_CURRENT_LIST_DIR}/kimi-k2.cpp
     ${CMAKE_CURRENT_LIST_DIR}/kimi-k3.cpp
diff --git a/tests/test-chat.cpp b/tests/test-chat.cpp
index 53a1b6d1a..b34de6562 100644
--- a/tests/test-chat.cpp
+++ b/tests/test-chat.cpp
@@ -166,6 +166,18 @@ static const char * DEEPSEEK_V4_1_MIN_TEMPLATE = R"({%- for m in messages -%}
 {%- if reasoning_effort is defined and reasoning_effort is not none -%}<|effort={{ reasoning_effort }}|>{%- endif -%}
 {%- if add_generation_prompt -%}<|Assistant|>{%- if enable_thinking -%}<think>{%- else -%}</think>{%- endif -%}{%- endif -%})";

+// Inkling 2 stand-in: the real template ships in the GGUF; this keeps only the message blocks,
+// one per content part, and the tokens used for detection
+static const char * INKLING2_MIN_TEMPLATE = R"({%- set role_token = {"user": "<|message_user|>", "assistant": "<|message_model|>", "system": "<|message_system|>"} -%}
+{%- for m in messages -%}
+{%- if m.content is string -%}{{ role_token[m.role] }}<|content_text|>{{ m.content }}<|end_message|>
+{%- else -%}{%- for p in m.content -%}{{ role_token[m.role] }}
+{%- if p.type == 'image' -%}<|content_image|>{%- else -%}<|content_text|>{{ p.text }}{%- endif -%}
+<|end_message|>{%- endfor -%}
+{%- endif -%}
+{%- endfor -%}
+{%- if add_generation_prompt -%}<|message_model|>{%- endif -%})";
+
 static std::unique_ptr<llama_grammar> build_grammar(const std::string & grammar_str) {
     return std::unique_ptr<llama_grammar>(
         llama_grammar_init_impl(nullptr, grammar_str.c_str(), "root", false, nullptr, 0, nullptr, 0));
@@ -7737,6 +7749,37 @@ static void test_template_output_peg_parsers(bool detailed_debug) {
     }
 }

+// Inkling 2: each media marker moves out of the text block into its own message block
+static void test_inkling2_media_blocks() {
+    LOG_DBG("%s\n", __func__);
+
+    // must be the marker used by the server, the split depends on its format
+    const std::string M = get_media_marker();
+    const std::string U = "<|message_user|>";
+    const std::string T = "<|content_text|>";
+    const std::string E = "<|end_message|>";
+
+    common_chat_templates_ptr tmpls(common_chat_templates_init(nullptr, INKLING2_MIN_TEMPLATE));
+    auto render = [&](const std::vector<std::string> & parts) {
+        common_chat_msg msg;
+        msg.role = "user";
+        for (const auto & text : parts) {
+            msg.content_parts.push_back({ /* .type = */ text == M ? "media_marker" : "text", /* .text = */ text, /* .name = */ "" });
+        }
+        common_chat_templates_inputs inputs;
+        inputs.messages = { msg };
+        inputs.add_generation_prompt = false;
+        return common_chat_templates_apply(tmpls.get(), inputs).prompt.str();
+    };
+
+    assert_equals(U + M + E + U + T + "a" + E,                 render({ M, "a" }));
+    assert_equals(U + T + "a" + E + U + M + E,                 render({ "a", M }));
+    assert_equals(U + M + E,                                   render({ M }));
+    assert_equals(U + M + E + U + M + E + U + T + "a" + E,     render({ M, M, "a" }));
+    assert_equals(U + T + "a" + E + U + M + E + U + T + "b" + E, render({ "a", M, "b" }));
+    assert_equals(U + T + "a" + E,                             render({ "a" }));
+}
+
 static void test_template_generation_prompt() {
     common_chat_msg system_msg;
     system_msg.role = "system";
@@ -8733,6 +8776,7 @@ int main(int argc, char ** argv) {
         test_deepseek_v4_thinking_retention();
         test_deepseek_v4_tool_result_ordering();
         test_deepseek_v4_1_message_fields();
+        test_inkling2_media_blocks();
         test_template_generation_prompt();
         test_reasoning_effort_caps();
         test_prompt_parts();
diff --git a/tools/mtmd/mtmd.cpp b/tools/mtmd/mtmd.cpp
index 929f34620..63a8e3954 100644
--- a/tools/mtmd/mtmd.cpp
+++ b/tools/mtmd/mtmd.cpp
@@ -907,9 +907,8 @@ struct mtmd_context {
                 } break;
             case PROJECTOR_TYPE_INKLING2V:
                 {
-                    // image goes in its own message block (always user role)
-                    img_beg = "<|end_message|><|message_user|><|content_image|>";
-                    img_end = "<|end_message|><|message_user|><|content_text|>";
+                    // the chat template code gives the image its own message block
+                    img_beg = "<|content_image|>";
                     image_preproc = std::make_unique<mtmd_image_preprocessor_inkling2v>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_MUSE_GLIMMER: