ember.cpp 0.1.0
C++ inference engine for Llama-family and paperkiln models (CPU, CUDA, WebAssembly)
Loading...
Searching...
No Matches
utf8_text.h
Go to the documentation of this file.
1// Valid UTF-8 for JSON replies. A reply decoded from BPE tokens is bytes: a model can stop (end-of-text,
2// or max_new_tokens) inside a multi-byte character, or emit byte tokens that form no character at all.
3// nlohmann::json's dump() throws on such a string (type_error 316), which /chat turned into HTTP 500: on
4// 26 Oct 2026 pk-bench2's IF-tiny job (paperkiln experiments/benchmarks_2026-10-26) stopped at prompt 375
5// of pick_e1_60m_L32, whose greedy reply ended on the lone lead byte 0xD7. utf8_valid replaces every
6// ill-formed subsequence with U+FFFD, one per maximal subpart (Unicode 15, section 3.9, U+FFFD
7// substitution of maximal subparts), the substitution Python's bytes.decode("utf-8", "replace") makes, so
8// a client decoding the same ids with errors="replace" gets the same text. The ids ("return_ids") are
9// untouched.
10//
11// Every place ember returns model text goes through here: /chat (plain, streamed, the legacy path),
12// /v1/chat/completions (plain and streamed), WebAssembly ember_chat / ember_finish, and the Python
13// bindings' generate / generate_batch. A streamed reply (ReplyStream) holds back a character a token
14// boundary cut (at most 3 bytes) until the next token completes it, so the concatenated pieces equal the
15// plain reply byte for byte. dump_valid is the last line for every other JSON string (error messages,
16// paths): a response is never HTTP 500 for its text.
17#pragma once
18
19#include <algorithm>
20#include <string>
21
22namespace ember {
23
24// What the maximal subpart at s[i] is: a whole character (ASCII included), ill-formed bytes that no
25// following byte can repair, or the start of a character that the end of s cut (more bytes may complete it).
26enum class Utf8Part { whole, ill_formed, cut };
27
28// The maximal subpart at s[i] (i < s.size()): its length, and what it is.
29inline size_t utf8_part(const std::string& s, size_t i, Utf8Part& part) {
30 const unsigned char c = static_cast<unsigned char>(s[i]);
32 if (c < 0x80) return 1;
33 // continuation bytes needed after the lead, and the range of the first one (Table 3-7)
34 size_t need = 0;
35 unsigned char lo = 0x80, hi = 0xBF;
36 if (c >= 0xC2 && c <= 0xDF) {
37 need = 1;
38 } else if (c == 0xE0) {
39 need = 2;
40 lo = 0xA0;
41 } else if ((c >= 0xE1 && c <= 0xEC) || c == 0xEE || c == 0xEF) {
42 need = 2;
43 } else if (c == 0xED) {
44 need = 2;
45 hi = 0x9F;
46 } else if (c == 0xF0) {
47 need = 3;
48 lo = 0x90;
49 } else if (c >= 0xF1 && c <= 0xF3) {
50 need = 3;
51 } else if (c == 0xF4) {
52 need = 3;
53 hi = 0x8F;
54 } else { // a continuation byte without a lead, C0, C1, F5..FF
56 return 1;
57 }
58 const size_t n = s.size();
59 size_t j = 1;
60 for (; j <= need; ++j) {
61 if (i + j >= n) { // the lead and the continuations that fitted, then the end of s
63 return j;
64 }
65 const unsigned char d = static_cast<unsigned char>(s[i + j]);
66 if (d < (j == 1 ? lo : 0x80) || d > (j == 1 ? hi : 0xBF)) {
67 part = Utf8Part::ill_formed; // the lead and the continuations that fitted: one maximal subpart
68 return j;
69 }
70 }
71 return j;
72}
73
74inline std::string utf8_valid(const std::string& s) {
75 const char rep[] = {static_cast<char>(0xEF), static_cast<char>(0xBF), static_cast<char>(0xBD)};
76 std::string out;
77 out.reserve(s.size());
78 const size_t n = s.size();
79 size_t i = 0;
80 while (i < n) {
82 const size_t len = utf8_part(s, i, part);
83 if (part == Utf8Part::whole)
84 out.append(s, i, len);
85 else
86 out.append(rep, 3); // ill-formed, or cut by the end of s: one U+FFFD per maximal subpart
87 i += len;
88 }
89 return out;
90}
91
92// The end of what is settled in s from `from` (a subpart boundary): every byte before it decodes the same
93// whatever bytes follow (whole characters, and ill-formed bytes no continuation can repair); the bytes
94// after it, at most 3, are a character the end of s cut, which the next token may complete.
95inline size_t utf8_settled(const std::string& s, size_t from) {
96 const size_t n = s.size();
97 size_t i = from;
98 while (i < n) {
100 const size_t len = utf8_part(s, i, part);
101 if (part == Utf8Part::cut) return i;
102 i += len;
103 }
104 return n;
105}
106
107// A reply streamed as it is generated. generate() returns the reply's bytes with leading and trailing
108// whitespace (the C isspace set) stripped (api.cpp), and the reply object carries utf8_valid of that; the
109// pieces next() returns concatenate to exactly that text. Each call takes the decoded reply so far (each a
110// prefix of the next, as byte-level BPE decoding is) and returns the newly final text, valid UTF-8 (often
111// empty): leading whitespace skipped; trailing whitespace held until something other than whitespace
112// follows it; a cut character held until the next token completes it, or until end. U+FFFD replaces only
113// bytes that can never become valid, or a character still cut at the end.
115 public:
116 std::string next(const std::string& full, bool end) {
117 static const char* ws = " \t\n\v\f\r";
118 const size_t first = full.find_first_not_of(ws);
119 if (first == std::string::npos) return std::string();
120 if (sent_ == std::string::npos) sent_ = first;
121 size_t limit = full.find_last_not_of(ws) + 1;
122 if (!end) limit = std::min(limit, utf8_settled(full, sent_));
123 if (limit <= sent_) return std::string();
124 std::string out = utf8_valid(full.substr(sent_, limit - sent_));
125 sent_ = limit;
126 return out;
127 }
128
129 private:
130 size_t sent_ = std::string::npos; // bytes of the decoded reply already sent; npos before the first non-space
131};
132
133// Json (nlohmann::json) with utf8_valid applied to every string and object key.
134template <class Json>
136 if (j.is_string()) return Json(utf8_valid(j.template get<std::string>()));
137 if (j.is_array()) {
138 Json a = Json::array();
139 for (const auto& x : j) a.push_back(utf8_valid_json(x));
140 return a;
141 }
142 if (j.is_object()) {
143 Json o = Json::object();
144 for (auto it = j.begin(); it != j.end(); ++it) o[utf8_valid(it.key())] = utf8_valid_json(it.value());
145 return o;
146 }
147 return j;
148}
149
150// j.dump(), or, when a string in j is not valid UTF-8 (dump() throws type_error 316), the dump of j with
151// utf8_valid applied to every string: the same bytes as dump() whenever dump() succeeds.
152template <class Json>
153std::string dump_valid(const Json& j) {
154 try {
155 return j.dump();
156 } catch (const typename Json::type_error&) {
157 return utf8_valid_json(j).dump();
158 }
159}
160
161} // namespace ember
std::string next(const std::string &full, bool end)
Definition utf8_text.h:116
int n
float c
std::vector< E > a
node out
size_t j
Json utf8_valid_json(const Json &j)
Definition utf8_text.h:135
size_t utf8_settled(const std::string &s, size_t from)
Definition utf8_text.h:95
size_t utf8_part(const std::string &s, size_t i, Utf8Part &part)
Definition utf8_text.h:29
std::string dump_valid(const Json &j)
Definition utf8_text.h:153
std::string utf8_valid(const std::string &s)
Definition utf8_text.h:74