ember.cpp 0.1.0
C++ inference engine for Llama-family and paperkiln models (CPU, CUDA, WebAssembly)
Loading...
Searching...
No Matches
chat_template.h
Go to the documentation of this file.
1// Vendored verbatim from paperkiln (microtorch) include/microtorch/chat_template.hpp
2// at paperkiln 877fb0d0, whose tests/chat_template_cases.json ember's
3// tools/chat_template_check.cpp runs. ember.cpp renders every chat prompt through it
4// (chat_tokenizer.h). Do not edit here: change it in paperkiln (its tests check C++/Python
5// parity) and re-vendor.
6#pragma once
7// Conversations and chat templates (LOCAL_LLM_STACK.md section 3, points 3-5): the C++ twin of
8// tools/chat_template.py, header-only, for mtstudio and for ember.cpp to vendor (as it vendors
9// bpe_tokenizer.hpp). Depends on the standard library and nlohmann/json only (as ir.hpp does;
10// ember vendors nlohmann/json too). tests/test_chat_template.cpp runs the cases file
11// tests/chat_template_cases.json, which tests/test_chat_template.py writes from the Python twin.
12//
13// A conversation is a list of messages {role: system | user | assistant, content, loss?}: at most
14// one system message, first; then user and assistant strictly alternating from the user. A prompt
15// conversation (a reply is wanted) ends on a user message, a training one on an assistant
16// message. The tool role is reserved and refused.
17//
18// A template is a named, versioned record (GGUF key paperkiln.chat_template, the record as JSON):
19// bos, then each message as open + content + close, the system message by its mode (fold: its
20// text + join opens the first user message's content; turn: open + text + close before the first
21// exchange; none: refused), then generation_prompt when a reply is wanted. Content holding one of
22// the record's `reserved` strings is refused (the Python twin and its parse refuse the same).
23// content "trim" strips from each message's own content exactly what Python's str.strip() strips
24// (HF's Jinja trim): the 29 code points str.isspace() accepts, UTF-8 encoded. past_assistant "strip_think" renders an
25// assistant message before the last user message as its content after the last "</think>", with
26// leading newlines stripped, when it contains "</think>"; prompt conversations always end on a
27// user message, so HF's re-wrapping of replies after the last user message never arises and is
28// not implemented (render refuses a training conversation under strip_think). A close's trailing
29// text after its stop token (ChatML's "<|im_end|>\n") is rendered in the next prompt and never
30// generated: the stop sets hold the stop tokens only, never "\n", and engines re-render the
31// history every turn.
32//
33// Resolution (resolve(), the same in every engine): (1) a declared paperkiln.chat_template;
34// (2) ids 0-3 are "<unk>", "<|endoftext|>", "<|user|>", "<|assistant|>" (both paperkiln BPE
35// vocabularies, 8k "bpe" and 16k "llama3_bpe"): paperkiln/1; (3) tokenizer.ggml.model "word":
36// paperkiln/legacy-word; (4) a third-party record whose `match` holds the sha256 of the file's
37// exact tokenizer.chat_template, else whose `markers` are each a single token and whose
38// `architectures` hold general.architecture (llama.cpp b6000's Gemma 3 GGUF has no
39// tokenizer.chat_template); (5) no chat mode, with the reason.
40//
41// v1's defaults, each behind one constant shared with tools/chat_template.py: a system message
42// folds into the first user message (kSystemModeV1, SYSTEM_MODE_V1 there), and the SFT loss
43// covers every assistant turn (kDefaultSftTurns, DEFAULT_SFT_TURNS there).
44//
45// Schema v2 (paperkiln/2, LOCAL_LLM_STACK.md section 3, point 3g; never inferred, always declared):
46// the system message is its own turn behind <|system|> (no fold); an assistant message may carry
47// tool_calls [{id?, type?: "function", function: {name, arguments}}], each rendered after its
48// content as <|tool_call|> + name + "\n" + arguments (the arguments string verbatim); exactly as
49// many tool messages follow, in call order (tool_call_id, when given, equals the call's id, a call
50// without an id counting as id null, keramos IR_SPEC 8.1.6; ids are never rendered), each
51// <|tool|> + content, then an assistant message; tool definitions (JSON
52// objects) render inside the system turn as Python's json.dumps(d, ensure_ascii=False) text
53// (detail::dump_py; HF's tojson), joined by "\n", after the system text and "\n\n". A prompt
54// conversation ends on a user message or on the last tool message of a call. parse_reply splits a
55// generated reply on the <|tool_call|> id, never on text. A user/assistant-only conversation renders
56// exactly as under paperkiln/1. The eight control tokens (kV2Control) are appended after the
57// vocabulary's last id with GGUF token type 3; check_control_tokens refuses a file without them.
58#include <array>
59#include <cstdint>
60#include <cstdio>
61#include <stdexcept>
62#include <string>
63#include <utility>
64#include <vector>
65
66#include <nlohmann/json.hpp>
67
68namespace microtorch {
69namespace chat {
70
71using json = nlohmann::ordered_json;
72
73inline constexpr const char* kSystemModeV1 = "fold";
74inline constexpr const char* kDefaultSftTurns = "all";
75inline constexpr const char* kGGUFKey = "paperkiln.chat_template";
76inline constexpr const char* kV1 = "paperkiln/1";
77inline constexpr const char* kLegacyWord = "paperkiln/legacy-word";
78inline constexpr const char* kV2 = "paperkiln/2";
79inline const std::array<const char*, 4> kV1Specials = {"<unk>", "<|endoftext|>", "<|user|>", "<|assistant|>"};
80// paperkiln/2's control tokens, appended in this order after a vocabulary's last id (the 16k
81// Llama 3-scheme vocabulary: ids 16384-16391), GGUF token type 3.
82inline const std::array<const char*, 8> kV2Control = {"<|system|>", "<|tool_call|>", "<|tool|>", "<|spare_3|>",
83 "<|spare_4|>", "<|spare_5|>", "<|spare_6|>", "<|spare_7|>"};
84
85struct Error : std::runtime_error {
86 using std::runtime_error::runtime_error;
87};
88
89struct Template {
90 std::string name;
91 json version; // an integer (1) or a string ("legacy-word")
92 std::string bos, content = "raw";
95 std::string generation_prompt;
96 bool has_nothink = false;
98 std::vector<std::string> stop_eos, stop_turn;
99 std::string past_assistant = "keep";
100 bool has_default_system = false;
101 std::string default_system;
102 std::vector<std::string> reserved, markers, match, architectures;
103 // v2: tool turns (`tool` {open, close} and `tool_call` {open, join} come together) and tool
104 // definitions (`tools` {join, sep}).
105 bool has_tool = false, has_tools = false;
107
108 std::string id() const { return name + "/" + (version.is_string() ? version.get<std::string>() : version.dump()); }
109
110 // The record, in tools/chat_template.py's field order.
111 json to_json() const {
112 json r = {{"name", name}, {"version", version}, {"bos", bos}, {"content", content}};
113 json s = {{"mode", system_mode}};
114 if (system_mode == "fold") s["join"] = system_join;
115 if (system_mode == "turn") {
116 s["open"] = system_open;
117 s["close"] = system_close;
118 }
119 r["system"] = s;
120 r["user"] = {{"open", user_open}, {"close", user_close}};
121 r["assistant"] = {{"open", assistant_open}, {"close", assistant_close}};
122 if (has_tool) {
123 r["tool"] = {{"open", tool_open}, {"close", tool_close}};
124 r["tool_call"] = {{"open", tool_call_open}, {"join", tool_call_join}};
125 }
126 if (has_tools) r["tools"] = {{"join", tools_join}, {"sep", tools_sep}};
127 r["generation_prompt"] = generation_prompt;
128 r["stop"] = {{"eos", stop_eos}, {"turn", stop_turn}};
129 r["past_assistant"] = past_assistant;
130 r["reserved"] = reserved;
131 if (!markers.empty()) r["markers"] = markers;
132 if (has_default_system) r["default_system"] = default_system;
133 if (has_nothink) r["generation_prompt_nothink"] = generation_prompt_nothink;
134 if (!match.empty()) r["match"] = match;
135 if (!architectures.empty()) r["architectures"] = architectures;
136 return r;
137 }
138
139 // A record from JSON; an unknown field or value is refused (never guessed).
140 template <class J>
141 static Template from_json(const J& r) {
142 static const char* known[] = {"name", "version", "bos", "content", "system", "user", "assistant",
143 "generation_prompt", "generation_prompt_nothink", "stop", "past_assistant",
144 "default_system", "reserved", "markers", "match", "architectures",
145 "tool", "tool_call", "tools"};
146 if (!r.is_object()) throw Error("chat template: the record is not a JSON object");
147 for (auto it = r.begin(); it != r.end(); ++it) {
148 const std::string k = it.key();
149 bool ok = !k.empty() && k[0] == '_';
150 for (const char* q : known) ok = ok || k == q;
151 if (!ok) throw Error("chat template field '" + k + "' is not implemented");
152 }
153 for (const char* k : {"name", "version", "user", "assistant", "generation_prompt", "stop"})
154 if (!r.contains(k)) throw Error(std::string("chat template has no '") + k + "'");
155 Template t;
156 t.name = r["name"].template get<std::string>();
157 if (!r["version"].is_string() && !r["version"].is_number_integer())
158 throw Error("chat template version must be an integer or a string");
159 t.version = json::parse(r["version"].dump());
160 t.bos = r.value("bos", std::string());
161 t.content = r.value("content", std::string("raw"));
162 if (t.content != "raw" && t.content != "trim")
163 throw Error("chat template content '" + t.content + "' is not implemented (raw, trim)");
164 if (r.contains("system")) {
165 const auto& s = r["system"];
166 t.system_mode = s.value("mode", std::string("none"));
167 if (t.system_mode != "fold" && t.system_mode != "turn" && t.system_mode != "none")
168 throw Error("chat template system mode '" + t.system_mode + "' is not implemented (fold, turn, none)");
169 t.system_join = s.value("join", std::string());
170 t.system_open = s.value("open", std::string());
171 t.system_close = s.value("close", std::string());
172 }
173 auto side = [&](const char* k, std::string& open, std::string& close) {
174 const auto& s = r[k];
175 for (auto it = s.begin(); it != s.end(); ++it)
176 if (it.key() != "open" && it.key() != "close")
177 throw Error(std::string("chat template ") + k + " takes open and close only");
178 open = s.value("open", std::string());
179 close = s.value("close", std::string());
180 };
181 side("user", t.user_open, t.user_close);
182 side("assistant", t.assistant_open, t.assistant_close);
183 auto pair = [&](const char* k, const char* a, const char* b, std::string& x, std::string& y) {
184 const auto& s = r[k];
185 if (!s.is_object()) throw Error(std::string("chat template ") + k + " is an object");
186 for (auto it = s.begin(); it != s.end(); ++it)
187 if (it.key() != a && it.key() != b)
188 throw Error(std::string("chat template ") + k + " takes " + a + " and " + b + " only");
189 x = s.value(a, std::string());
190 y = s.value(b, std::string());
191 };
192 if (r.contains("tool") != r.contains("tool_call")) throw Error("chat template tool and tool_call come together");
193 if (r.contains("tools") && !r.contains("tool")) throw Error("chat template tools needs tool and tool_call");
194 if (r.contains("tool")) {
195 if (t.system_mode != "turn") throw Error("chat template tool turns need system mode turn");
196 t.has_tool = true;
197 pair("tool", "open", "close", t.tool_open, t.tool_close);
198 pair("tool_call", "open", "join", t.tool_call_open, t.tool_call_join);
199 }
200 if (r.contains("tools")) {
201 t.has_tools = true;
202 pair("tools", "join", "sep", t.tools_join, t.tools_sep);
203 }
204 t.generation_prompt = r["generation_prompt"].template get<std::string>();
205 if (r.contains("generation_prompt_nothink")) {
206 t.has_nothink = true;
207 t.generation_prompt_nothink = r["generation_prompt_nothink"].template get<std::string>();
208 }
209 auto strs = [&](const J& a) {
210 std::vector<std::string> v;
211 for (const auto& x : a) v.push_back(x.template get<std::string>());
212 return v;
213 };
214 t.stop_eos = strs(r["stop"].value("eos", J::array()));
215 t.stop_turn = strs(r["stop"].value("turn", J::array()));
216 t.past_assistant = r.value("past_assistant", std::string("keep"));
217 if (t.past_assistant != "keep" && t.past_assistant != "strip_think")
218 throw Error("chat template past_assistant '" + t.past_assistant + "' is not implemented");
219 if (r.contains("default_system")) {
220 t.has_default_system = true;
221 t.default_system = r["default_system"].template get<std::string>();
222 }
223 if (r.contains("reserved")) t.reserved = strs(r["reserved"]);
224 if (r.contains("markers")) t.markers = strs(r["markers"]);
225 if (r.contains("match")) t.match = strs(r["match"]);
226 if (r.contains("architectures")) t.architectures = strs(r["architectures"]);
227 return t;
228 }
229};
230
231// ---- the registry ---------------------------------------------------------------------------
232inline Template v1() {
233 Template t;
234 t.name = "paperkiln";
235 t.version = 1;
236 t.system_mode = kSystemModeV1;
237 t.system_join = "\n\n";
238 t.user_open = "<|user|>";
239 t.assistant_open = "<|assistant|>";
240 t.assistant_close = "<|endoftext|>";
241 t.generation_prompt = "<|assistant|>";
242 t.stop_eos = {"<|endoftext|>"};
243 t.stop_turn = {"<|user|>", "<|assistant|>"};
244 t.reserved = {"<|endoftext|>", "<|user|>", "<|assistant|>"};
245 return t;
246}
247
248// Schema v2: tools/chat_template.py _v2(), field for field.
249inline Template v2() {
250 Template t = v1();
251 t.version = 2;
252 t.system_mode = "turn";
253 t.system_join.clear();
254 t.system_open = "<|system|>";
255 t.has_tool = true;
256 t.tool_open = "<|tool|>";
257 t.tool_call_open = "<|tool_call|>";
258 t.tool_call_join = "\n";
259 t.has_tools = true;
260 t.tools_join = "\n\n";
261 t.tools_sep = "\n";
262 t.stop_turn = {"<|user|>", "<|assistant|>", "<|system|>", "<|tool|>"};
263 for (const char* c : kV2Control) t.reserved.push_back(c);
264 return t;
265}
266
267// mtstudio's word-level form: "user: U assistant: A " per past turn, then "user: X assistant:".
268// eos is the vocabulary's end-of-text word (GGUF-defined); "user:" and "assistant:" stop as
269// multi-token sequences.
271 Template t;
272 t.name = "paperkiln";
273 t.version = "legacy-word";
274 t.system_mode = kSystemModeV1;
275 t.system_join = "\n\n";
276 t.user_open = "user: ";
277 t.user_close = " ";
278 t.assistant_open = "assistant: ";
279 t.assistant_close = " ";
280 t.generation_prompt = "assistant:";
281 t.stop_turn = {"user:", "assistant:"};
282 return t;
283}
284
285// Third-party records: ember owns them and gates them against HF apply_chat_template (G5).
286inline Template chatml(const std::string& name, bool qwen25) {
287 Template t;
288 t.name = name;
289 t.version = 1;
290 t.system_mode = "turn";
291 t.system_open = "<|im_start|>system\n";
292 t.system_close = "<|im_end|>\n";
293 t.user_open = "<|im_start|>user\n";
294 t.user_close = "<|im_end|>\n";
295 t.assistant_open = "<|im_start|>assistant\n";
296 t.assistant_close = "<|im_end|>\n";
297 t.generation_prompt = "<|im_start|>assistant\n";
298 t.stop_eos = {"<|im_end|>", "<|endoftext|>"};
299 t.reserved = {"<|im_start|>", "<|im_end|>", "<|endoftext|>"};
300 t.markers = {"<|im_start|>", "<|im_end|>"};
301 if (qwen25) {
302 t.has_default_system = true;
303 t.default_system = "You are Qwen, created by Alibaba Cloud. You are a helpful assistant.";
304 t.match = {"d5495a1e5db0611132a97e46a65dbb64a642a499421228b9c8b93229097fa9a4"};
305 t.architectures = {"qwen2"};
306 } else {
307 t.past_assistant = "strip_think";
308 t.has_nothink = true;
309 t.generation_prompt_nothink = "<|im_start|>assistant\n<think>\n\n</think>\n\n";
310 t.match = {"57f1fd00f0013a2be96aa79b857391f27e23df5b5f847072b524c897e24d0361"};
311 t.architectures = {"qwen3"};
312 }
313 return t;
314}
315
316inline Template gemma3() {
317 Template t;
318 t.name = "gemma3";
319 t.version = 1;
320 t.bos = "<bos>";
321 t.content = "trim";
322 t.system_mode = "fold";
323 t.system_join = "\n\n";
324 t.user_open = "<start_of_turn>user\n";
325 t.user_close = "<end_of_turn>\n";
326 t.assistant_open = "<start_of_turn>model\n";
327 t.assistant_close = "<end_of_turn>\n";
328 t.generation_prompt = "<start_of_turn>model\n";
329 t.stop_eos = {"<end_of_turn>", "<eos>"};
330 t.reserved = {"<start_of_turn>", "<end_of_turn>", "<bos>", "<eos>"};
331 t.markers = {"<start_of_turn>", "<end_of_turn>"};
332 t.match = {"7de1c58e208eda46e9c7f86397df37ec49883aeece39fb961e0a6b24088dd3c4"};
333 t.architectures = {"gemma3"};
334 return t;
335}
336
337inline std::vector<Template> registry() {
338 return {v1(), v2(), legacy_word(), chatml("chatml-qwen2.5", true), chatml("chatml-qwen3", false), gemma3()};
339}
340
341inline Template get(const std::string& id) {
342 std::string known;
343 for (const auto& t : registry()) {
344 if (t.id() == id) return t;
345 known += (known.empty() ? "" : ", ") + t.id();
346 }
347 throw Error("no chat template '" + id + "' (known: " + known + ")");
348}
349
350// ---- conversations ---------------------------------------------------------------------------
351// A v2 tool call: {id?, type?: "function", function: {name, arguments}}.
352struct ToolCall {
353 bool has_id = false;
354 std::string id, name, arguments;
355};
356
357struct Message {
358 std::string role, content;
359 int loss = -1; // -1 unset, 0 false, 1 true (assistant messages only)
360 bool has_tool_calls = false; // the message has a tool_calls member (refused outside v2 assistant messages)
361 std::vector<ToolCall> tool_calls;
362 bool has_tool_call_id = false; // tool messages: the call's id, checked, never rendered
363 std::string tool_call_id;
364 bool tool_call_id_null = false; // tool_call_id null: answers a call without an id (IR_SPEC 8.1.6)
365
366 Message() = default;
367 Message(std::string r, std::string c, int l = -1) : role(std::move(r)), content(std::move(c)), loss(l) {}
368};
369
370template <class J>
371std::vector<Message> messages_from_json(const J& a) {
372 if (!a.is_array()) throw Error("a conversation needs a messages list");
373 std::vector<Message> out;
374 for (const auto& m : a) {
375 if (!m.is_object() || !m.contains("content") || !m["content"].is_string())
376 throw Error("every message needs a role and a string content");
377 Message x;
378 x.role = m.value("role", std::string());
379 x.content = m["content"].template get<std::string>();
380 if (m.contains("loss")) {
381 if (!m["loss"].is_boolean()) throw Error("loss is a bool on assistant messages only");
382 x.loss = m["loss"].template get<bool>() ? 1 : 0;
383 }
384 if (m.contains("tool_calls")) {
385 x.has_tool_calls = true;
386 const auto& tc = m["tool_calls"];
387 if (!tc.is_null()) {
388 if (!tc.is_array() || tc.empty()) throw Error("tool_calls is a non-empty list");
389 for (const auto& c : tc) {
390 if (!c.is_object() || !c.contains("function") || !c["function"].is_object())
391 throw Error("a tool call is {id?, type?: function, function: {name, arguments}}");
392 if (c.contains("type") && !(c["type"].is_string() && c["type"].template get<std::string>() == "function"))
393 throw Error("tool call type is not implemented (function)");
394 const auto& f = c["function"];
395 ToolCall k;
396 if (!f.contains("name") || !f["name"].is_string())
397 throw Error("a tool call's name is a non-empty string without a newline");
398 k.name = f["name"].template get<std::string>();
399 if (k.name.empty() || k.name.find('\n') != std::string::npos)
400 throw Error("a tool call's name is a non-empty string without a newline");
401 if (f.contains("arguments")) {
402 if (!f["arguments"].is_string()) throw Error("a tool call's arguments are a string (rendered verbatim)");
403 k.arguments = f["arguments"].template get<std::string>();
404 }
405 if (c.contains("id")) {
406 if (!c["id"].is_string()) throw Error("a tool call's id is a string");
407 k.has_id = true;
408 k.id = c["id"].template get<std::string>();
409 }
410 x.tool_calls.push_back(std::move(k));
411 }
412 }
413 }
414 if (m.contains("tool_call_id")) {
415 x.has_tool_call_id = true;
416 if (m["tool_call_id"].is_null())
417 x.tool_call_id_null = true;
418 else if (!m["tool_call_id"].is_string())
419 throw Error("tool_call_id is a string or null");
420 else
421 x.tool_call_id = m["tool_call_id"].template get<std::string>();
422 }
423 out.push_back(std::move(x));
424 }
425 return out;
426}
427
428// Messages from (user, assistant) pairs, then `user` when has_user.
429inline std::vector<Message> from_pairs(const std::vector<std::pair<std::string, std::string>>& turns,
430 const std::string* user = nullptr, const std::string* system = nullptr) {
431 std::vector<Message> m;
432 if (system) m.push_back({"system", *system});
433 for (const auto& t : turns) {
434 m.push_back({"user", t.first});
435 m.push_back({"assistant", t.second});
436 }
437 if (user) m.push_back({"user", *user});
438 return m;
439}
440
441// The structure rules (tools/chat_template.py _structure). Returns the index of the first
442// non-system message. tools_ok: the record defines tool turns (v2).
443inline size_t check_structure(const std::vector<Message>& m, bool prompt, bool tools_ok = false) {
444 if (m.empty()) throw Error("a conversation needs a non-empty messages list");
445 for (const auto& x : m) {
446 if (x.role == "tool" && !tools_ok) throw Error("role 'tool' is reserved: no template defines it yet");
447 if (x.role != "system" && x.role != "user" && x.role != "assistant" && x.role != "tool")
448 throw Error("unknown role '" + x.role + "'");
449 if (x.has_tool_calls && (!tools_ok || x.role != "assistant"))
450 throw Error("tool_calls belong on assistant messages of a template with tool turns");
451 if (x.has_tool_call_id && x.role != "tool") throw Error("tool_call_id belongs on tool messages");
452 }
453 const size_t first = m[0].role == "system" ? 1 : 0;
454 if (first == m.size()) throw Error("a system message needs a user message after it");
455 std::string want = "user";
456 size_t pending = 0, call = 0; // results still owed to the last calls; the next call they answer
457 const Message* caller = nullptr;
458 for (size_t i = first; i < m.size(); ++i) {
459 if (m[i].role == "system") throw Error("a system message is allowed only as the first message");
460 if (m[i].role != want) {
461 if (!tools_ok)
462 throw Error("message " + std::to_string(i) + " is " + m[i].role + ", expected " + want +
463 " (user and assistant alternate from the user)");
464 throw Error("message " + std::to_string(i) + " is " + m[i].role + ", expected " + want +
465 " (user, then assistant; an assistant message with k tool calls, then k tool messages, then "
466 "assistant)");
467 }
468 if (m[i].loss >= 0 && m[i].role != "assistant") throw Error("loss is a bool on assistant messages only");
469 if (m[i].role == "user") {
470 want = "assistant";
471 } else if (m[i].role == "assistant") {
472 pending = m[i].tool_calls.size();
473 call = 0;
474 caller = &m[i];
475 want = pending ? "tool" : "user";
476 } else {
477 const ToolCall& c = caller->tool_calls[call++];
478 --pending;
479 // A call without an id counts as id null (IR_SPEC 8.1.6): a null tool_call_id answers only such a call.
480 if (m[i].has_tool_call_id && m[i].tool_call_id_null && c.has_id)
481 throw Error("tool_call_id null is not the call's id '" + c.id + "' (tool messages answer the calls in "
482 "order; null answers a call without an id)");
483 if (m[i].has_tool_call_id && !m[i].tool_call_id_null && (!c.has_id || m[i].tool_call_id != c.id))
484 throw Error("tool_call_id '" + m[i].tool_call_id + "' is not the call's id (tool messages answer the "
485 "calls in order)");
486 want = pending ? "tool" : "assistant";
487 }
488 }
489 const std::string& last = m.back().role;
490 if (prompt && !(last == "user" || (last == "tool" && pending == 0)))
491 throw Error(std::string("a prompt conversation ends on a user message") +
492 (tools_ok ? " or on the last tool message of a call" : ""));
493 if (!prompt && last != "assistant") throw Error("a training conversation ends on an assistant message");
494 return first;
495}
496
497struct Piece {
498 std::string text, role, kind; // kind: bos | open | content | call_open | call | close | generation
499 int index; // the message's position; -1 for bos, the generation prompt and a default system
500};
501
502// Python's str.strip(): drop leading and trailing code points for which str.isspace() is true.
503// Invalid UTF-8 at an end is kept (never stripped).
504inline bool py_space(uint32_t c) {
505 return (c >= 0x09 && c <= 0x0d) || (c >= 0x1c && c <= 0x20) || c == 0x85 || c == 0xa0 || c == 0x1680 ||
506 (c >= 0x2000 && c <= 0x200a) || c == 0x2028 || c == 0x2029 || c == 0x202f || c == 0x205f ||
507 c == 0x3000;
508}
509
510inline std::string strip_py_ws(const std::string& s) {
511 // Decode the code point starting at i; returns its length (0 when invalid).
512 auto decode = [&](size_t i, uint32_t& c) -> size_t {
513 const unsigned char b = static_cast<unsigned char>(s[i]);
514 size_t n = b < 0x80 ? 1 : (b >> 5) == 0x6 ? 2 : (b >> 4) == 0xe ? 3 : (b >> 3) == 0x1e ? 4 : 0;
515 if (n == 0 || i + n > s.size()) return 0;
516 c = n == 1 ? b : b & (0xff >> (n + 1));
517 for (size_t k = 1; k < n; ++k) {
518 const unsigned char x = static_cast<unsigned char>(s[i + k]);
519 if ((x >> 6) != 0x2) return 0;
520 c = (c << 6) | (x & 0x3f);
521 }
522 return n;
523 };
524 size_t b = 0;
525 while (b < s.size()) {
526 uint32_t c = 0;
527 const size_t n = decode(b, c);
528 if (n == 0 || !py_space(c)) break;
529 b += n;
530 }
531 size_t e = s.size();
532 while (e > b) {
533 size_t st = e - 1; // back up to the lead byte of the last code point
534 while (st > b && (static_cast<unsigned char>(s[st]) >> 6) == 0x2 && e - st < 4) --st;
535 uint32_t c = 0;
536 const size_t n = decode(st, c);
537 if (n != e - st || !py_space(c)) break;
538 e = st;
539 }
540 return s.substr(b, e - b);
541}
542
543namespace detail {
544// Python's json.dumps(x, ensure_ascii=False) with its default separators ", " and ": ", member order
545// kept (HF transformers' Jinja tojson). Strings are escaped as nlohmann::json writes them, which is
546// Python's escaping without ensure_ascii; non-integer numbers are refused (tool_json in Python).
547inline std::string dump_py(const json& x) {
548 if (x.is_object()) {
549 std::string s = "{";
550 bool first = true;
551 for (auto it = x.begin(); it != x.end(); ++it) {
552 if (!first) s += ", ";
553 first = false;
554 s += json(it.key()).dump() + ": " + dump_py(it.value());
555 }
556 return s + "}";
557 }
558 if (x.is_array()) {
559 std::string s = "[";
560 for (size_t i = 0; i < x.size(); ++i) s += (i ? ", " : "") + dump_py(x[i]);
561 return s + "]";
562 }
563 if (x.is_number_float())
564 throw Error("a tool definition holds a non-integer number (refused: its text is not portable)");
565 return x.dump();
566}
567} // namespace detail
568
569// A tool definition's text (tools/chat_template.py tool_json).
570inline std::string tool_json(const json& d) {
571 if (!d.is_object()) throw Error("a tool definition is a JSON object");
572 return detail::dump_py(d);
573}
574
575// thinking: -1 the template's default (HF's: on), 0 off (generation_prompt_nothink when the record
576// has one), 1 on. tools: v2 tool definitions, rendered inside the system turn (parse requests with
577// ordered_json so that member order is the request's).
578inline std::vector<Piece> render_pieces(const std::vector<Message>& m, const Template& t, bool generation_prompt,
579 int thinking = -1, const std::vector<json>& tools = {}) {
580 if (!tools.empty() && !t.has_tools) throw Error("this model's template has no tool definitions");
581 const size_t first = check_structure(m, generation_prompt, t.has_tool);
582 const bool trim = t.content == "trim";
583 auto check_reserved = [&](const std::string& s) {
584 for (const auto& r : t.reserved)
585 if (!r.empty() && s.find(r) != std::string::npos)
586 throw Error("content holds the template's reserved text '" + r + "'");
587 return s;
588 };
589 auto body = [&](std::string s) {
590 if (trim) s = strip_py_ws(s);
592 return s;
593 };
594 std::vector<Piece> out;
595 auto add = [&](const std::string& text, const std::string& role, const char* kind, int idx) {
596 if (!text.empty()) out.push_back({text, role, kind, idx});
597 };
598 add(t.bos, "", "bos", -1);
599 bool have_sys = first == 1;
600 std::string sys = have_sys ? m[0].content : std::string();
601 const int sys_idx = have_sys ? 0 : -1;
602 if (!have_sys && t.has_default_system && !t.default_system.empty()) {
603 have_sys = true;
604 sys = t.default_system;
605 }
606 std::string defs;
607 for (size_t k = 0; k < tools.size(); ++k) defs += (k ? t.tools_sep : "") + check_reserved(tool_json(tools[k]));
608 if (!tools.empty()) {
609 const std::string head = have_sys ? body(sys) : std::string();
610 sys = (head.empty() ? std::string() : head + t.tools_join) + defs;
611 have_sys = true;
612 }
613 bool fold = false;
614 std::string fold_text;
615 if (have_sys) {
616 if (t.system_mode == "none") throw Error("this model's template has no system role");
617 if (t.system_mode == "turn") {
618 add(t.system_open, "system", "open", sys_idx);
619 add(tools.empty() ? body(sys) : sys, "system", "content", sys_idx);
620 add(t.system_close, "system", "close", sys_idx);
621 } else {
622 check_reserved(sys);
623 fold = true;
624 fold_text = sys + t.system_join;
625 }
626 }
627 size_t last_user = first;
628 for (size_t i = first; i < m.size(); ++i)
629 if (m[i].role == "user") last_user = i;
630 for (size_t i = first; i < m.size(); ++i) {
631 const std::string& role = m[i].role;
632 const bool user = role == "user", tool = role == "tool";
633 std::string text = body(m[i].content);
634 if (user && fold) {
635 text = fold_text + text;
636 fold = false;
637 }
638 if (role == "assistant" && t.past_assistant == "strip_think" && i < last_user) {
639 const size_t p = text.rfind("</think>");
640 if (p != std::string::npos) {
641 text = text.substr(p + 8);
642 text.erase(0, text.find_first_not_of('\n') == std::string::npos ? text.size()
643 : text.find_first_not_of('\n'));
644 }
645 }
646 const int idx = static_cast<int>(i);
647 add(user ? t.user_open : tool ? t.tool_open : t.assistant_open, role, "open", idx);
648 add(text, role, "content", idx);
649 for (const auto& c : m[i].tool_calls) {
650 add(t.tool_call_open, role, "call_open", idx);
651 add(check_reserved(c.name) + t.tool_call_join + check_reserved(c.arguments), role, "call", idx);
652 }
653 add(user ? t.user_close : tool ? t.tool_close : t.assistant_close, role, "close", idx);
654 }
655 if (generation_prompt)
656 add(thinking == 0 && t.has_nothink ? t.generation_prompt_nothink : t.generation_prompt, "", "generation", -1);
657 else if (t.past_assistant == "strip_think")
658 throw Error("a strip_think template renders prompt conversations only (HF re-wraps the reasoning of "
659 "replies after the last user message; prompts never have one)");
660 return out;
661}
662
663inline std::string render(const std::vector<Message>& m, const Template& t, bool generation_prompt, int thinking = -1,
664 const std::vector<json>& tools = {}) {
665 std::string s;
666 for (const auto& p : render_pieces(m, t, generation_prompt, thinking, tools)) s += p.text;
667 return s;
668}
669
670// The exchanges after the system message, as [start, end) message index ranges: a user message and
671// everything before the next user message (replies, tool calls, tool results). The context rule
672// (section 3, point 5) drops the oldest whole exchange; the system turn is pinned.
673inline std::vector<std::pair<size_t, size_t>> exchanges(const std::vector<Message>& m) {
674 std::vector<std::pair<size_t, size_t>> out;
675 for (size_t i = 0; i < m.size(); ++i)
676 if (m[i].role == "user") {
677 if (!out.empty()) out.back().second = i;
678 out.push_back({i, m.size()});
679 }
680 return out;
681}
682
683// A generated v2 reply (ids without the stop token), split on ids, never on text: cut at every
684// call_id (the <|tool_call|> control token) and each part decoded. Part 0 is the content; every
685// later part must be name + "\n" + arguments with a non-empty name; the calls get ids call_0,
686// call_1, ... in order. parsed false: a part does not parse (the engine returns the whole decoded
687// reply as content). BPE pieces that spell "<|tool_call|>" are content.
689 bool parsed = false;
690 std::string content;
691 std::vector<ToolCall> tool_calls;
692};
693
694template <class Decode>
695ParsedReply parse_reply(const std::vector<int>& ids, int call_id, Decode decode) {
696 std::vector<std::vector<int>> parts(1);
697 for (int x : ids) {
698 if (x == call_id) parts.emplace_back();
699 else parts.back().push_back(x);
700 }
702 r.content = decode(parts[0]);
703 for (size_t k = 1; k < parts.size(); ++k) {
704 const std::string text = decode(parts[k]);
705 const size_t nl = text.find('\n');
706 if (nl == std::string::npos || nl == 0) return ParsedReply{false, {}, {}};
707 ToolCall c;
708 c.has_id = true;
709 c.id = "call_" + std::to_string(k - 1);
710 c.name = text.substr(0, nl);
711 c.arguments = text.substr(nl + 1);
712 r.tool_calls.push_back(std::move(c));
713 }
714 r.parsed = true;
715 return r;
716}
717
718// A paperkiln/2 file's vocabulary must hold each control token as one token (tokens: the GGUF's
719// tokenizer.ggml.tokens; types: tokenizer.ggml.token_type, 3 = control); otherwise it is refused.
720inline void check_control_tokens(const Template& t, const std::vector<std::string>& tokens,
721 const std::vector<int32_t>& types) {
722 if (!t.has_tool) return;
723 for (const auto& r : t.reserved) {
724 bool ok = false;
725 for (size_t i = 0; i < tokens.size() && !ok; ++i)
726 ok = tokens[i] == r && i < types.size() && types[i] == 3;
727 if (!ok) throw Error("chat template " + t.id() + " needs '" + r + "' as a control token of the vocabulary");
728 }
729}
730
731// ---- Jinja (llama.cpp --jinja, LM Studio) and an Ollama Modelfile, for paperkiln records -----
732namespace detail {
733inline std::string jstr(const std::string& s) {
734 std::string o = "'";
735 for (char c : s) {
736 if (c == '\\') o += "\\\\";
737 else if (c == '\'') o += "\\'";
738 else if (c == '\n') o += "\\n";
739 else o += c;
740 }
741 return o + "'";
742}
743inline std::string go(const std::string& s) {
744 std::string o;
745 for (size_t i = 0; i < s.size(); ++i) {
746 if (s.compare(i, 2, "{{") == 0) {
747 o += "{{ \"{{\" }}";
748 ++i;
749 } else {
750 o += s[i];
751 }
752 }
753 return o;
754}
755inline void paperkiln_only(const Template& t, const char* what) {
756 if (t.name != "paperkiln") throw Error(std::string(what) + " writes paperkiln records only");
757}
758} // namespace detail
759
760// tokenizer.chat_template: the same text as render() for HF-style messages (tools/chat_template.py
761// jinja(); the two are compared in the cases file).
762namespace detail {
763// tools/chat_template.py _jinja_tools: a record with tool turns (paperkiln/2).
764inline std::string jinja_tools(const Template& t) {
765 std::string j;
766 if (!t.bos.empty()) j += "{{- " + jstr(t.bos) + " -}}";
767 j += "{%- if messages and messages[0]['role'] == 'system' -%}"
768 "{%- set sys = messages[0]['content'] -%}{%- set msgs = messages[1:] -%}"
769 "{%- else -%}{%- set sys = none -%}{%- set msgs = messages -%}{%- endif -%}";
770 if (t.has_tools)
771 j += "{%- if tools -%}{%- set ns = namespace(d='') -%}{%- for x in tools -%}"
772 "{%- if not loop.first -%}{%- set ns.d = ns.d + " + jstr(t.tools_sep) + " -%}{%- endif -%}"
773 "{%- set ns.d = ns.d + (x | tojson) -%}{%- endfor -%}"
774 "{%- if sys -%}{%- set sys = sys + " + jstr(t.tools_join) + " + ns.d -%}"
775 "{%- else -%}{%- set sys = ns.d -%}{%- endif -%}{%- endif -%}";
776 j += "{%- if sys is not none -%}{{- " + jstr(t.system_open) + " + sys + " + jstr(t.system_close) +
777 " -}}{%- endif -%}";
778 j += "{%- for m in msgs -%}"
779 "{%- if m['role'] == 'user' -%}{{- " + jstr(t.user_open) + " + m['content'] + " + jstr(t.user_close) + " -}}"
780 "{%- elif m['role'] == 'assistant' -%}{{- " + jstr(t.assistant_open) + " + m['content'] -}}"
781 "{%- for c in (m['tool_calls'] or []) -%}{{- " + jstr(t.tool_call_open) + " + c['function']['name'] + " +
782 jstr(t.tool_call_join) + " -}}"
783 "{%- if c['function']['arguments'] is string -%}{{- c['function']['arguments'] -}}"
784 "{%- else -%}{{- c['function']['arguments'] | tojson -}}{%- endif -%}{%- endfor -%}"
785 "{{- " + jstr(t.assistant_close) + " -}}"
786 "{%- elif m['role'] == 'tool' -%}{{- " + jstr(t.tool_open) + " + m['content'] + " + jstr(t.tool_close) + " -}}"
787 "{%- else -%}{{ raise_exception('roles: one leading system message, then user, assistant and tool') }}"
788 "{%- endif -%}{%- endfor -%}"
789 "{%- if add_generation_prompt -%}{{- " + jstr(t.generation_prompt) + " -}}{%- endif -%}";
790 return j;
791}
792
793// tools/chat_template.py _modelfile_tools.
794inline std::string modelfile_tools(const std::string& gguf_name, const Template& t) {
795 std::string head = go(t.bos);
796 head += "{{ if or .System .Tools }}" + go(t.system_open) + "{{ if .System }}{{ .System }}{{ end }}"
797 "{{ if .Tools }}{{ if .System }}" + go(t.tools_join) + "{{ end }}"
798 "{{ range $i, $t := .Tools }}{{ if $i }}" + go(t.tools_sep) + "{{ end }}{{ json $t }}{{ end }}"
799 "{{ end }}" + go(t.system_close) + "{{ end }}";
800 const std::string msgs =
801 "{{ range .Messages }}{{ if eq .Role \"user\" }}" + go(t.user_open) + "{{ .Content }}" + go(t.user_close) +
802 "{{ else if eq .Role \"assistant\" }}" + go(t.assistant_open) + "{{ .Content }}"
803 "{{ range .ToolCalls }}" + go(t.tool_call_open) + "{{ .Function.Name }}" + go(t.tool_call_join) +
804 "{{ json .Function.Arguments }}{{ end }}" + go(t.assistant_close) +
805 "{{ else if eq .Role \"tool\" }}" + go(t.tool_open) + "{{ .Content }}" + go(t.tool_close) +
806 "{{ end }}{{ end }}" + go(t.generation_prompt);
807 std::string s = "# Ollama Modelfile for " + gguf_name + ": chat template " + t.id() +
808 " (LOCAL_LLM_STACK.md section 3, point 3).\nFROM ./" + gguf_name + "\nTEMPLATE \"\"\"" + head +
809 msgs + "\"\"\"\n";
810 std::vector<std::string> stops = t.stop_eos;
811 stops.insert(stops.end(), t.stop_turn.begin(), t.stop_turn.end());
812 for (const auto& x : stops) s += "PARAMETER stop " + json(x).dump() + "\n";
813 return s;
814}
815} // namespace detail
816
817inline std::string jinja(const Template& t) {
818 using detail::jstr;
819 detail::paperkiln_only(t, "jinja()");
820 if (t.content != "raw" || t.past_assistant != "keep" || t.has_default_system)
821 throw Error("jinja() covers raw content, kept replies and no default system");
822 if (t.has_tool) return detail::jinja_tools(t);
823 std::string j;
824 if (!t.bos.empty()) j += "{{- " + jstr(t.bos) + " -}}";
825 j += "{%- if messages and messages[0]['role'] == 'system' -%}"
826 "{%- set sys = messages[0]['content'] -%}{%- set msgs = messages[1:] -%}"
827 "{%- else -%}{%- set sys = none -%}{%- set msgs = messages -%}{%- endif -%}";
828 if (t.system_mode == "none")
829 j += "{%- if sys is not none -%}{{ raise_exception('this model\\'s template has no system role') }}"
830 "{%- endif -%}";
831 else if (t.system_mode == "turn")
832 j += "{%- if sys is not none -%}{{- " + jstr(t.system_open) + " + sys + " + jstr(t.system_close) +
833 " -}}{%- endif -%}";
834 j += "{%- for m in msgs -%}"
835 "{%- if (m['role'] == 'user') != (loop.index0 % 2 == 0) -%}"
836 "{{ raise_exception('user and assistant messages alternate from the user') }}{%- endif -%}"
837 "{%- if m['role'] == 'user' -%}{{- " + jstr(t.user_open) + " -}}";
838 if (t.system_mode == "fold")
839 j += "{%- if loop.first and sys is not none -%}{{- sys + " + jstr(t.system_join) + " -}}{%- endif -%}";
840 j += "{{- m['content'] + " + jstr(t.user_close) + " -}}"
841 "{%- else -%}{{- " + jstr(t.assistant_open) + " + m['content'] + " + jstr(t.assistant_close) + " -}}"
842 "{%- endif -%}{%- endfor -%}"
843 "{%- if add_generation_prompt -%}{{- " + jstr(t.generation_prompt) + " -}}{%- endif -%}";
844 return j;
845}
846
847inline std::string modelfile(const std::string& gguf_name, const Template& t) {
848 using detail::go;
849 detail::paperkiln_only(t, "modelfile()");
850 if (t.has_tool) return detail::modelfile_tools(gguf_name, t);
851 // No action trims whitespace, so the record's text is emitted exactly; the fold goes to the first user
852 // message (Ollama keeps a system message in .Messages as well as in .System), as in tools/chat_template.py.
853 std::string msgs = "{{ $first := true }}{{ range .Messages }}{{ if eq .Role \"user\" }}" + go(t.user_open);
854 if (t.system_mode == "fold") msgs += "{{ if and $first $.System }}{{ $.System }}" + go(t.system_join) + "{{ end }}";
855 msgs += "{{ $first = false }}{{ .Content }}" + go(t.user_close) + "{{ else if eq .Role \"assistant\" }}" +
856 go(t.assistant_open) + "{{ .Content }}" + go(t.assistant_close) + "{{ end }}{{ end }}" + go(t.generation_prompt);
857 std::string head = go(t.bos);
858 if (t.system_mode == "turn")
859 head += "{{ if .System }}" + go(t.system_open) + "{{ .System }}" + go(t.system_close) + "{{ end }}";
860 std::string s = "# Ollama Modelfile for " + gguf_name + ": chat template " + t.id() +
861 " (LOCAL_LLM_STACK.md section 3, point 3).\nFROM ./" + gguf_name + "\nTEMPLATE \"\"\"" + head +
862 msgs + "\"\"\"\n";
863 std::vector<std::string> stops = t.stop_eos;
864 stops.insert(stops.end(), t.stop_turn.begin(), t.stop_turn.end());
865 for (const auto& x : stops) s += "PARAMETER stop " + json(x).dump() + "\n";
866 return s;
867}
868
869// ---- resolution ------------------------------------------------------------------------------
870namespace detail {
871inline std::string sha256_hex(const std::string& msg) {
872 static const uint32_t k[64] = {
873 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
874 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
875 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
876 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
877 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
878 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
879 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
880 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2};
881 uint32_t h[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
882 std::string m = msg;
883 const uint64_t bits = static_cast<uint64_t>(msg.size()) * 8;
884 m += static_cast<char>(0x80);
885 while (m.size() % 64 != 56) m += '\0';
886 for (int i = 7; i >= 0; --i) m += static_cast<char>((bits >> (8 * i)) & 0xff);
887 auto rotr = [](uint32_t x, int n) { return (x >> n) | (x << (32 - n)); };
888 for (size_t off = 0; off < m.size(); off += 64) {
889 uint32_t w[64];
890 for (int i = 0; i < 16; ++i)
891 w[i] = (uint32_t(uint8_t(m[off + 4 * i])) << 24) | (uint32_t(uint8_t(m[off + 4 * i + 1])) << 16) |
892 (uint32_t(uint8_t(m[off + 4 * i + 2])) << 8) | uint32_t(uint8_t(m[off + 4 * i + 3]));
893 for (int i = 16; i < 64; ++i) {
894 const uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3);
895 const uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10);
896 w[i] = w[i - 16] + s0 + w[i - 7] + s1;
897 }
898 uint32_t a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7];
899 for (int i = 0; i < 64; ++i) {
900 const uint32_t t1 = hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + k[i] + w[i];
901 const uint32_t t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c));
902 hh = g;
903 g = f;
904 f = e;
905 e = d + t1;
906 d = c;
907 c = b;
908 b = a;
909 a = t1 + t2;
910 }
911 h[0] += a; h[1] += b; h[2] += c; h[3] += d; h[4] += e; h[5] += f; h[6] += g; h[7] += hh;
912 }
913 char buf[65];
914 for (int i = 0; i < 8; ++i) std::snprintf(buf + 8 * i, 9, "%08x", h[i]);
915 return std::string(buf, 64);
916}
917} // namespace detail
918
920 bool chat = false;
922 std::string why;
923};
924
925// declared: the GGUF's paperkiln.chat_template ("" when absent); tokens: tokenizer.ggml.tokens;
926// model: tokenizer.ggml.model; chat_template: tokenizer.chat_template ("" when absent); arch:
927// general.architecture.
928inline Resolution resolve(const std::string& declared, const std::vector<std::string>& tokens, const std::string& model,
929 const std::string& chat_template, const std::string& arch) {
931 r.chat = true;
932 if (!declared.empty()) {
933 r.tmpl = Template::from_json(json::parse(declared));
934 r.why = "declared";
935 return r;
936 }
937 bool v1specials = tokens.size() >= 4;
938 for (size_t i = 0; v1specials && i < 4; ++i) v1specials = tokens[i] == kV1Specials[i];
939 if (v1specials) {
940 r.tmpl = v1();
941 r.why = "paperkiln specials at ids 0-3";
942 return r;
943 }
944 if (model == "word") {
945 r.tmpl = legacy_word();
946 r.why = "word-level vocabulary";
947 return r;
948 }
949 const auto reg = registry();
950 if (!chat_template.empty()) {
951 const std::string h = detail::sha256_hex(chat_template);
952 for (const auto& t : reg)
953 for (const auto& m : t.match)
954 if (m == h) {
955 r.tmpl = t;
956 r.why = "tokenizer.chat_template sha256 " + h.substr(0, 12);
957 return r;
958 }
959 }
960 for (const auto& t : reg) {
961 if (t.markers.empty()) continue;
962 bool arch_ok = false;
963 for (const auto& a : t.architectures) arch_ok = arch_ok || a == arch;
964 bool all = arch_ok;
965 for (const auto& mk : t.markers) {
966 bool found = false;
967 for (const auto& tok : tokens) found = found || tok == mk;
968 all = all && found;
969 }
970 if (all) {
971 r.tmpl = t;
972 r.why = "markers and architecture " + arch;
973 return r;
974 }
975 }
976 r.chat = false;
977 r.why = "no declared template, no paperkiln specials, and no known tokenizer.chat_template or markers: raw "
978 "completion only";
979 return r;
980}
981
982} // namespace chat
983} // namespace microtorch
const float * p[kIrDepthSourcesMax]
int n
float value
std::string text
std::vector< float > v
const char * name
Kind kind
Tok tok
float c
std::vector< E > a
node out
node add
size_t j
std::string go(const std::string &s)
std::string jinja_tools(const Template &t)
std::string sha256_hex(const std::string &msg)
std::string dump_py(const json &x)
std::string modelfile_tools(const std::string &gguf_name, const Template &t)
std::string jstr(const std::string &s)
void paperkiln_only(const Template &t, const char *what)
std::vector< Message > messages_from_json(const J &a)
std::string tool_json(const json &d)
Template get(const std::string &id)
constexpr const char * kDefaultSftTurns
constexpr const char * kGGUFKey
std::vector< Piece > render_pieces(const std::vector< Message > &m, const Template &t, bool generation_prompt, int thinking=-1, const std::vector< json > &tools={})
const std::array< const char *, 8 > kV2Control
std::vector< std::pair< size_t, size_t > > exchanges(const std::vector< Message > &m)
std::string render(const std::vector< Message > &m, const Template &t, bool generation_prompt, int thinking=-1, const std::vector< json > &tools={})
constexpr const char * kV1
std::string jinja(const Template &t)
constexpr const char * kLegacyWord
Template chatml(const std::string &name, bool qwen25)
std::vector< Message > from_pairs(const std::vector< std::pair< std::string, std::string > > &turns, const std::string *user=nullptr, const std::string *system=nullptr)
Template legacy_word()
bool py_space(uint32_t c)
nlohmann::ordered_json json
std::string strip_py_ws(const std::string &s)
std::vector< Template > registry()
void check_control_tokens(const Template &t, const std::vector< std::string > &tokens, const std::vector< int32_t > &types)
constexpr const char * kV2
std::string modelfile(const std::string &gguf_name, const Template &t)
constexpr const char * kSystemModeV1
size_t check_structure(const std::vector< Message > &m, bool prompt, bool tools_ok=false)
const std::array< const char *, 4 > kV1Specials
ParsedReply parse_reply(const std::vector< int > &ids, int call_id, Decode decode)
Resolution resolve(const std::string &declared, const std::vector< std::string > &tokens, const std::string &model, const std::string &chat_template, const std::string &arch)
nlohmann::json json
Definition server.cpp:68
Message(std::string r, std::string c, int l=-1)
std::vector< ToolCall > tool_calls
std::vector< ToolCall > tool_calls
std::vector< std::string > stop_eos
std::vector< std::string > reserved
std::string generation_prompt_nothink
std::vector< std::string > architectures
static Template from_json(const J &r)
std::vector< std::string > match
std::vector< std::string > markers
std::vector< std::string > stop_turn
std::string id() const