Skip to content

feat(encoding): add claude_v3 and claude_v4_7 encodings ported from ctok - #10

Merged
ThomasK33 merged 19 commits into
mainfrom
feat/claude-ctok-tokenizers
Nov 3, 2026
Merged

ThomasK33 merged 19 commits into
mainfrom
feat/claude-ctok-tokenizers

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

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 claude encoding (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_tokens probes 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.

Encoding Models Frame What differs
claude_v3 Claude 3 through Opus 4.6, Sonnet 4.5/4.6, Haiku 4.5 7 ctok v3 vocabulary
claude_v4_7 Opus 4.7 11 new vocabulary, frame ends in a word boundary, newline tail n + 2 (ctok 4.7)
claude_v4_8 Opus 4.8, Sonnet 5, Fable 5 6 no word boundary at message start, newline tail n
claude_v5 Opus 5, Opus 5.5, Sonnet 5.5, Haiku 5.5, Fable 5.1 6 (Opus 5), 8 (others) as claude_v4_8, after dropping trailing ASCII whitespace
  • count(text) and encode(text).length are 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 old claude encoding stays as it is.
  • claude_v5 trims 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 or tool_result content. 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 on claude_v4_8). The README states the rule. The sdk PR applies it per message.
  • Tokenizer accepts a CodecEncoding ({ kind, name, codec }, kind = "ctok") next to the BPE Encoding, and hands encode and decode to the codec. src/tokenizer.ts changes only at the constructor head and the first line of encode and decode. The engine is in src/claude/.
  • Ids are synthetic (Anthropic does not publish ids): piece index, then single bytes, then byte prefixes. decode returns the normalized text. It is lossy and throws RangeError on an id outside the vocabulary. count("") is 0, because the API rejects empty content.
  • The data is generated from ctok commit 8c43ffd3c7be467622a540f025516e49ef78d8b1 (ctok 1.3.0) by scripts/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.
  • MIT notice for ctok appended to LICENSE. README section "Claude encodings" with the table, semantics and credit.

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_v5 trims trailing whitespace, which ctok does not model.

Type changes

  • Model["encoding"] is keyof typeof encodings, so it widens from 4 names to 8: claude_v3, claude_v4_7, claude_v4_8 and claude_v5 join the existing ones. Code that switches over model.encoding exhaustively gets four new cases. This PR keeps the widening on purpose, because new Tokenizer(encoding[model.encoding]) must accept every encoding that a later models.json can name.
  • The Tokenizer constructor parameter widens from Encoding to Encoding | CodecEncoding. New exported types: Codec and CodecEncoding. Encoding itself is unchanged.

