Repository navigation
feat(encoding): add claude_v3 and claude_v4_7 encodings ported from ctok - #10
Conversation
The fixture holds 93 texts and their real count_tokens results on claude-opus-4-6 (claude_v3 family) and claude-opus-4-8 (claude_v4_7 family). scripts/generate-claude-fixture.ts refreshes it and checks the measured message frame (7 and 6 tokens) against ctok's frame. Change-Id: I7088dbdeeabf78987ec19ca36cbade7e38c1c570 Signed-off-by: Thomas Kosiewski <tk@coder.com>
Change-Id: I27ad645dd164748800d9a32d234fbcfb417bf514 Signed-off-by: Thomas Kosiewski <tk@coder.com>
scripts/ctok-export.py runs inside uv with ctok pinned to CTOK_COMMIT and
prints the vocabularies, the byte-floor prefixes, the family scalars and
ctok's own answers to its Unicode predicates for every BMP codepoint.
scripts/generate-claude-encodings.ts writes them as src/claude/unicode.ts
and the two encoding modules through prettier. A rerun writes
byte-identical files.
ctok leaves its regex dependency unpinned, and regex's \p{M} and
\p{Alphabetic} decide how ctok classifies codepoints that Unicode 15.1
leaves unassigned. CTOK_REGEX pins the version in ctok's uv.lock.
The encoding modules import ../claude/codec, which the next commits add.
Change-Id: Ie456c1db0bd3c70009516c7a8e7fba2b8c15e8d4
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Without the pin, uv resolves the newest regex, whose Unicode tables call some codepoints unassigned in Unicode 15.1 marks (U+05C8, U+05C9 and others). ctok then classifies them differently from the generated data, and 27 of 7,595 synthetic documents differ by one or two tokens. Change-Id: I50b611d8f2ce18d739ea5279ec97610b482900ee Signed-off-by: Thomas Kosiewski <tk@coder.com>
The two encodings count Claude tokens offline: claude_v3 for Claude 3
through Claude Opus 4.6 and claude_v4_7 for Claude Opus 4.7 and later
(ctok --version 4.8). They port ctok's normalization, min-cost tiling,
byte floor and frame tail.
src/claude/normalize.ts turns text into ctok's marked stream on codepoint
arrays, with Unicode facts from the generated tables. NFC runs on the
engine between codepoints that Unicode 15.1 leaves unassigned, so a newer
engine cannot reorder marks across them. src/claude/codec.ts tiles the
stream over a lazy reverse trie and emits synthetic ids in ctok's tie
order. decode() returns the normalized text.
Tokenizer accepts a CodecEncoding ({ name, codec }) next to Encoding and
hands encode and decode to the codec. The BPE paths do not change.
count("") is 0. claude_v4_7 tiles a content-final newline run without
ctok's two extra frame newlines, as the Opus 4.8 API measures.
Change-Id: I620b36175798280a2c79213e37858eabbbd257e0
Signed-off-by: Thomas Kosiewski <tk@coder.com>
src/claude.test.ts asserts every fixture case on both encodings, with no network and no Python, plus the public codec behavior: empty text, decode of plain prose, invalid ids, special-token options and the namespace. bench/claude.ts times 1M characters of prose and 100k of one repeated character at 1x and 2x size. Change-Id: Iab87fd9c418c0f1cd623936ec34f6e948a7c95f8 Signed-off-by: Thomas Kosiewski <tk@coder.com>
The README section covers usage and count semantics. LICENSE gains the ctok MIT notice. Change-Id: I24edf2d1794e81300e500ccf1a602f2d95df9d9f Signed-off-by: Thomas Kosiewski <tk@coder.com>
Change-Id: Idd1d7173c9122feb28671cdaadf1ca45e1ff5214 Signed-off-by: Thomas Kosiewski <tk@coder.com>
Add CCC to src/claude/unicode.ts: every codepoint with a non-zero combining class, as [first, last, class, ...] runs. The exporter asserts that decomposing such a codepoint never changes its class, which the mark presort in the next commit relies on. The encoding modules do not change. Change-Id: I401f166992b8d305c5608d567304ac50d87dcb6f Signed-off-by: Thomas Kosiewski <tk@coder.com>
String.prototype.normalize("NFC") orders a run of marks by insertion in
ICU and JavaScriptCore, which is quadratic when the run is out of order:
a "a" plus 40k U+0301 and 40k U+0316 took 4 to 7 s on Bun. Stably sort
each run by class first. The NFC text is unchanged because NFC sorts the
same way and decomposition keeps a mark's class. The common path stays
allocation-free: the string is rebuilt only when a run is out of order.
The 64k-mark test took about 13.6 s before and 23 ms after.
Change-Id: Iafd7ce6b16adcc87dd95ebe2fcc120ca6f7a151c
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Change-Id: Id8b50a30d75c9eb04c87f644be2854873e6ba59f Signed-off-by: Thomas Kosiewski <tk@coder.com>
The generated Claude modules export `kind = "ctok"`. CodecEncoding gains a `kind: string` field and Tokenizer tells it from a BPE Encoding with `"kind" in data`, so every non-BPE encoding module can share one discriminator. Count and dispatch behave as before. Change-Id: Ic593562b5a54a7e51ef45e455d26a0b27f322628 Signed-off-by: Thomas Kosiewski <tk@coder.com>
…nizers Change-Id: I3dccacfc1d2d76d57c5b4bdfa21a9a4395c0484a Signed-off-by: Thomas Kosiewski <tk@coder.com>
Change-Id: I1f131c57a8739d4e8d7ea85de998a4649d86dc08 Signed-off-by: Thomas Kosiewski <tk@coder.com>
…7F in llama4, type large literals gemma4 exports kind, name and a codec, the shape PR #10 uses, so Tokenizer keeps one constructor path and no early return. llama4's contraction group now folds U+017F like the reference. merges and special_tokens carry a type, so the d.ts files stay small. SentencePieceTokenizer decodes extended special tokens. Change-Id: I11cb526e749c7945070c28c0d4e80aa16a5601ff Signed-off-by: Thomas Kosiewski <tk@coder.com>
Opus 4.7, Opus 4.8 and Opus 5 share one vocabulary but count content differently, so one encoding cannot be exact for all of them: - claude_v4_7 (Opus 4.7): ctok's 4.7 family, a frame that ends in a word boundary and a newline tail of n + 2. - claude_v4_8 (Opus 4.8, Sonnet 5, Fable 5): the previous claude_v4_7. - claude_v5 (Opus 5, Opus 5.5, Sonnet 5.5, Haiku 5.5, Fable 5.1): as claude_v4_8, after dropping trailing space, tab, CR, LF, VT and FF. count_tokens drops it from every user text block, not from assistant messages. The three encodings import one generated vocabulary module and share one lazily built trie. Ids are unchanged. The fixture now has rows from claude-opus-4-7 and claude-opus-5-5, and the compare script checks claude_v4_7 and claude_v5 against ctok's recorded Opus 4.7 and Opus 5 counts. Change-Id: I743f0ee31cf63990ab0a6ce6d6cc2f87f24bf1c5 Signed-off-by: Thomas Kosiewski <tk@coder.com>
Change-Id: I364c22eb8231fc03266ddba5b4936fdb4ae4f7bc Signed-off-by: Thomas Kosiewski <tk@coder.com>
Five fixture texts end in a space followed by VT or FF, or in a control character. Each is measured on all four models. Dropping VT or FF from the trim fails three claude_v5 cases. Change-Id: If82bba396431aefb7d4cacb27cf06903d748faeb Signed-off-by: Thomas Kosiewski <tk@coder.com>
The API joins a user message's text blocks with no separator and trims only the end of the last block, unless that block is only whitespace. It also trims the system prompt, and never an assistant message. Change-Id: I80a1acb4b5496c9332c4d437510fb8742d05e877 Signed-off-by: Thomas Kosiewski <tk@coder.com>
A final single space after an ideographic tone mark counts 1 too many on claude_v3, claude_v4_7 and claude_v4_8, as in ctok. The Opus 5 trailing whitespace trim does not apply to tool_result content. Change-Id: I99044454a0388f2e389dcb4b805ee6c6a42d9ce6 Signed-off-by: Thomas Kosiewski <tk@coder.com>
|
Review record for head @kylecarbs, your approval is on
Generated with |
Why
This library wasn't updated in a long while. It needs the new tokenizer for Opus and for a few other models. Anthropic switched tokenizers at Claude Opus 4.7. The current
claudeencoding (the Claude 2 BPE times 1.10) is off by -51% to +10% on Opus 4.7 and later, and by -32% to +10% on Opus 4.5 and 4.6.ctok (MIT, Python, by Sander Land) rebuilt both Claude vocabularies from
count_tokensprobes and reproduces the API. This PR ports ctok to TypeScript as offline encodings, one per counting behavior, so every current Claude model has an exact encoding.What
Four encodings, exported like the others (
ai-tokenizer/encoding/<name>). Each is named after the first model that counts that way. The last three share one vocabulary module.claude_v3claude_v4_7claude_v4_8claude_v5claude_v4_8, after dropping trailing ASCII whitespacecount(text)andencode(text).lengthare the content tokens of one user message. The frame is left to the model config, which a later PR updates. models.json does not change here. The oldclaudeencoding stays as it is.claude_v5trims trailing space, tab, CR, LF, VT and FF from the raw text before counting. A trailing control character or NBSP stops the trim. The API applies this at the end of each user message and at the end of the system prompt, never in assistant messages ortool_resultcontent. It joins a message's text blocks with no separator and trims only the last block, unless that block is only whitespace (["a ", "b ", "c "]costs what["a ", "b ", "c"]costs, and["a", " "]costs what"a "costs onclaude_v4_8). The README states the rule. The sdk PR applies it per message.Tokenizeraccepts aCodecEncoding({ kind, name, codec },kind = "ctok") next to the BPEEncoding, and handsencodeanddecodeto the codec.src/tokenizer.tschanges only at the constructor head and the first line ofencodeanddecode. The engine is insrc/claude/.decodereturns the normalized text. It is lossy and throwsRangeErroron an id outside the vocabulary.count("")is 0, because the API rejects empty content.8c43ffd3c7be467622a540f025516e49ef78d8b1(ctok 1.3.0) byscripts/generate-claude-encodings.ts, with ctok's Unicode 15.1 answers per BMP codepoint, so counts do not depend on the JS engine's Unicode version. NFC runs on the engine between codepoints unassigned in Unicode 15.1. Runs of combining marks are sorted by combining class first, because the engines' NFC is quadratic on a long unsorted run.Two deliberate differences from ctok, both measured on the API. First, on the Opus 4.8 and later frames, a run of n newlines at the end of the content tiles as n newlines, not n + 2 (284 of 284 probes; ctok's rule matches 236). Second,
claude_v5trims trailing whitespace, which ctok does not model.Type changes
Model["encoding"]iskeyof typeof encodings, so it widens from 4 names to 8:claude_v3,claude_v4_7,claude_v4_8andclaude_v5join the existing ones. Code that switches overmodel.encodingexhaustively gets four new cases. This PR keeps the widening on purpose, becausenew Tokenizer(encoding[model.encoding])must accept every encoding that a later models.json can name.Tokenizerconstructor parameter widens fromEncodingtoEncoding | CodecEncoding. New exported types:CodecandCodecEncoding.Encodingitself is unchanged.Evidence
API parity, in CI (
bun test):claude count_tokens fixture: 392/392 exact. The fixture has 98 texts measured onclaude-opus-4-6,claude-opus-4-7,claude-opus-4-8andclaude-opus-5-5. It covers the 9 audit samples, all-caps, contractions, whitespace runs, leading and trailing whitespace, trailing newline runs, mixed scripts, emoji, Devanagari, Arabic, code with tabs, JSON, numbers, URLs, a single character, control and private-use characters, a space followed by VT or FF, and more. Removing VT and FF from theclaude_v5trim fails three of these cases. Content =input_tokensminus the frame.scripts/generate-claude-fixture.tsmeasures the frame ascount_tokens("a") - 1and fails unless it equals the expected frame (7, 11, 6, 8). The API rejects empty and whitespace-only content, socount("")is asserted as 0 instead.A separate 295-text corpus (from the root's verifier), measured on all 14 models, one line per model:
claude-opus-4-5-20251101 (claude_v3): 295/295,claude-sonnet-4-5-20250929 (claude_v3): 295/295,claude-haiku-4-5-20251001 (claude_v3): 295/295,claude-opus-4-6 (claude_v3): 295/295,claude-sonnet-4-6 (claude_v3): 295/295claude-opus-4-7 (claude_v4_7): 295/295claude-opus-4-8 (claude_v4_8): 295/295,claude-sonnet-5 (claude_v4_8): 295/295,claude-fable-5 (claude_v4_8): 295/295claude-opus-5 (claude_v5): 295/295,claude-opus-5-5 (claude_v5): 295/295,claude-sonnet-5-5 (claude_v5): 295/295,claude-haiku-5-5 (claude_v5): 295/295,claude-fable-5-1 (claude_v5): 295/295all models: 4130/4130 exactParity with Python ctok, dev only (
bun scripts/compare-claude-ctok.ts --ctok <ctok clone>, ctok at the pinned commit through uv on Python 3.13, with the two divergences applied to the reference):claude_v3 vs ctok: 7603/7603,claude_v4_7 vs ctok: 7603/7603,claude_v4_8 vs ctok: 7603/7603,claude_v5 vs ctok: 7603/7603claude_v3 vs claude-opus-4-5: 2514/2514,claude_v4_7 vs claude-opus-4-7: 2514/2514,claude_v5 vs claude-opus-5: 2514/2514Performance (
bun bench/claude.ts, load average 88 on 96 cores, median of 3, ms at 1x and 2x size):"a"x 100k" "x 100k"\n"x 100k"!"x 100k"日"x 100kEvery 2x/1x ratio is between 1.73 and 2.33, so the time is linear.
claude_v4_8andclaude_v5run the same engine on the same vocabulary. The first count builds the trie once: 65 ms (v3 vocabulary) and 10 ms (shared vocabulary).Size (
bun run build, ESM, raw / gzip -9 bytes):claude_v3683,715 / 140,622. Shared vocabulary of the other three 174,733 / 44,168. Each of their entry chunks is about 600 / 380. Shared engine and Unicode tables 85,919 / 26,136. The oldclaudeencoding is 2,347,960 / 904,855.Dogfood with the repo's consumer harness (
.agents/skills/verify-ai-tokenizer/verify.sh launch, thendrive entry-points, packed tarball in a scratch project): ESM 30/30 and CJS 22/22 checks pass, including the 20 checks for the four new subpaths. The twotypes-*steps fail on this branch and on main alike:dist/index.d.tsanddist/sdk.d.tseach declare their ownTokenizerclass with private fields, so the README'scount({ tokenizer })does not type-check against the published types. That bug predates this PR.Gates on the pushed head:
npx prettier --check "src/**/*.{ts,tsx,json}"(only the pre-existingsrc/models.jsonwarning),bun run typecheck,bun test(594 pass),bun run build --dts=false,bun run build.Risks
claude_v3,claude_v4_7andclaude_v4_8("x \u302e ": API 5, this PR 6 on Opus 4.6 and Opus 4.8). Python ctok also says 6.claude_v5is right.claude_v5to assistant text, where it would undercount trailing whitespace by about 1 token per message.kindbranch at the top of theTokenizerconstructor. Whichever merges second resolves that conflict.bun run format --checkexpands toprettier --write ... --check, so it always passes and rewritessrc/models.jsonon main. This PR does not change that.Follow-ups
Tokenizerdeclaration in the published.d.tsfiles (pre-existing).Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:xhigh