ember.cpp 0.1.0
C++ inference engine for Llama-family and paperkiln models (CPU, CUDA, WebAssembly)
Loading...
Searching...
No Matches
chat_tokenizer.h
Go to the documentation of this file.
1#pragma once
2// The tokenizer and chat template behind ember's chat mode (TinyLlamaSession::is_bpe()).
3//
4// The template is data (LOCAL_LLM_STACK.md section 3, point 3): a record from paperkiln's
5// chat_template.h (vendored), resolved at load by chat::resolve, the same in every engine:
6// (1) the GGUF's paperkiln.chat_template, (2) paperkiln's chat specials at ids 0-3: paperkiln/1,
7// (3) a word-level vocabulary: paperkiln/legacy-word, (4) a third-party record (chatml-qwen2.5,
8// chatml-qwen3, gemma3) by the sha256 of tokenizer.chat_template, else by its markers and
9// general.architecture (llama.cpp b6000's Gemma 3 GGUF has no tokenizer.chat_template),
10// (5) no chat mode (raw completion only).
11// Every prompt is chat::render of the conversation under that record; the stop ids are the
12// record's stop strings, each of which must be a single token of the vocabulary. ember never
13// hard-codes a format.
14//
15// The vocabulary forms ember tokenises for chat mode, all vendored or ported:
16// - tokenizer.ggml.model "bpe": paperkiln's byte-level BPE with id-pair merges (bpe_tokenizer.h).
17// - "gpt2" with pre "llama-bpe" (paperkiln's 16k Llama 3-scheme vocabulary) or "qwen2" (Qwen2.5,
18// Qwen3: single digits, added tokens split out by their GGUF token types): paperkiln's exact
19// llama.cpp port (llama3_tokenizer.h); for "qwen2", NFC between the added tokens first, as HF's
20// tokenizer.json (QwenNfcTokenizer, unicode_nfc.h).
21// - "llama" (SentencePiece; Gemma 3): llama.cpp b6000's SPM session (spm_tokenizer.h).
22// A word-level vocabulary ("word") resolves to paperkiln/legacy-word but keeps ember's own
23// word-level tokenizer and the server's word-level path (TinyLlamaSession::chat_template()).
24#include <memory>
25#include <stdexcept>
26#include <string>
27#include <utility>
28#include <vector>
29
30#include "bpe_tokenizer.h"
31#include "chat_template.h"
32#include "llama3_tokenizer.h"
33#include "spm_tokenizer.h"
34#include "unicode_nfc.h"
35
36namespace ember {
37
38namespace chat = microtorch::chat;
39
40// The id of `text` among the tokens (the last one, as llama.cpp's token_to_id), or -1.
41inline int token_id(const std::vector<std::string>& tokens, const std::string& text) {
42 for (size_t i = tokens.size(); i-- > 0;)
43 if (tokens[i] == text) return static_cast<int>(i);
44 return -1;
45}
46
47// A chat model's template and where its reply ends: generation stops at an id in eos_ids (stop
48// reason "eos") or turn_ids ("turn": the model starting the next turn); the stop id is cut from
49// the reply. A close's text after its stop token (ChatML's "<|im_end|>\n") is never generated: the
50// next prompt renders it from the history. A record with tool turns (paperkiln/2: Template::has_tool)
51// has tool_call_id, the id of its tool_call.open (<|tool_call|>), which is not a stop: a reply is
52// split on it (chat_reply.h, chat::parse_reply); -1 for every other record.
53struct ChatFormat {
55 std::string name; // tmpl.id(): "paperkiln/1", "chatml-qwen3/1", ...
56 std::string why; // the resolution's reason
57 std::vector<int> eos_ids, turn_ids;
58 int tool_call_id = -1;
59
60 // The record with its stop strings mapped to ids; chat::Error when one is not a single token.
61 static ChatFormat of(const chat::Template& t, const std::vector<std::string>& tokens, std::string why) {
63 f.tmpl = t;
64 f.name = t.id();
65 f.why = std::move(why);
66 auto ids = [&](const std::vector<std::string>& stops, const char* field) {
67 std::vector<int> v;
68 for (const auto& s : stops) {
69 const int id = token_id(tokens, s);
70 if (id < 0)
71 throw chat::Error("chat template " + t.id() + ": stop." + field + " '" + s +
72 "' is not a single token of this vocabulary (ember stops on single tokens only)");
73 v.push_back(id);
74 }
75 return v;
76 };
77 f.eos_ids = ids(t.stop_eos, "eos");
78 f.turn_ids = ids(t.stop_turn, "turn");
79 if (f.eos_ids.empty()) throw chat::Error("chat template " + t.id() + " has no stop.eos token");
80 if (t.has_tool) {
81 f.tool_call_id = token_id(tokens, t.tool_call_open);
82 if (f.tool_call_id < 0)
83 throw chat::Error("chat template " + t.id() + ": tool_call.open '" + t.tool_call_open +
84 "' is not a single token of this vocabulary");
85 }
86 return f;
87 }
88
89 bool is_eos(int id) const {
90 for (int e : eos_ids)
91 if (e == id) return true;
92 return false;
93 }
94 bool is_stop(int id) const {
95 if (is_eos(id)) return true;
96 for (int t : turn_ids)
97 if (t == id) return true;
98 return false;
99 }
100 std::vector<int> stop_ids() const {
101 std::vector<int> v = eos_ids;
102 v.insert(v.end(), turn_ids.begin(), turn_ids.end());
103 return v;
104 }
105 // The markers paperkiln/1's per-turn state tokens are defined over (state_tokens.h): the record
106 // writes <|user|>U<|assistant|>A<|endoftext|> and nothing else around a message.
107 bool v1_markers() const {
108 const chat::Template v = chat::v1();
109 return tmpl.name == v.name && tmpl.version == v.version && tmpl.bos.empty() && tmpl.content == "raw" &&
110 tmpl.user_open == v.user_open && tmpl.user_close == v.user_close &&
111 tmpl.assistant_open == v.assistant_open && tmpl.assistant_close == v.assistant_close &&
112 tmpl.generation_prompt == v.generation_prompt && tmpl.system_mode != "turn" &&
113 tmpl.stop_eos == v.stop_eos && tmpl.stop_turn == v.stop_turn;
114 }
115
116 // The prompt for `user` after turns[from..] ((user, assistant) pairs, oldest first), with the
117 // system message (nullptr: none) and the thinking mode (-1 the template's default, 0 off, 1 on):
118 // the record's rendering with a generation prompt. chat::Error when the template refuses it.
119 std::string prompt(const std::vector<std::pair<std::string, std::string>>& turns, size_t from,
120 const std::string& user, const std::string* system = nullptr, int thinking = -1) const {
121 const std::vector<std::pair<std::string, std::string>> kept(turns.begin() + static_cast<std::ptrdiff_t>(from),
122 turns.end());
123 return chat::render(chat::from_pairs(kept, &user, system), tmpl, /*generation_prompt=*/true, thinking);
124 }
125};
126
128public:
129 virtual ~ChatTokenizer() = default;
130 virtual std::vector<int> encode(const std::string& text) const = 0;
131 virtual std::string decode(const std::vector<int>& ids) const = 0;
132 virtual size_t size() const = 0;
133 virtual const char* kind() const = 0;
134 const ChatFormat& format() const { return format_; }
135
136protected:
138};
139
140template <class T>
142public:
143 ChatTokenizerOf(T t, const char* kind, ChatFormat format) : t_(std::move(t)), kind_(kind) {
144 format_ = std::move(format);
145 }
146 std::vector<int> encode(const std::string& text) const override { return t_.encode(text); }
147 std::string decode(const std::vector<int>& ids) const override { return t_.decode(ids); }
148 size_t size() const override { return t_.size(); }
149 const char* kind() const override { return kind_; }
150
151private:
153 const char* kind_;
154};
155
156// The Qwen2.5/Qwen3 tokeniser: HF's tokenizer.json for these models normalises with NFC before
157// pre-tokenising, and its added tokens are all "normalized": false, so HF cuts the raw text at them
158// first and normalises only the text between. This does the same around paperkiln's llama.cpp port
159// (llama3_tokenizer.h, unchanged): the same cut (token types unknown, control and user-defined,
160// longest first, as the port's), then unicode_nfc.h on each piece between, then the port's encoder on
161// it. Text that NFC leaves alone goes to the port whole, so its ids are exactly the port's. NFC could
162// form an added token's text from other text only through kNfcAsciiImages (U+212A KELVIN SIGN -> "K",
163// U+037E -> ";", U+1FEF -> "`"), and HF would not cut there: a vocabulary whose added tokens contain
164// one of those characters is refused at load (none of Qwen2.5's or Qwen3's do).
166public:
167 QwenNfcTokenizer(std::vector<std::string> tokens, const std::vector<std::string>& merges,
168 const std::vector<int32_t>& token_type) {
169 for (size_t i = 0; i < token_type.size() && i < tokens.size(); ++i) {
170 const int t = token_type[i];
171 if (t != 2 && t != 3 && t != 4) continue;
172 for (const char* c = nfc::tables::kNfcAsciiImages; *c; ++c)
173 if (tokens[i].find(*c) != std::string::npos)
174 throw std::runtime_error("Qwen vocabulary: added token '" + tokens[i] + "' holds '" +
175 std::string(1, *c) +
176 "', which NFC writes for another character; ember's NFC cut "
177 "would differ from HF's there");
178 specials_.push_back(static_cast<int>(i));
179 texts_.push_back(tokens[i]);
180 }
181 std::vector<size_t> order(specials_.size());
182 for (size_t k = 0; k < order.size(); ++k) order[k] = k;
183 std::stable_sort(order.begin(), order.end(),
184 [&](size_t a, size_t b) { return texts_[a].size() > texts_[b].size(); });
185 std::vector<int> s;
186 std::vector<std::string> t;
187 for (size_t k : order) {
188 s.push_back(specials_[k]);
189 t.push_back(texts_[k]);
190 }
191 specials_ = std::move(s);
192 texts_ = std::move(t);
193 t_ = std::make_unique<microtorch::llama3::Tokenizer>(std::move(tokens), merges, token_type, 1);
194 }
195
196 std::vector<int> encode(const std::string& text) const {
197 if (nfc::nfc_utf8(text) == text) return t_->encode(text); // already NFC: the port's ids
198 std::vector<std::pair<std::string, int>> frags = {{text, -1}};
199 for (size_t k = 0; k < specials_.size(); ++k) {
200 const std::string& s = texts_[k];
201 if (s.empty()) continue;
202 std::vector<std::pair<std::string, int>> next;
203 for (auto& [t, id] : frags) {
204 if (id >= 0) {
205 next.emplace_back(std::move(t), id);
206 continue;
207 }
208 size_t at = 0;
209 for (size_t m; (m = t.find(s, at)) != std::string::npos; at = m + s.size()) {
210 if (m > at) next.emplace_back(t.substr(at, m - at), -1);
211 next.emplace_back(std::string(), specials_[k]);
212 }
213 if (at < t.size()) next.emplace_back(t.substr(at), -1);
214 }
215 frags = std::move(next);
216 }
217 std::vector<int> out;
218 for (const auto& [t, id] : frags) {
219 if (id >= 0) {
220 out.push_back(id);
221 continue;
222 }
223 const std::vector<int> ids = t_->encode(nfc::nfc_utf8(t));
224 out.insert(out.end(), ids.begin(), ids.end());
225 }
226 return out;
227 }
228
229 std::string decode(const std::vector<int>& ids) const { return t_->decode(ids); }
230 size_t size() const { return t_->size(); }
231
232private:
233 std::unique_ptr<microtorch::llama3::Tokenizer> t_;
234 std::vector<int> specials_; // added token ids, longest text first (ties by id)
235 std::vector<std::string> texts_; // their texts, in the same order
236};
237
238// True when a GGUF's tokens start with paperkiln's four chat specials.
239inline bool has_chat_specials(const std::vector<std::string>& tokens) {
240 if (tokens.size() < chat::kV1Specials.size()) return false;
241 for (size_t i = 0; i < chat::kV1Specials.size(); ++i)
242 if (tokens[i] != chat::kV1Specials[i]) return false;
243 return true;
244}
245
246// A GGUF's tokenizer block and the keys template resolution reads.
247struct ChatVocab {
248 microtorch::bpe::GgufTokenizer g; // model, pre, tokens, merges, token_type
249 std::vector<float> scores; // tokenizer.ggml.scores (SentencePiece)
250 bool add_space_prefix = true; // tokenizer.ggml.add_space_prefix (SentencePiece; llama.cpp's default)
251 std::string arch; // general.architecture
252 std::string chat_template; // tokenizer.chat_template ("" when absent)
253 std::string declared; // paperkiln.chat_template ("" when absent)
254};
255
256// What load found: the chat tokenizer (nullptr: no chat mode ember serves), the resolved record
257// (also for a word-level vocabulary, which has no chat tokenizer here), and why.
258struct ChatSetup {
259 std::unique_ptr<ChatTokenizer> tok;
260 bool resolved = false;
262 std::string why;
263};
264
265// A record with tool turns (paperkiln/2) is refused at load, never served as raw completion, unless
266// every reserved string is a control token of the vocabulary (chat::check_control_tokens) and the
267// chat tokenizer encodes each as that one id (control_ids_check below): its prompts and parse_reply
268// depend on both.
270 const std::vector<std::pair<std::string, int>>& want) {
271 for (const auto& [text, id] : want) {
272 const std::vector<int> got = tok.encode(text);
273 if (got.size() != 1 || got[0] != id)
274 throw std::runtime_error("chat template " + t.id() + ": the " + tok.kind() + " tokenizer does not encode '" +
275 text + "' as its control token " + std::to_string(id) + ": the file is refused");
276 }
277}
278
280 ChatSetup s;
282 try {
283 r = chat::resolve(v.declared, v.g.tokens, v.g.model, v.chat_template, v.arch);
284 } catch (const std::exception& e) {
285 s.why = std::string("paperkiln.chat_template refused (") + e.what() + "): raw completion only";
286 return s;
287 }
288 s.why = r.why;
289 if (!r.chat) return s;
290 s.resolved = true;
291 s.tmpl = r.tmpl;
292 auto& g = v.g;
293 std::vector<std::pair<std::string, int>> control; // tool-turn records: each reserved string and its id
294 if (r.tmpl.has_tool) {
295 try {
296 chat::check_control_tokens(r.tmpl, g.tokens, g.token_type);
297 } catch (const chat::Error& e) {
298 throw std::runtime_error(std::string(e.what()) + ": the file is refused");
299 }
300 for (const auto& x : r.tmpl.reserved) control.emplace_back(x, token_id(g.tokens, x));
301 }
302 const bool bpe = g.model == "bpe";
303 const bool l3 = g.model == "gpt2" && (g.pre == "llama-bpe" || g.pre == "qwen2");
304 const bool spm = g.model == "llama";
305 if (!bpe && !l3 && !spm) {
306 if (g.model != "word") s.why += "; ember has no chat tokenizer for tokenizer.ggml.model '" + g.model + "'";
307 return s;
308 }
310 try {
311 f = ChatFormat::of(r.tmpl, g.tokens, r.why);
312 } catch (const chat::Error& e) {
313 s.resolved = false;
314 s.why = r.why + "; " + e.what() + ": raw completion only";
315 return s;
316 }
317 if (bpe) {
318 if (g.merges.empty()) throw std::runtime_error("BPE GGUF has no tokenizer.ggml.merges");
319 s.tok = std::make_unique<ChatTokenizerOf<microtorch::bpe::Tokenizer>>(
320 microtorch::bpe::Tokenizer(std::move(g.tokens), g.merges), "bpe", std::move(f));
321 } else if (l3) {
322 if (g.merges.empty()) throw std::runtime_error(g.pre + " GGUF has no tokenizer.ggml.merges");
323 if (g.pre == "qwen2")
324 s.tok = std::make_unique<ChatTokenizerOf<QwenNfcTokenizer>>(
325 QwenNfcTokenizer(std::move(g.tokens), g.merges, g.token_type), "qwen2_bpe", std::move(f));
326 else
327 s.tok = std::make_unique<ChatTokenizerOf<microtorch::llama3::Tokenizer>>(
328 microtorch::llama3::Tokenizer(std::move(g.tokens), g.merges, g.token_type, 3), "llama3_bpe",
329 std::move(f));
330 } else {
331 s.tok = std::make_unique<ChatTokenizerOf<SpmTokenizer>>(
332 SpmTokenizer(std::move(g.tokens), std::move(v.scores), g.token_type, v.add_space_prefix), "spm",
333 std::move(f));
334 }
335 if (!control.empty()) control_ids_check(s.tmpl, *s.tok, control);
336 return s;
337}
338
339} // namespace ember
ChatTokenizerOf(T t, const char *kind, ChatFormat format)
std::vector< int > encode(const std::string &text) const override
std::string decode(const std::vector< int > &ids) const override
const char * kind() const override
size_t size() const override
const ChatFormat & format() const
virtual std::string decode(const std::vector< int > &ids) const =0
virtual ~ChatTokenizer()=default
virtual size_t size() const =0
virtual const char * kind() const =0
virtual std::vector< int > encode(const std::string &text) const =0
std::unique_ptr< microtorch::llama3::Tokenizer > t_
std::vector< std::string > texts_
std::vector< int > specials_
QwenNfcTokenizer(std::vector< std::string > tokens, const std::vector< std::string > &merges, const std::vector< int32_t > &token_type)
std::vector< int > encode(const std::string &text) const
std::string decode(const std::vector< int > &ids) const
std::string text
std::vector< float > v
Tok tok
float c
std::vector< E > a
node out
size_t j
constexpr char kNfcAsciiImages[]
std::string nfc_utf8(const std::string &text)
bool has_chat_specials(const std::vector< std::string > &tokens)
int token_id(const std::vector< std::string > &tokens, const std::string &text)
ChatSetup make_chat_setup(ChatVocab v)
void control_ids_check(const chat::Template &t, const ChatTokenizer &tok, const std::vector< std::pair< std::string, int > > &want)
std::string render(const std::vector< Message > &m, const Template &t, bool generation_prompt, int thinking=-1, const std::vector< json > &tools={})
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)
void check_control_tokens(const Template &t, const std::vector< std::string > &tokens, const std::vector< int32_t > &types)
const std::array< const char *, 4 > kV1Specials
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)
std::vector< int > turn_ids
std::vector< int > eos_ids
chat::Template tmpl
static ChatFormat of(const chat::Template &t, const std::vector< std::string > &tokens, std::string why)
bool v1_markers() const
bool is_eos(int id) const
std::vector< int > stop_ids() const
std::string prompt(const std::vector< std::pair< std::string, std::string > > &turns, size_t from, const std::string &user, const std::string *system=nullptr, int thinking=-1) const
bool is_stop(int id) const
chat::Template tmpl
std::unique_ptr< ChatTokenizer > tok
microtorch::bpe::GgufTokenizer g
std::string chat_template
std::string declared
std::vector< float > scores
std::vector< std::string > stop_eos
std::vector< std::string > stop_turn