feat(thicktoken): replace tiktoken with in-repo Rust→WASM tokenizer engine#691
Conversation
…ngine
Same TextTokenizer/getWasmTokenizer public API, new engine: a thin
wasm-bindgen wrapper (wasm/) around the tokie BPE crate (MIT, Chonkie Inc —
see NOTICE), cl100k_base. tiktoken dependency removed; gpt-tokenizer kept as
independent test ground truth.
- vocab shipped merges-only (469KB gz) and rebuilt at init; no Aho-Corasick
- count(text, { approximate }) — statistical sampling, on by default for
large inputs (~0.2% error), exact opt-in
- truncate is exact and windowed: never tokenizes more than it needs
- bundle 2483KB → 1156KB (−53%), init on par, 2.5–5× faster exact ops,
~34–53× on default count / small-budget truncate at 10M tokens
- also bumps tsup to 8.5.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…Buffer) Caught by a browser smoke test: the inline-wasm tsup plugin decoded the embedded wasm with Buffer.from, which crashes outside Node. Falls back to atob in browsers. Verified in Chromium: init 82ms, all ops pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…enizer Additive: truncate(text, maxTokens, mode = 'head'). Lets llmz's truncator replace its splitAndSlice/slice/join dance (which materializes every token as a JS string) with a single windowed call per preserve mode. Also fixes a middle-mode bug where overlapping head/tail windows (total < maxTokens) duplicated text — now returns the whole text. Tests assert exact token counts of truncated results. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile SummaryReplaces the
Confidence Score: 4/5Safe to merge; the public API is backward-compatible for callers that only use count(text) and truncate, and the two findings are non-blocking quality notes. The engine swap is well-tested (63 tests, independent gpt-tokenizer reference), the wasm bytes are pre-built and committed so CI requires no Rust toolchain, and the singleton initialization is correctly handled synchronously. Two issues are worth tracking: slice double-encodes the input text when negative or omitted indices are used, and CountOptions is defined independently in both tokenizer.ts and wasm/index.ts rather than shared from one source. thicktoken/wasm/index.ts (slice double-encode) and thicktoken/src/tokenizer.ts (duplicate CountOptions type). Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant C as Consumer (llmz etc.)
participant TT as TextTokenizer
participant WT as WasmTokenizer (wasm/index.ts)
participant RW as RawTokenizer (wasm-bindgen)
participant RS as Rust / WASM
C->>TT: await getWasmTokenizer()
activate TT
TT->>WT: WasmTokenizer.create()
WT->>WT: "ensureInit() -> initSync(wasmBytes)"
WT->>RW: new RawTokenizer()
RW->>RS: inflate_gzip + build_tokenizer
RS-->>RW: tokie::Tokenizer
RW-->>WT: RawTokenizer instance
WT-->>TT: WasmTokenizer instance
TT-->>C: TextTokenizer singleton
C->>TT: count(text)
TT->>WT: "count(text, {})"
WT->>RW: "count(text, approximate=true)"
RW->>RS: approx_count (stratified sampling)
RS-->>C: approx tokens (fast, within few%)
C->>TT: truncate(text, N, tail)
TT->>WT: truncate(text, N, tail)
WT->>RW: truncate(text, N, tail)
RW->>RS: take_tail (optimistic suffix window)
RS-->>C: exact last-N-token substring
C->>TT: count(text, approximate false)
TT->>WT: count(text, approximate false)
WT->>RW: "count(text, approximate=false)"
RW->>RS: count_tokens (full encode)
RS-->>C: exact token count
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant C as Consumer (llmz etc.)
participant TT as TextTokenizer
participant WT as WasmTokenizer (wasm/index.ts)
participant RW as RawTokenizer (wasm-bindgen)
participant RS as Rust / WASM
C->>TT: await getWasmTokenizer()
activate TT
TT->>WT: WasmTokenizer.create()
WT->>WT: "ensureInit() -> initSync(wasmBytes)"
WT->>RW: new RawTokenizer()
RW->>RS: inflate_gzip + build_tokenizer
RS-->>RW: tokie::Tokenizer
RW-->>WT: RawTokenizer instance
WT-->>TT: WasmTokenizer instance
TT-->>C: TextTokenizer singleton
C->>TT: count(text)
TT->>WT: "count(text, {})"
WT->>RW: "count(text, approximate=true)"
RW->>RS: approx_count (stratified sampling)
RS-->>C: approx tokens (fast, within few%)
C->>TT: truncate(text, N, tail)
TT->>WT: truncate(text, N, tail)
WT->>RW: truncate(text, N, tail)
RW->>RS: take_tail (optimistic suffix window)
RS-->>C: exact last-N-token substring
C->>TT: count(text, approximate false)
TT->>WT: count(text, approximate false)
WT->>RW: "count(text, approximate=false)"
RW->>RS: count_tokens (full encode)
RS-->>C: exact token count
Reviews (1): Last reviewed commit: "feat(thicktoken): expose truncate modes ..." | Re-trigger Greptile |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…l25k) The wasm is now code-only (~127KB gz): the constructor takes the gzip'd merges asset from JS instead of embedding it, so one Rust artifact serves all vocab variants. Each package entry bundles its own asset: . cl100k ~647KB gz ~60ms init exact OpenAI counts ./lite cl50k ~392KB gz ~25ms init overcounts ~+3-7% ./micro cl25k ~275KB gz ~13ms init overcounts ~+8-20% Truncated vocabs are cl100k's first N merges (valid BPE by prefix-closure) — they only ever OVERCOUNT, the safe direction for budget enforcement; not for billing math. Specials renumbered to follow the truncated merge range. ESM entries share one engine chunk; assets regenerate via wasm/scripts/gen-assets.mjs. Verified in Chromium (all variants) + 72 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- slice: negative/omitted index resolution moves into the wasm, against the one encode pass. Previously the JS wrapper called count(text, exact) to resolve indices, encoding the full text twice for e.g. slice(big, -100). - CountOptions: single definition in wasm/index.ts, re-exported through core/entries — the package no longer exports two identical-but-distinct type identities. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Swaps thicktoken's engine from
tiktokento our own Rust→WASM build (thicktoken/wasm/) while keeping theTextTokenizer/getWasmTokenizer()public API unchanged — llmz and other consumers work as-is. The BPE core is the tokie crate (MIT © Chonkie, Inc. — attribution inNOTICE), wrapped with a thin wasm-bindgen layer and a typed TS API.Benchmarks — before vs after
Measured on the built
dist/, same machine (darwin/arm64, Node 22),TOKEN×10M input (60 MB, exactly 10M tokens):count(new default: approximate)count(exact)splittruncateto a small budget (10M → 1,000 tokens)Two of these are algorithmic, not just engine speed:
countis approximate by default on large inputs ({ approximate: false }opts out): it tokenizes ~48 KB of stratified sample windows regardless of input size and extrapolates — cost is ~constant in input length, so the gap grows with input size (2–3 orders of magnitude on multi-MB texts).truncateis windowed and exact: it only tokenizes the prefix/suffix window it needs, so trimming a huge text to a small budget no longer tokenizes the whole thing. (It's also boundary-exact — the old chunked implementation dropped a token at chunk boundaries.)How the wasm stays small & fast to init
model.vocabis fully derivable for a topological BPE, so it's rebuilt at init (base-256 byte-level alphabet + merge concatenations + specials).tokenizer.jsonround-trip) using tokie'sSimpleencoder (no Aho-Corasick automaton) → ~44 ms engine init, on par with tiktoken.gpt-tokenizer, kept as devDependency for tests). Known alpha edge case, documented in tests: glued mid-word contractions (e.g.na'transl) tokenize to different ids (same text round-trip); never observed on real prose/code.Vocab variants (edge targets)
One code-only wasm engine (~127 KB gz); each entry bundles its own vocab asset (truncated cl100k merge lists — valid BPE by prefix-closure). Same
TextTokenizerAPI everywhere:@bpinternal/thicktoken@bpinternal/thicktoken/lite@bpinternal/thicktoken/microTruncated variants only ever overcount (safe for budget enforcement — never overflows a window); not for billing math. Inflation is content-dependent (worst on emoji/unicode-heavy text). Assets regenerate via
wasm/scripts/gen-assets.mjs; all variants verified in Chromium (browser-test.html).API changes (⚠️ minor break → v3.0.0)
count(text, max?: number)→count(text, options?: { approximate?: boolean }). The oldmaxceiling is gone (obsolete — counting is fast now). llmz only callscount(text), unaffected.WasmTokenizer(direct engine access:encode/decode/split/slice/truncate(text, n, 'head'|'tail'|'middle')),TruncateMode,CountOptions.tiktokendependency removed.Rebuilding the wasm
wasm/pkg/is committed, so the package builds and tests without a Rust toolchain. To rebuild from Rust source: seewasm/README.md(wasm-pack,--target web).Test plan
TextTokenizersuite + 39 primitive tests (parity vs independent cl100k reference, exact truncate head/tail/middle, slice incl. negative indices, approximate-count error bounds, documented edge case)tsc --noEmitclean🤖 Generated with Claude Code