Commit bd4eeaa04 for llama.cpp

commit bd4eeaa047006cb1fe71999fbd11134b5836e167
Author: Nicolas Budyn <16596345+nbud@users.noreply.github.com>
Date:   Wed Oct 7 22:48:06 2026 +0200

    chat: fix jinja parser for TranslateGemma (#30096)

    * chat: fix jinja parser for translategemma

    * log if missing soruce_lang_code or target_lang_code

diff --git a/common/chat.cpp b/common/chat.cpp
index 2c795c281..c8f156a70 100644
--- a/common/chat.cpp
+++ b/common/chat.cpp
@@ -1223,6 +1223,13 @@ std::optional<common_chat_params> common_chat_try_specialized_template(
         return common_chat_params_init_minicpm5(tmpl, params);
     }

+    // TranslateGemma - user content must follow a custom schema with language codes
+    if (src.find("[source_lang_code]") != std::string::npos &&
+        src.find("[target_lang_code]") != std::string::npos) {
+        LOG_DBG("Using specialized template: TranslateGemma\n");
+        return common_chat_params_init_translate_gemma(tmpl, params);
+    }
+
     // Qwen3-Coder XML tool calls, also used by Nemotron Nano 3, Qwen3.5 and StepFun-3.5-Flash
     if (src.find("<tool_call>") != std::string::npos &&
         src.find("<function=") != std::string::npos &&
diff --git a/common/parsers/parsers.h b/common/parsers/parsers.h
index 1b385264e..fd9fc1ce1 100644
--- a/common/parsers/parsers.h
+++ b/common/parsers/parsers.h
@@ -81,3 +81,5 @@ common_chat_params common_chat_params_init_ministral_3(const common_chat_templat
 common_chat_params common_chat_params_init_muse_glimmer(const common_chat_template & tmpl, const autoparser::generation_params & inputs);

 common_chat_params common_chat_params_init_qwen3_coder(const common_chat_template & tmpl, const autoparser::generation_params & inputs);
+
+common_chat_params common_chat_params_init_translate_gemma(const common_chat_template & tmpl, const autoparser::generation_params & inputs);
diff --git a/common/parsers/sources.cmake b/common/parsers/sources.cmake
index af0c68019..152397074 100644
--- a/common/parsers/sources.cmake
+++ b/common/parsers/sources.cmake
@@ -20,4 +20,5 @@ set(LLAMA_CHAT_PARSERS_SOURCES
     ${CMAKE_CURRENT_LIST_DIR}/ministral3.cpp
     ${CMAKE_CURRENT_LIST_DIR}/muse-glimmer.cpp
     ${CMAKE_CURRENT_LIST_DIR}/qwen3-coder.cpp
+    ${CMAKE_CURRENT_LIST_DIR}/translate-gemma.cpp
 )
diff --git a/common/parsers/translate-gemma.cpp b/common/parsers/translate-gemma.cpp
new file mode 100644
index 000000000..c52778064
--- /dev/null
+++ b/common/parsers/translate-gemma.cpp
@@ -0,0 +1,63 @@
+#include "parsers.h"
+#include "log.h"
+
+// TranslateGemma does not support tools or reasoning, it only needs user messages in its own content schema
+common_chat_params common_chat_params_init_translate_gemma(
+        const common_chat_template & tmpl,
+        const autoparser::generation_params & inputs) {
+
+    common_chat_params data;
+
+    // default to chat_template_kwargs, or en-GB if not specified
+    std::string src_lang = inputs.extra_context.value("source_lang_code", "en-GB");
+    std::string tgt_lang = inputs.extra_context.value("target_lang_code", "en-GB");
+    for (const char * key : { "source_lang_code", "target_lang_code" }) {
+        if (!inputs.extra_context.contains(key)) {
+            LOG_WRN("TranslateGemma: %s not set in chat_template_kwargs, defaulting to en-GB\n", key);
+        }
+    }
+
+    json messages = inputs.messages;
+    for (auto & message : messages) {
+        if (message.value("role", "") != "user") {
+            continue;
+        }
+        std::string text;
+        const auto & content = message.contains("content") ? message.at("content") : json();
+        if (content.is_string()) {
+            text = content.get<std::string>();
+        } else if (content.is_array()) {
+            for (const auto & part : content) {
+                if (!text.empty()) {
+                    text += "\n";
+                }
+                text += part.value("text", "");
+            }
+        }
+        message["content"] = json::array({
+            json{
+                {"type", "text"},
+                {"text", text},
+                {"source_lang_code", src_lang},
+                {"target_lang_code", tgt_lang},
+            }
+        });
+    }
+
+    data.prompt            = common_chat_template_direct_apply_impl(tmpl, inputs, messages);
+    data.generation_prompt = common_chat_template_generation_prompt_impl(tmpl, inputs, messages);
+    data.format            = COMMON_CHAT_FORMAT_PEG_NATIVE;
+    data.supports_thinking = false;
+
+    if (inputs.has_continuation()) {
+        data.generation_prompt = "<start_of_turn>model\n" + inputs.continue_msg.render_content();
+        data.prompt += data.generation_prompt;
+    }
+
+    auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
+        return p.literal(data.generation_prompt) << p.content(p.rest());
+    });
+    data.parser = parser.save();
+
+    return data;
+}
diff --git a/models/templates/google-translategemma-4b-it.jinja b/models/templates/google-translategemma-4b-it.jinja
new file mode 100644
index 000000000..d571968f9
--- /dev/null
+++ b/models/templates/google-translategemma-4b-it.jinja
@@ -0,0 +1,641 @@
+{%- set languages = {
+    "aa": "Afar",
+    "aa-DJ": "Afar",
+    "aa-ER": "Afar",
+    "ab": "Abkhazian",
+    "af": "Afrikaans",
+    "af-NA": "Afrikaans",
+    "ak": "Akan",
+    "am": "Amharic",
+    "an": "Aragonese",
+    "ar": "Arabic",
+    "ar-AE": "Arabic",
+    "ar-BH": "Arabic",
+    "ar-DJ": "Arabic",
+    "ar-DZ": "Arabic",
+    "ar-EG": "Arabic",
+    "ar-EH": "Arabic",
+    "ar-ER": "Arabic",
+    "ar-IL": "Arabic",
+    "ar-IQ": "Arabic",
+    "ar-JO": "Arabic",
+    "ar-KM": "Arabic",
+    "ar-KW": "Arabic",
+    "ar-LB": "Arabic",
+    "ar-LY": "Arabic",
+    "ar-MA": "Arabic",
+    "ar-MR": "Arabic",
+    "ar-OM": "Arabic",
+    "ar-PS": "Arabic",
+    "ar-QA": "Arabic",
+    "ar-SA": "Arabic",
+    "ar-SD": "Arabic",
+    "ar-SO": "Arabic",
+    "ar-SS": "Arabic",
+    "ar-SY": "Arabic",
+    "ar-TD": "Arabic",
+    "ar-TN": "Arabic",
+    "ar-YE": "Arabic",
+    "as": "Assamese",
+    "az": "Azerbaijani",
+    "az-Arab": "Azerbaijani",
+    "az-Arab-IQ": "Azerbaijani",
+    "az-Arab-TR": "Azerbaijani",
+    "az-Cyrl": "Azerbaijani",
+    "az-Latn": "Azerbaijani",
+    "ba": "Bashkir",
+    "be": "Belarusian",
+    "be-tarask": "Belarusian",
+    "bg": "Bulgarian",
+    "bg-BG": "Bulgarian",
+    "bm": "Bambara",
+    "bm-Nkoo": "Bambara",
+    "bn": "Bengali",
+    "bn-IN": "Bengali",
+    "bo": "Tibetan",
+    "bo-IN": "Tibetan",
+    "br": "Breton",
+    "bs": "Bosnian",
+    "bs-Cyrl": "Bosnian",
+    "bs-Latn": "Bosnian",
+    "ca": "Catalan",
+    "ca-AD": "Catalan",
+    "ca-ES": "Catalan",
+    "ca-FR": "Catalan",
+    "ca-IT": "Catalan",
+    "ce": "Chechen",
+    "co": "Corsican",
+    "cs": "Czech",
+    "cs-CZ": "Czech",
+    "cv": "Chuvash",
+    "cy": "Welsh",
+    "da": "Danish",
+    "da-DK": "Danish",
+    "da-GL": "Danish",
+    "de": "German",
+    "de-AT": "German",
+    "de-BE": "German",
+    "de-CH": "German",
+    "de-DE": "German",
+    "de-IT": "German",
+    "de-LI": "German",
+    "de-LU": "German",
+    "dv": "Divehi",
+    "dz": "Dzongkha",
+    "ee": "Ewe",
+    "ee-TG": "Ewe",
+    "el": "Greek",
+    "el-CY": "Greek",
+    "el-GR": "Greek",
+    "el-polyton": "Greek",
+    "en": "English",
+    "en-AE": "English",
+    "en-AG": "English",
+    "en-AI": "English",
+    "en-AS": "English",
+    "en-AT": "English",
+    "en-AU": "English",
+    "en-BB": "English",
+    "en-BE": "English",
+    "en-BI": "English",
+    "en-BM": "English",
+    "en-BS": "English",
+    "en-BW": "English",
+    "en-BZ": "English",
+    "en-CA": "English",
+    "en-CC": "English",
+    "en-CH": "English",
+    "en-CK": "English",
+    "en-CM": "English",
+    "en-CX": "English",
+    "en-CY": "English",
+    "en-CZ": "English",
+    "en-DE": "English",
+    "en-DG": "English",
+    "en-DK": "English",
+    "en-DM": "English",
+    "en-ER": "English",
+    "en-ES": "English",
+    "en-FI": "English",
+    "en-FJ": "English",
+    "en-FK": "English",
+    "en-FM": "English",
+    "en-FR": "English",
+    "en-GB": "English",
+    "en-GD": "English",
+    "en-GG": "English",
+    "en-GH": "English",
+    "en-GI": "English",
+    "en-GM": "English",
+    "en-GS": "English",
+    "en-GU": "English",
+    "en-GY": "English",
+    "en-HK": "English",
+    "en-HU": "English",
+    "en-ID": "English",
+    "en-IE": "English",
+    "en-IL": "English",
+    "en-IM": "English",
+    "en-IN": "English",
+    "en-IO": "English",
+    "en-IT": "English",
+    "en-JE": "English",
+    "en-JM": "English",
+    "en-KE": "English",
+    "en-KI": "English",
+    "en-KN": "English",
+    "en-KY": "English",
+    "en-LC": "English",
+    "en-LR": "English",
+    "en-LS": "English",
+    "en-MG": "English",
+    "en-MH": "English",
+    "en-MO": "English",
+    "en-MP": "English",
+    "en-MS": "English",
+    "en-MT": "English",
+    "en-MU": "English",
+    "en-MV": "English",
+    "en-MW": "English",
+    "en-MY": "English",
+    "en-NA": "English",
+    "en-NF": "English",
+    "en-NG": "English",
+    "en-NL": "English",
+    "en-NO": "English",
+    "en-NR": "English",
+    "en-NU": "English",
+    "en-NZ": "English",
+    "en-PG": "English",
+    "en-PH": "English",
+    "en-PK": "English",
+    "en-PL": "English",
+    "en-PN": "English",
+    "en-PR": "English",
+    "en-PT": "English",
+    "en-PW": "English",
+    "en-RO": "English",
+    "en-RW": "English",
+    "en-SB": "English",
+    "en-SC": "English",
+    "en-SD": "English",
+    "en-SE": "English",
+    "en-SG": "English",
+    "en-SH": "English",
+    "en-SI": "English",
+    "en-SK": "English",
+    "en-SL": "English",
+    "en-SS": "English",
+    "en-SX": "English",
+    "en-SZ": "English",
+    "en-TC": "English",
+    "en-TK": "English",
+    "en-TO": "English",
+    "en-TT": "English",
+    "en-TV": "English",
+    "en-TZ": "English",
+    "en-UG": "English",
+    "en-UM": "English",
+    "en-VC": "English",
+    "en-VG": "English",
+    "en-VI": "English",
+    "en-VU": "English",
+    "en-WS": "English",
+    "en-ZA": "English",
+    "en-ZM": "English",
+    "en-ZW": "English",
+    "eo": "Esperanto",
+    "es": "Spanish",
+    "es-AR": "Spanish",
+    "es-BO": "Spanish",
+    "es-BR": "Spanish",
+    "es-BZ": "Spanish",
+    "es-CL": "Spanish",
+    "es-CO": "Spanish",
+    "es-CR": "Spanish",
+    "es-CU": "Spanish",
+    "es-DO": "Spanish",
+    "es-EA": "Spanish",
+    "es-EC": "Spanish",
+    "es-ES": "Spanish",
+    "es-GQ": "Spanish",
+    "es-GT": "Spanish",
+    "es-HN": "Spanish",
+    "es-IC": "Spanish",
+    "es-MX": "Spanish",
+    "es-NI": "Spanish",
+    "es-PA": "Spanish",
+    "es-PE": "Spanish",
+    "es-PH": "Spanish",
+    "es-PR": "Spanish",
+    "es-PY": "Spanish",
+    "es-SV": "Spanish",
+    "es-US": "Spanish",
+    "es-UY": "Spanish",
+    "es-VE": "Spanish",
+    "et": "Estonian",
+    "et-EE": "Estonian",
+    "eu": "Basque",
+    "fa": "Persian",
+    "fa-AF": "Persian",
+    "fa-IR": "Persian",
+    "ff": "Fulah",
+    "ff-Adlm": "Fulah",
+    "ff-Adlm-BF": "Fulah",
+    "ff-Adlm-CM": "Fulah",
+    "ff-Adlm-GH": "Fulah",
+    "ff-Adlm-GM": "Fulah",
+    "ff-Adlm-GW": "Fulah",
+    "ff-Adlm-LR": "Fulah",
+    "ff-Adlm-MR": "Fulah",
+    "ff-Adlm-NE": "Fulah",
+    "ff-Adlm-NG": "Fulah",
+    "ff-Adlm-SL": "Fulah",
+    "ff-Adlm-SN": "Fulah",
+    "ff-Latn": "Fulah",
+    "ff-Latn-BF": "Fulah",
+    "ff-Latn-CM": "Fulah",
+    "ff-Latn-GH": "Fulah",
+    "ff-Latn-GM": "Fulah",
+    "ff-Latn-GN": "Fulah",
+    "ff-Latn-GW": "Fulah",
+    "ff-Latn-LR": "Fulah",
+    "ff-Latn-MR": "Fulah",
+    "ff-Latn-NE": "Fulah",
+    "ff-Latn-NG": "Fulah",
+    "ff-Latn-SL": "Fulah",
+    "fi": "Finnish",
+    "fi-FI": "Finnish",
+    "fil-PH": "Filipino",
+    "fo": "Faroese",
+    "fo-DK": "Faroese",
+    "fr": "French",
+    "fr-BE": "French",
+    "fr-BF": "French",
+    "fr-BI": "French",
+    "fr-BJ": "French",
+    "fr-BL": "French",
+    "fr-CA": "French",
+    "fr-CD": "French",
+    "fr-CF": "French",
+    "fr-CG": "French",
+    "fr-CH": "French",
+    "fr-CI": "French",
+    "fr-CM": "French",
+    "fr-DJ": "French",
+    "fr-DZ": "French",
+    "fr-FR": "French",
+    "fr-GA": "French",
+    "fr-GF": "French",
+    "fr-GN": "French",
+    "fr-GP": "French",
+    "fr-GQ": "French",
+    "fr-HT": "French",
+    "fr-KM": "French",
+    "fr-LU": "French",
+    "fr-MA": "French",
+    "fr-MC": "French",
+    "fr-MF": "French",
+    "fr-MG": "French",
+    "fr-ML": "French",
+    "fr-MQ": "French",
+    "fr-MR": "French",
+    "fr-MU": "French",
+    "fr-NC": "French",
+    "fr-NE": "French",
+    "fr-PF": "French",
+    "fr-PM": "French",
+    "fr-RE": "French",
+    "fr-RW": "French",
+    "fr-SC": "French",
+    "fr-SN": "French",
+    "fr-SY": "French",
+    "fr-TD": "French",
+    "fr-TG": "French",
+    "fr-TN": "French",
+    "fr-VU": "French",
+    "fr-WF": "French",
+    "fr-YT": "French",
+    "fy": "Western Frisian",
+    "ga": "Irish",
+    "ga-GB": "Irish",
+    "gd": "Scottish Gaelic",
+    "gl": "Galician",
+    "gn": "Guarani",
+    "gu": "Gujarati",
+    "gu-IN": "Gujarati",
+    "gv": "Manx",
+    "ha": "Hausa",
+    "ha-Arab": "Hausa",
+    "ha-Arab-SD": "Hausa",
+    "ha-GH": "Hausa",
+    "ha-NE": "Hausa",
+    "he": "Hebrew",
+    "he-IL": "Hebrew",
+    "hi": "Hindi",
+    "hi-IN": "Hindi",
+    "hi-Latn": "Hindi",
+    "hr": "Croatian",
+    "hr-BA": "Croatian",
+    "hr-HR": "Croatian",
+    "ht": "Haitian",
+    "hu": "Hungarian",
+    "hu-HU": "Hungarian",
+    "hy": "Armenian",
+    "ia": "Interlingua",
+    "id": "Indonesian",
+    "id-ID": "Indonesian",
+    "ie": "Interlingue",
+    "ig": "Igbo",
+    "ii": "Sichuan Yi",
+    "ik": "Inupiaq",
+    "io": "Ido",
+    "is": "Icelandic",
+    "it": "Italian",
+    "it-CH": "Italian",
+    "it-IT": "Italian",
+    "it-SM": "Italian",
+    "it-VA": "Italian",
+    "iu": "Inuktitut",
+    "iu-Latn": "Inuktitut",
+    "ja": "Japanese",
+    "ja-JP": "Japanese",
+    "jv": "Javanese",
+    "ka": "Georgian",
+    "ki": "Kikuyu",
+    "kk": "Kazakh",
+    "kk-Arab": "Kazakh",
+    "kk-Cyrl": "Kazakh",
+    "kk-KZ": "Kazakh",
+    "kl": "Kalaallisut",
+    "km": "Central Khmer",
+    "kn": "Kannada",
+    "kn-IN": "Kannada",
+    "ko": "Korean",
+    "ko-CN": "Korean",
+    "ko-KP": "Korean",
+    "ko-KR": "Korean",
+    "ks": "Kashmiri",
+    "ks-Arab": "Kashmiri",
+    "ks-Deva": "Kashmiri",
+    "ku": "Kurdish",
+    "kw": "Cornish",
+    "ky": "Kyrgyz",
+    "la": "Latin",
+    "lb": "Luxembourgish",
+    "lg": "Ganda",
+    "ln": "Lingala",
+    "ln-AO": "Lingala",
+    "ln-CF": "Lingala",
+    "ln-CG": "Lingala",
+    "lo": "Lao",
+    "lt": "Lithuanian",
+    "lt-LT": "Lithuanian",
+    "lu": "Luba-Katanga",
+    "lv": "Latvian",
+    "lv-LV": "Latvian",
+    "mg": "Malagasy",
+    "mi": "Maori",
+    "mk": "Macedonian",
+    "ml": "Malayalam",
+    "ml-IN": "Malayalam",
+    "mn": "Mongolian",
+    "mn-Mong": "Mongolian",
+    "mn-Mong-MN": "Mongolian",
+    "mr": "Marathi",
+    "mr-IN": "Marathi",
+    "ms": "Malay",
+    "ms-Arab": "Malay",
+    "ms-Arab-BN": "Malay",
+    "ms-BN": "Malay",
+    "ms-ID": "Malay",
+    "ms-SG": "Malay",
+    "mt": "Maltese",
+    "my": "Burmese",
+    "nb": "Norwegian Bokmål",
+    "nb-SJ": "Norwegian Bokmål",
+    "nd": "North Ndebele",
+    "ne": "Nepali",
+    "ne-IN": "Nepali",
+    "nl": "Dutch",
+    "nl-AW": "Dutch",
+    "nl-BE": "Dutch",
+    "nl-BQ": "Dutch",
+    "nl-CW": "Dutch",
+    "nl-NL": "Dutch",
+    "nl-SR": "Dutch",
+    "nl-SX": "Dutch",
+    "nn": "Norwegian Nynorsk",
+    "no": "Norwegian",
+    "no-NO": "Norwegian",
+    "nr": "South Ndebele",
+    "nv": "Navajo",
+    "ny": "Chichewa",
+    "oc": "Occitan",
+    "oc-ES": "Occitan",
+    "om": "Oromo",
+    "om-KE": "Oromo",
+    "or": "Oriya",
+    "os": "Ossetian",
+    "os-RU": "Ossetian",
+    "pa": "Punjabi",
+    "pa-IN": "Punjabi",
+    "pa-Arab": "Punjabi",
+    "pa-Guru": "Punjabi",
+    "pl": "Polish",
+    "pl-PL": "Polish",
+    "ps": "Pashto",
+    "ps-PK": "Pashto",
+    "pt": "Portuguese",
+    "pt-AO": "Portuguese",
+    "pt-BR": "Portuguese",
+    "pt-CH": "Portuguese",
+    "pt-CV": "Portuguese",
+    "pt-GQ": "Portuguese",
+    "pt-GW": "Portuguese",
+    "pt-LU": "Portuguese",
+    "pt-MO": "Portuguese",
+    "pt-MZ": "Portuguese",
+    "pt-PT": "Portuguese",
+    "pt-ST": "Portuguese",
+    "pt-TL": "Portuguese",
+    "qu": "Quechua",
+    "qu-BO": "Quechua",
+    "qu-EC": "Quechua",
+    "rm": "Romansh",
+    "rn": "Rundi",
+    "ro": "Romanian",
+    "ro-MD": "Romanian",
+    "ro-RO": "Romanian",
+    "ru": "Russian",
+    "ru-BY": "Russian",
+    "ru-KG": "Russian",
+    "ru-KZ": "Russian",
+    "ru-MD": "Russian",
+    "ru-RU": "Russian",
+    "ru-UA": "Russian",
+    "rw": "Kinyarwanda",
+    "sa": "Sanskrit",
+    "sc": "Sardinian",
+    "sd": "Sindhi",
+    "sd-Arab": "Sindhi",
+    "sd-Deva": "Sindhi",
+    "se": "Northern Sami",
+    "se-FI": "Northern Sami",
+    "se-SE": "Northern Sami",
+    "sg": "Sango",
+    "si": "Sinhala",
+    "sk": "Slovak",
+    "sk-SK": "Slovak",
+    "sl": "Slovenian",
+    "sl-SI": "Slovenian",
+    "sn": "Shona",
+    "so": "Somali",
+    "so-DJ": "Somali",
+    "so-ET": "Somali",
+    "so-KE": "Somali",
+    "sq": "Albanian",
+    "sq-MK": "Albanian",
+    "sq-XK": "Albanian",
+    "sr": "Serbian",
+    "sr-RS": "Serbian",
+    "sr-Cyrl": "Serbian",
+    "sr-Cyrl-BA": "Serbian",
+    "sr-Cyrl-ME": "Serbian",
+    "sr-Cyrl-XK": "Serbian",
+    "sr-Latn": "Serbian",
+    "sr-Latn-BA": "Serbian",
+    "sr-Latn-ME": "Serbian",
+    "sr-Latn-XK": "Serbian",
+    "ss": "Swati",
+    "ss-SZ": "Swati",
+    "st": "Southern Sotho",
+    "st-LS": "Southern Sotho",
+    "su": "Sundanese",
+    "su-Latn": "Sundanese",
+    "sv": "Swedish",
+    "sv-AX": "Swedish",
+    "sv-FI": "Swedish",
+    "sv-SE": "Swedish",
+    "sw": "Swahili",
+    "sw-CD": "Swahili",
+    "sw-KE": "Swahili",
+    "sw-TZ": "Swahili",
+    "sw-UG": "Swahili",
+    "ta": "Tamil",
+    "ta-IN": "Tamil",
+    "ta-LK": "Tamil",
+    "ta-MY": "Tamil",
+    "ta-SG": "Tamil",
+    "te": "Telugu",
+    "te-IN": "Telugu",
+    "tg": "Tajik",
+    "th": "Thai",
+    "th-TH": "Thai",
+    "ti": "Tigrinya",
+    "ti-ER": "Tigrinya",
+    "tk": "Turkmen",
+    "tl": "Tagalog",
+    "tn": "Tswana",
+    "tn-BW": "Tswana",
+    "to": "Tonga",
+    "tr": "Turkish",
+    "tr-CY": "Turkish",
+    "tr-TR": "Turkish",
+    "ts": "Tsonga",
+    "tt": "Tatar",
+    "ug": "Uyghur",
+    "uk": "Ukrainian",
+    "uk-UA": "Ukrainian",
+    "ur": "Urdu",
+    "ur-IN": "Urdu",
+    "ur-PK": "Urdu",
+    "uz": "Uzbek",
+    "uz-Arab": "Uzbek",
+    "uz-Cyrl": "Uzbek",
+    "uz-Latn": "Uzbek",
+    "ve": "Venda",
+    "vi": "Vietnamese",
+    "vi-VN": "Vietnamese",
+    "vo": "Volapük",
+    "wa": "Walloon",
+    "wo": "Wolof",
+    "xh": "Xhosa",
+    "yi": "Yiddish",
+    "yo": "Yoruba",
+    "yo-BJ": "Yoruba",
+    "za": "Zhuang",
+    "zh": "Chinese",
+    "zh-CH": "Chinese",
+    "zh-TW": "Chinese",
+    "zh-Hans": "Chinese",
+    "zh-Hans-HK": "Chinese",
+    "zh-Hans-MO": "Chinese",
+    "zh-Hans-MY": "Chinese",
+    "zh-Hans-SG": "Chinese",
+    "zh-Hant": "Chinese",
+    "zh-Hant-HK": "Chinese",
+    "zh-Hant-MO": "Chinese",
+    "zh-Hant-MY": "Chinese",
+    "zh-Latn": "Chinese",
+    "zu": "Zulu",
+    "zu-ZA": "Zulu",
+}
+-%}
+{{ bos_token }}
+{%- if (messages[0]['role'] != 'user') -%}
+    {{ raise_exception("Conversations must start with a user prompt.") }}
+{%- endif -%}
+{%- for message in messages -%}
+    {%- if (message['role'] == 'user') != (loop.index0 % 2 == 0) -%}
+        {{ raise_exception("Conversation roles must alternate user/assistant/user/assistant/...") }}
+    {%- endif -%}
+    {%- if (message['role'] == 'assistant') -%}
+        {%- if message['content'] is none or message['content'] is not string -%}
+            {{ raise_exception("Assistant role must provide content as a string") }}
+        {%- endif -%}
+        {{ '<start_of_turn>model\n'}}
+        {{ message["content"] | trim }}
+    {%- elif (message['role'] == 'user') -%}
+        {%- if message['content'] is none or message['content'] is not iterable or message['content'] | length != 1 -%}
+            {{ raise_exception(
+                "User role must provide `content` as an iterable with exactly one item. That item must be a " +
+                "`mapping(type:'text' | 'image', source_lang_code:string, target_lang_code:string, " +
+                "text:string | none, image:string | none)`."
+            ) }}
+        {%- endif -%}
+        {%- set content = message["content"][0] -%}
+        {%- set source_lang_code = content["source_lang_code"] | replace("_", "-")  -%}
+        {%- set source_lang = languages[source_lang_code] -%}
+        {%- set target_lang_code = content["target_lang_code"] | replace("_", "-") -%}
+        {%- set target_lang = languages[target_lang_code] -%}
+        {{ '<start_of_turn>user\nYou are a professional ' + source_lang + ' (' + source_lang_code + ') to ' +
+           target_lang + ' (' + target_lang_code + ') translator. Your goal is to accurately convey the meaning and '
+           'nuances of the original ' + source_lang + ' text while adhering to ' + target_lang + ' grammar, '
+           'vocabulary, and cultural sensitivities.\n'
+        }}
+        {%- if content["type"] == 'text' -%}
+            {{
+                'Produce only the ' + target_lang + ' translation, without any additional explanations or ' +
+                'commentary. Please translate the following ' + source_lang + ' text into ' + target_lang + ':\n\n\n' +
+                content["text"] | trim
+            }}
+        {%- elif content["type"] == 'image' -%}
+            {{
+                'Please translate the ' + source_lang + ' text in the provided image into ' + target_lang + '. ' +
+                'Produce only the ' + target_lang + ' translation, without any additional explanations, ' +
+                'alternatives or commentary. Focus only on the text, do not output where the text is located, ' +
+                'surrounding objects or any other explanation about the picture. Ignore symbols, pictogram, and ' +
+                'arrows!\n\n\n<start_of_image>'
+            }}
+        {%- else -%}
+            {{ raise_exception("User role `content` can contain either `text` or `image` data.") }}
+        {%- endif -%}
+    {%- else -%}
+        {{ raise_exception("Conversations must only contain user or assistant roles.") }}
+    {%- endif -%}
+    {{ '<end_of_turn>\n' }}
+{%- endfor -%}
+{%- if add_generation_prompt -%}
+    {{'<start_of_turn>model\n'}}
+{%- endif -%}
diff --git a/tests/test-chat.cpp b/tests/test-chat.cpp
index f0aec5bbe..41e4948c4 100644
--- a/tests/test-chat.cpp
+++ b/tests/test-chat.cpp
@@ -7192,6 +7192,13 @@ static void test_template_output_peg_parsers(bool detailed_debug) {
             .run();
     }

+    // TranslateGemma
+    {
+        // no reconstruction check, the template adds whitespace around assistant content
+        auto tst = peg_tester("models/templates/google-translategemma-4b-it.jinja", detailed_debug);
+        tst.test("Hello, world!\nWhat's up?").expect(message_assist).run();
+    }
+
     // MiniCPM5 - XML tool calls with <function name="..."><param name="...">...</param></function>
     {
         auto tst = peg_tester("models/templates/openbmb-MiniCPM5-1B.jinja", detailed_debug);
@@ -7609,6 +7616,43 @@ static void test_developer_role_to_system_workaround() {
     }
 }

+// TranslateGemma raises on plain string user content, the specialized handler must rewrite it
+static void test_translate_gemma() {
+    LOG_DBG("%s\n", __func__);
+
+    auto tmpls = read_templates("models/templates/google-translategemma-4b-it.jinja");
+
+    // server startup renders this example, it must not throw
+    auto example = common_chat_format_example(tmpls.get(), /* use_jinja= */ true, {});
+    assert_contains(example, "English (en-GB) to English (en-GB) translator");
+    assert_contains(example, "How are you?");
+
+    common_chat_templates_inputs inputs;
+    inputs.messages                                 = { message_user };
+    inputs.add_generation_prompt                    = true;
+    inputs.chat_template_kwargs["source_lang_code"] = R"("en")";
+    inputs.chat_template_kwargs["target_lang_code"] = R"("fr")";
+
+    auto params = common_chat_templates_apply(tmpls.get(), inputs);
+    assert_contains(params.prompt, "English (en) to French (fr) translator");
+    assert_contains(params.prompt, "into French:\n\n\nHey there!<end_of_turn>\n");
+    assert_equals(std::string("<start_of_turn>model\n"), params.generation_prompt);
+    assert_ends_with(params.prompt, params.generation_prompt);
+
+    // typed text parts are joined into one item
+    inputs.messages = { message_user_parts };
+    params = common_chat_templates_apply(tmpls.get(), inputs);
+    assert_contains(params.prompt, "into French:\n\n\nHey\nthere<end_of_turn>\n");
+
+    // assistant prefill is appended after the generation prompt
+    inputs.messages               = { message_user, message_assist_prefill_content };
+    inputs.add_generation_prompt  = false;
+    inputs.continue_final_message = COMMON_CHAT_CONTINUATION_CONTENT;
+    params = common_chat_templates_apply(tmpls.get(), inputs);
+    assert_equals(std::string("<start_of_turn>model\nHello, "), params.generation_prompt);
+    assert_ends_with(params.prompt, "Hey there!<end_of_turn>\n<start_of_turn>model\nHello, ");
+}
+
 // Verify reasoning-trace retention rules in the DeepSeek-V4 template:
 // all traces are retained unless drop_thinking is true AND the conversation
 // has no tool calls, in which case only the last (after-final-user) trace is
@@ -7986,6 +8030,7 @@ int main(int argc, char ** argv) {
         test_tools_oaicompat_json_conversion();
         test_convert_responses_to_chatcmpl();
         test_developer_role_to_system_workaround();
+        test_translate_gemma();
         test_deepseek_v4_thinking_retention();
         test_deepseek_v4_tool_result_ordering();
         test_template_generation_prompt();