DaisyChain-Infer / public /tokenizer.js
Quazim0t0's picture
DaisyChain-Infer: project files + guide
30bafb7 verified
Raw
History Blame Contribute Delete
5.7 kB
// Byte-level BPE, read straight from a repo's tokenizer.json.
//
// The three families this build supports — GPT-2, Llama-style (SmolLM,
// TinyLlama), and Qwen2 — all use byte-level BPE, so one implementation covers
// them. A repo using anything else (SentencePiece/Unigram, WordPiece) is
// refused by name rather than approximated: a tokenizer that is merely close
// produces text that looks right and is subtly not, which is the worst
// possible failure mode to ship quietly.
(function (root) {
"use strict";
// GPT-2's byte<->unicode table. Byte-level BPE maps raw bytes into printable
// codepoints so the merge table can be plain text; every byte round-trips.
function byteTables() {
const bs = [];
for (let i = 33; i <= 126; i++) bs.push(i);
for (let i = 161; i <= 172; i++) bs.push(i);
for (let i = 174; i <= 255; i++) bs.push(i);
const cs = bs.slice();
let n = 0;
for (let b = 0; b < 256; b++)
if (!bs.includes(b)) { bs.push(b); cs.push(256 + n); n++; }
const b2u = new Map(), u2b = new Map();
for (let i = 0; i < bs.length; i++) {
const ch = String.fromCodePoint(cs[i]);
b2u.set(bs[i], ch); u2b.set(ch, bs[i]);
}
return { b2u, u2b };
}
const { b2u, u2b } = byteTables();
// GPT-2 / Llama pretokenizer split. Keeps leading spaces attached to the
// following word, which is what the merge table was built against.
const SPLIT = /'s|'t|'re|'ve|'m|'ll|'d| ?[\p{L}]+| ?[\p{N}]+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+/gu;
function build(json) {
const model = json.model || {};
if (String(model.type || "").toUpperCase() !== "BPE")
throw new Error(`tokenizer type "${model.type}" is not supported — this build implements byte-level BPE ` +
`(GPT-2, Llama-style and Qwen2 repos). A different tokenizer would decode to subtly wrong text.`);
const vocab = model.vocab || {};
const ids = [];
for (const [tokStr, id] of Object.entries(vocab)) ids[id] = tokStr;
// merges may be "a b" strings or ["a","b"] pairs depending on the version
const ranks = new Map();
(model.merges || []).forEach((m, i) => {
const pair = Array.isArray(m) ? m : String(m).split(" ");
if (pair.length === 2) ranks.set(pair[0] + "" + pair[1], i);
});
// Added tokens live OUTSIDE model.vocab, so they have to be written into
// the id table too — otherwise an id like <|endoftext|> decodes to nothing
// and, worse, an id past the end of the vocab silently disappears instead
// of being reported.
const added = new Map();
for (const t of json.added_tokens || []) { added.set(t.content, t.id); ids[t.id] = t.content; }
const specialIds = new Set([...added.values()]);
return { vocab, ids, ranks, added, specialIds, size: ids.length,
name: `byte-level BPE (${ids.length} tokens)` };
}
function bpe(tok, word) {
if (word.length === 1) return [word];
let parts = [...word];
for (;;) {
let best = null, bestRank = Infinity, bestIdx = -1;
for (let i = 0; i < parts.length - 1; i++) {
const r = tok.ranks.get(parts[i] + "" + parts[i + 1]);
if (r !== undefined && r < bestRank) { bestRank = r; best = i; bestIdx = i; }
}
if (best === null) break;
parts = parts.slice(0, bestIdx).concat(parts[bestIdx] + parts[bestIdx + 1], parts.slice(bestIdx + 2));
if (parts.length === 1) break;
}
return parts;
}
function encode(tok, text) {
const out = [];
// added/special tokens are matched literally before anything else, so a
// chat template's <|im_start|> stays one token instead of being merged
const specials = [...tok.added.keys()].sort((a, b) => b.length - a.length);
let rest = text;
const pieces = [];
while (rest.length) {
let hit = null, at = -1;
for (const s of specials) {
const i = rest.indexOf(s);
if (i !== -1 && (at === -1 || i < at)) { at = i; hit = s; }
}
if (hit === null) { pieces.push({ text: rest }); break; }
if (at > 0) pieces.push({ text: rest.slice(0, at) });
pieces.push({ special: hit });
rest = rest.slice(at + hit.length);
}
for (const p of pieces) {
if (p.special) { out.push(tok.added.get(p.special)); continue; }
for (const m of p.text.matchAll(SPLIT)) {
let mapped = "";
for (const b of new TextEncoder().encode(m[0])) mapped += b2u.get(b);
for (const piece of bpe(tok, mapped)) {
const id = tok.vocab[piece];
if (id !== undefined) out.push(id);
else for (const ch of piece) { // fall back to single bytes
const bid = tok.vocab[ch];
if (bid !== undefined) out.push(bid);
}
}
}
}
return Int32Array.from(out);
}
function decode(tok, idArr, keepSpecial) {
const bytes = [];
for (const id of idArr) {
const t = tok.ids[id];
if (t === undefined) continue;
if (tok.specialIds.has(id) && !keepSpecial) continue;
for (const ch of t) {
const b = u2b.get(ch);
// A codepoint outside the byte table means the token is literal text
// (some added tokens are), so emit its UTF-8 rather than dropping it.
if (b === undefined) for (const bb of new TextEncoder().encode(ch)) bytes.push(bb);
else bytes.push(b);
}
}
return new TextDecoder("utf-8", { fatal: false }).decode(Uint8Array.from(bytes));
}
const api = { build, encode, decode, byteTables };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.Tokenizer = api;
})(typeof self !== "undefined" ? self : this);