Evidence

  • API parity, in CI (bun test): claude count_tokens fixture: 392/392 exact. The fixture has 98 texts measured on claude-opus-4-6, claude-opus-4-7, claude-opus-4-8 and claude-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 the claude_v5 trim fails three of these cases. Content = input_tokens minus the frame. scripts/generate-claude-fixture.ts measures the frame as count_tokens("a") - 1 and fails unless it equals the expected frame (7, 11, 6, 8). The API rejects empty and whitespace-only content, so count("") 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/295
    • claude-opus-4-7 (claude_v4_7): 295/295
    • claude-opus-4-8 (claude_v4_8): 295/295, claude-sonnet-5 (claude_v4_8): 295/295, claude-fable-5 (claude_v4_8): 295/295
    • claude-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/295
    • all models: 4130/4130 exact
  • Parity 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/7603
    • ctok's own recorded API counts (UDHR, Rosetta Code, MultiPL-E): claude_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/2514
    • Two adversarial reviewers also compared 1,667,440 and 301,424 more inputs on Bun and on Node 22 (Unicode 17) and found 0 count mismatches. They found one defect, the quadratic NFC, which this PR fixes.
  • Performance (bun bench/claude.ts, load average 88 on 96 cores, median of 3, ms at 1x and 2x size):

    Input claude_v3 claude_v4_7
    1M chars of prose 172 / 332 141 / 279
    "a" x 100k 8 / 14 7 / 12
    " " x 100k 17 / 31 17 / 34
    "\n" x 100k 113 / 264 28 / 57
    "!" x 100k 8 / 16 8 / 16
    "日" x 100k 15 / 28 14 / 25
    100k out-of-order combining marks 26 / 54 25 / 43

    Every 2x/1x ratio is between 1.73 and 2.33, so the time is linear. claude_v4_8 and claude_v5 run 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_v3 683,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 old claude encoding is 2,347,960 / 904,855.

  • Dogfood with the repo's consumer harness (.agents/skills/verify-ai-tokenizer/verify.sh launch, then drive 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 two types-* steps fail on this branch and on main alike: dist/index.d.ts and dist/sdk.d.ts each declare their own Tokenizer class with private fields, so the README's count({ 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-existing src/models.json warning), bun run typecheck, bun test (594 pass), bun run build --dts=false, bun run build.

Risks

  • ctok reconstructs counts, not Anthropic's token boundaries. Inputs outside the measured sets carry no guarantee.
  • Some Latin letters added in Unicode 16 (for example U+A7CC, U+A7CD, U+A7DA) count a few tokens off on every encoding. ctok has the same gap.
  • A single space at the end of the text, right after one of the ideographic tone marks U+302A to U+302F, counts 1 token too many on claude_v3, claude_v4_7 and claude_v4_8 ("x \u302e ": API 5, this PR 6 on Opus 4.6 and Opus 4.8). Python ctok also says 6. claude_v5 is right.
  • A JS engine with Unicode data older than 15.1 can count text with newer characters differently. Ids change when the data is regenerated.
  • The encodings count one text. The sdk must join a user message's text blocks before counting, and must not apply claude_v5 to assistant text, where it would undercount trailing whitespace by about 1 token per message.
  • A sibling PR (feat(encoding): add Gemma 4 (Gemini) and Llama 4 encodings #16, Gemma 4) also adds a kind branch at the top of the Tokenizer constructor. Whichever merges second resolves that conflict.
  • bun run format --check expands to prettier --write ... --check, so it always passes and rewrites src/models.json on main. This PR does not change that.

Follow-ups

  • Map each Claude model in models.json to its encoding and frame (the table above).
  • Report the Opus 4.8+ newline-tail and the Opus 5 trailing-whitespace measurements, and the Unicode 16 gap, to ctok upstream.
  • Mention in the changelog that Claude token ids are synthetic and change when the data is regenerated.
  • Fix the duplicated Tokenizer declaration in the published .d.ts files (pre-existing).

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: xhigh

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>
@ThomasK33
ThomasK33 marked this pull request as ready for review November 3, 2026 13:41
ThomasK33 added a commit that referenced this pull request Nov 3, 2026
…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>
@ThomasK33

Copy link
Copy Markdown
Member Author

Review record for head fbab6e3ecdb6d0d88b3be3b47fccf791bc896a17.

@kylecarbs, your approval is on 1c58418, an early single-encoding draft. Since then the PR has split into four encodings, one per Claude tokenizer behavior. It also adds the kind + codec shape and a public Codec / CodecEncoding type. Please re-review this head.

  • Reviews: 3 rounds plus a docs-only round. Codex does not respond on this repository, so the operator waived it here. Two independent review lanes (Claude Opus and gpt-6.1-sol) replaced it, and an independent verifier ran each round. A clean-context advisor recommends "ready with tracked follow-ups".

  • Exactness against count_tokens:

    • A verifier corpus of 295 texts gave 4130/4130 exact over 14 Claude models.
    • A holdout of 735 texts gave 10290/10290.
    • The committed fixture gives 392/392.
    • Parity with Python ctok is 7603/7603 per encoding.
  • Tests on this head: bun test 594 pass / 0 fail, build ok, CI green.

  • Known limits, documented in the README:

    1. A final space after U+302A to U+302F counts 1 too many on v3, v4_7 and v4_8.
    2. Unicode 16 letters U+A7CC, U+A7CD and U+A7DA count about 1 off.

    ctok has both gaps.

  • Follow-ups. Later PRs in this series do them:

    1. Map Claude models to these encodings in models.json.
    2. Apply the claude_v5 trim only at the end of a user message in count().
    3. Reconcile the Codec.encode signature with feat(encoding): add Gemma 4 (Gemini) and Llama 4 encodings #16.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high

@ThomasK33
ThomasK33 requested a review from kylecarbs November 3, 2026 15:13
@ThomasK33
ThomasK33 merged commit 6dec619 into main Nov 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants