token-estimate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/dist/index.cjs +0 -0
- package/dist/index.d.cts +66 -0
- package/dist/index.d.mts +66 -0
- package/dist/index.mjs +0 -0
- package/package.json +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom Ryan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# token-estimate
|
|
2
|
+
|
|
3
|
+
Estimate how many tokens a string will cost, without installing a tokenizer.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
import {estimateTokens, fitsWithin, truncateToTokens} from 'token-estimate';
|
|
7
|
+
|
|
8
|
+
estimateTokens('The build finished with no errors.'); // 7, and the exact count is 7
|
|
9
|
+
fitsWithin(hugeToolOutput, 8000); // false
|
|
10
|
+
truncateToTokens(hugeToolOutput, 8000).text; // trimmed to fit
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Zero runtime dependencies. Strings in, numbers out; nothing is spawned, read or written. ESM, CommonJS and TypeScript declarations. Node 18+.
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install token-estimate
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Why estimate at all
|
|
20
|
+
|
|
21
|
+
Because the exact answer is expensive to carry:
|
|
22
|
+
|
|
23
|
+
| | installed size | cold start to first count |
|
|
24
|
+
|---|---:|---:|
|
|
25
|
+
| `gpt-tokenizer` (exact) | 29.8 MB | 146 ms |
|
|
26
|
+
| `js-tiktoken` (exact) | 22.0 MB | 253 ms |
|
|
27
|
+
| **token-estimate** | **39 kB** | **~5 ms** |
|
|
28
|
+
|
|
29
|
+
Estimating is also linear in input length: 160 kB of pathological whitespace takes about 5 ms.
|
|
30
|
+
|
|
31
|
+
That 29.8 MB lands in `node_modules` whichever encoding you import, and a single encoding still costs 129 ms of process start. Throughput is comparable either way, so the whole trade is size. If you need exact counts and can afford the weight, use a real tokenizer — this package will tell you the same thing to within a few percent for a thousandth of the footprint.
|
|
32
|
+
|
|
33
|
+
## Why not characters ÷ 4
|
|
34
|
+
|
|
35
|
+
Because BPE does not see characters. It sees the chunks its pre-tokenizer produces, and what a chunk costs depends on what it is. `estimateTokenCount` is one token. `YWFhYWFhYWFh`, the same length, is many, because no learned merge covers it. Sixty spaces of indentation are one token. A Japanese character is about two-thirds of one.
|
|
36
|
+
|
|
37
|
+
Measured against the exact tokenizer on 247 files from npm packages that were **never seen during calibration**:
|
|
38
|
+
|
|
39
|
+
| | median error | worst 1% | undercounts | undercounts by >10% |
|
|
40
|
+
|---|---:|---:|---:|---:|
|
|
41
|
+
| **token-estimate** | **5.9%** | 41.7% | 55.5% | **19.0%** |
|
|
42
|
+
| `tokenx` | 16.1% | 22.6% | 65.2% | 52.2% |
|
|
43
|
+
| `length / 4` | 23.5% | 15.4% | 85.8% | 71.7% |
|
|
44
|
+
|
|
45
|
+
The gap is not spread evenly. It is concentrated in the content that tools actually move around:
|
|
46
|
+
|
|
47
|
+
| content | token-estimate | `tokenx` |
|
|
48
|
+
|---|---:|---:|
|
|
49
|
+
| base64, hashes, JWTs | **99.3%** | 25.2% |
|
|
50
|
+
| indentation and blank lines | **110.9%** | 71.0% |
|
|
51
|
+
| URLs | **89.7%** | 133.4% |
|
|
52
|
+
| emoji | **87.8%** | 63.6% |
|
|
53
|
+
| Markdown prose | 98.7% | 105.6% |
|
|
54
|
+
|
|
55
|
+
A 25% reading on base64 is a fourfold undercount. If that feeds a context-window check, the request is assembled, sent, and rejected.
|
|
56
|
+
|
|
57
|
+
## Direction matters more than magnitude
|
|
58
|
+
|
|
59
|
+
Overcounting wastes budget you paid for. Undercounting means the request fails. They are not the same mistake, so there are two modes:
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
estimateTokens(text); // closest on average
|
|
63
|
+
estimateTokens(text, {mode: 'safe'}); // biased upward, for decisions
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`safe` undercounted **8.1%** of held-out samples on `o200k_base` and 13.8% on `cl100k_base`, against 65% and 67% for `tokenx`, at the cost of reading about 10% high. It is a calibration, not a guarantee — see Limits.
|
|
67
|
+
|
|
68
|
+
`fitsWithin`, `truncateToTokens` and `splitByTokens` all use `safe` by default, because each one is making a decision rather than reporting a number.
|
|
69
|
+
|
|
70
|
+
## API
|
|
71
|
+
|
|
72
|
+
### `estimateTokens(text, options?)`
|
|
73
|
+
|
|
74
|
+
`options`: `{encoding = 'o200k_base', mode = 'estimate', weights?}`.
|
|
75
|
+
|
|
76
|
+
`encoding` is `'o200k_base'` (GPT-4o and newer) or `'cl100k_base'` (GPT-4, GPT-3.5, `text-embedding-3-*`). `weights` replaces the calibration, to tune for a model shipped here.
|
|
77
|
+
|
|
78
|
+
### `fitsWithin(text, limit, options?)`
|
|
79
|
+
|
|
80
|
+
Whether `text` is expected to fit in `limit` tokens. Uses `mode: 'safe'`.
|
|
81
|
+
|
|
82
|
+
### `truncateToTokens(text, maxTokens, options?)`
|
|
83
|
+
|
|
84
|
+
Returns `{text, truncated, estimatedTokens}`. Cuts only at a chunk boundary, so a character, a surrogate pair and a combining sequence are never split.
|
|
85
|
+
|
|
86
|
+
### `splitByTokens(text, maxTokens, options?)`
|
|
87
|
+
|
|
88
|
+
Consecutive pieces, each estimated to fit. `pieces.join('')` returns the input exactly.
|
|
89
|
+
|
|
90
|
+
### `analyze(text)`
|
|
91
|
+
|
|
92
|
+
The breakdown behind the number — `wordChunks`, `opaqueChars`, `cjkChars`, `spaceRuns` and the rest — for when an estimate is surprising and you want to know why.
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
analyze('const key = "YWFhYWFhYWFhYWFh";').opaqueChars; // 16
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### `supportedEncodings()`
|
|
99
|
+
|
|
100
|
+
All three throw `TypeError` on a non-string input or an unknown encoding or mode, and `RangeError` on a limit that is not positive.
|
|
101
|
+
|
|
102
|
+
## How it was calibrated
|
|
103
|
+
|
|
104
|
+
Weights were fitted against `gpt-tokenizer`'s exact BPE on a corpus built from 42 published npm packages — prose, source, declaration files, JSON, and non-English documentation — split **by package**, so the files used to measure come from packages the fit never saw. Files are capped per package so that one large library cannot decide the weights for everyone. The fit trims its worst 3% of residuals, because a handful of samples are not representative text at all: character-encoding tables whose escaped JSON costs more tokens than it has characters.
|
|
105
|
+
|
|
106
|
+
Per-character rates (CJK, other scripts, long runs, astral characters) are **measured directly** rather than fitted, by tokenising pure samples of each and dividing. Regression cannot recover them reliably, because in real files those characters never appear alone; fitting gave CJK 1.88 and 4.0 tokens per character where measurement gives 0.65 and 0.91, and the fitted values overcounted Japanese prose threefold.
|
|
107
|
+
|
|
108
|
+
## Limits
|
|
109
|
+
|
|
110
|
+
This is an estimator. It has no vocabulary, so it cannot be exact, and `safe` is a calibrated bias rather than a proven bound — it undercounted 8.1% and 13.8% of held-out samples on the two encodings. For billing, quota enforcement, or anything where being wrong is expensive, use a real tokenizer.
|
|
111
|
+
|
|
112
|
+
Known weak spots, all measured: Greek and Cyrillic read about 36% high on `o200k_base`; emoji read 88% and 80% of true on the two encodings; dense CJK inside JSON data files is the worst case in the corpus at 38% of true. Only `o200k_base` and `cl100k_base` are calibrated — other model families differ, and `weights` exists for that. Text is treated as a whole: chat message framing and tool-call scaffolding add tokens this does not see.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT.
|
package/dist/index.cjs
ADDED
|
Binary file
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type Encoding = 'o200k_base' | 'cl100k_base';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `estimate` is calibrated to sit close to the true count on average. `safe` is calibrated to sit
|
|
5
|
+
* above it in the large majority of cases, for decisions where undercounting fails the request.
|
|
6
|
+
*/
|
|
7
|
+
export type Mode = 'estimate' | 'safe';
|
|
8
|
+
|
|
9
|
+
export interface Options {
|
|
10
|
+
/** Which tokenizer to estimate for. Default `'o200k_base'`. */
|
|
11
|
+
encoding?: Encoding;
|
|
12
|
+
/** Default `'estimate'` when counting, `'safe'` for the functions that decide whether text fits. */
|
|
13
|
+
mode?: Mode;
|
|
14
|
+
/**
|
|
15
|
+
* Replace the calibrated weights, to tune the estimate for a model this package does not ship a
|
|
16
|
+
* calibration for. Keys match the fields reported by `analyze`, in tokens per unit.
|
|
17
|
+
*/
|
|
18
|
+
weights?: Record<string, number>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What `analyze` reports: the text broken down the way the estimate charges it. */
|
|
22
|
+
export interface Features {
|
|
23
|
+
wordChunks: number;
|
|
24
|
+
wordChars: number;
|
|
25
|
+
opaqueChunks: number;
|
|
26
|
+
opaqueChars: number;
|
|
27
|
+
digitChunks: number;
|
|
28
|
+
digitChars: number;
|
|
29
|
+
punctChunks: number;
|
|
30
|
+
punctChars: number;
|
|
31
|
+
newlineRuns: number;
|
|
32
|
+
newlineChars: number;
|
|
33
|
+
spaceRuns: number;
|
|
34
|
+
spaceChars: number;
|
|
35
|
+
cjkChars: number;
|
|
36
|
+
otherScriptChars: number;
|
|
37
|
+
astralUnits: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TruncateResult {
|
|
41
|
+
text: string;
|
|
42
|
+
truncated: boolean;
|
|
43
|
+
estimatedTokens: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Break `text` down the way the estimate does, so a surprising number can be explained: how much is
|
|
48
|
+
* ordinary words, how much is opaque, how much is another script, how much is whitespace.
|
|
49
|
+
* Throws `TypeError` for a non-string input.
|
|
50
|
+
*/
|
|
51
|
+
export function analyze(text: string): Features;
|
|
52
|
+
|
|
53
|
+
/** Estimate how many tokens `text` will cost. Throws `TypeError` for a non-string input. */
|
|
54
|
+
export function estimateTokens(text: string, options?: Options): number;
|
|
55
|
+
|
|
56
|
+
/** Whether `text` is expected to fit inside `limit` tokens. Uses `mode: 'safe'` unless overridden. */
|
|
57
|
+
export function fitsWithin(text: string, limit: number, options?: Options): boolean;
|
|
58
|
+
|
|
59
|
+
/** Cut `text` to an estimated `maxTokens`, never splitting a character. Uses `mode: 'safe'`. */
|
|
60
|
+
export function truncateToTokens(text: string, maxTokens: number, options?: Options): TruncateResult;
|
|
61
|
+
|
|
62
|
+
/** Split `text` into consecutive pieces each estimated to fit `maxTokens`. Joining them restores the input. */
|
|
63
|
+
export function splitByTokens(text: string, maxTokens: number, options?: Options): string[];
|
|
64
|
+
|
|
65
|
+
/** The encodings this package is calibrated for. */
|
|
66
|
+
export function supportedEncodings(): Encoding[];
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type Encoding = 'o200k_base' | 'cl100k_base';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `estimate` is calibrated to sit close to the true count on average. `safe` is calibrated to sit
|
|
5
|
+
* above it in the large majority of cases, for decisions where undercounting fails the request.
|
|
6
|
+
*/
|
|
7
|
+
export type Mode = 'estimate' | 'safe';
|
|
8
|
+
|
|
9
|
+
export interface Options {
|
|
10
|
+
/** Which tokenizer to estimate for. Default `'o200k_base'`. */
|
|
11
|
+
encoding?: Encoding;
|
|
12
|
+
/** Default `'estimate'` when counting, `'safe'` for the functions that decide whether text fits. */
|
|
13
|
+
mode?: Mode;
|
|
14
|
+
/**
|
|
15
|
+
* Replace the calibrated weights, to tune the estimate for a model this package does not ship a
|
|
16
|
+
* calibration for. Keys match the fields reported by `analyze`, in tokens per unit.
|
|
17
|
+
*/
|
|
18
|
+
weights?: Record<string, number>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What `analyze` reports: the text broken down the way the estimate charges it. */
|
|
22
|
+
export interface Features {
|
|
23
|
+
wordChunks: number;
|
|
24
|
+
wordChars: number;
|
|
25
|
+
opaqueChunks: number;
|
|
26
|
+
opaqueChars: number;
|
|
27
|
+
digitChunks: number;
|
|
28
|
+
digitChars: number;
|
|
29
|
+
punctChunks: number;
|
|
30
|
+
punctChars: number;
|
|
31
|
+
newlineRuns: number;
|
|
32
|
+
newlineChars: number;
|
|
33
|
+
spaceRuns: number;
|
|
34
|
+
spaceChars: number;
|
|
35
|
+
cjkChars: number;
|
|
36
|
+
otherScriptChars: number;
|
|
37
|
+
astralUnits: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TruncateResult {
|
|
41
|
+
text: string;
|
|
42
|
+
truncated: boolean;
|
|
43
|
+
estimatedTokens: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Break `text` down the way the estimate does, so a surprising number can be explained: how much is
|
|
48
|
+
* ordinary words, how much is opaque, how much is another script, how much is whitespace.
|
|
49
|
+
* Throws `TypeError` for a non-string input.
|
|
50
|
+
*/
|
|
51
|
+
export function analyze(text: string): Features;
|
|
52
|
+
|
|
53
|
+
/** Estimate how many tokens `text` will cost. Throws `TypeError` for a non-string input. */
|
|
54
|
+
export function estimateTokens(text: string, options?: Options): number;
|
|
55
|
+
|
|
56
|
+
/** Whether `text` is expected to fit inside `limit` tokens. Uses `mode: 'safe'` unless overridden. */
|
|
57
|
+
export function fitsWithin(text: string, limit: number, options?: Options): boolean;
|
|
58
|
+
|
|
59
|
+
/** Cut `text` to an estimated `maxTokens`, never splitting a character. Uses `mode: 'safe'`. */
|
|
60
|
+
export function truncateToTokens(text: string, maxTokens: number, options?: Options): TruncateResult;
|
|
61
|
+
|
|
62
|
+
/** Split `text` into consecutive pieces each estimated to fit `maxTokens`. Joining them restores the input. */
|
|
63
|
+
export function splitByTokens(text: string, maxTokens: number, options?: Options): string[];
|
|
64
|
+
|
|
65
|
+
/** The encodings this package is calibrated for. */
|
|
66
|
+
export function supportedEncodings(): Encoding[];
|
package/dist/index.mjs
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "token-estimate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Estimate LLM token counts without shipping a 30 MB tokenizer, including the base64, hashes, non-Latin text and indentation that character-ratio estimates get badly wrong.",
|
|
5
|
+
"main": "./dist/index.cjs",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.cts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": {
|
|
11
|
+
"types": "./dist/index.d.mts",
|
|
12
|
+
"default": "./dist/index.mjs"
|
|
13
|
+
},
|
|
14
|
+
"require": {
|
|
15
|
+
"types": "./dist/index.d.cts",
|
|
16
|
+
"default": "./dist/index.cjs"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"author": "Tom Ryan",
|
|
31
|
+
"keywords": [
|
|
32
|
+
"tokens",
|
|
33
|
+
"token-count",
|
|
34
|
+
"tiktoken",
|
|
35
|
+
"llm",
|
|
36
|
+
"openai",
|
|
37
|
+
"context-window",
|
|
38
|
+
"estimate",
|
|
39
|
+
"budget",
|
|
40
|
+
"truncate",
|
|
41
|
+
"agent"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "node scripts/build.mjs",
|
|
45
|
+
"test": "node --test test/api.test.mjs",
|
|
46
|
+
"test:types": "tsc -p test/tsconfig.json",
|
|
47
|
+
"test:pack": "node scripts/test-pack.mjs",
|
|
48
|
+
"verify": "npm run build && npm test && npm run test:types && npm run test:pack",
|
|
49
|
+
"prepack": "npm run build",
|
|
50
|
+
"prepublishOnly": "npm run verify"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"typescript": "5.9.3"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public",
|
|
57
|
+
"registry": "https://registry.npmjs.org/"
|
|
58
|
+
},
|
|
59
|
+
"repository": {
|
|
60
|
+
"type": "git",
|
|
61
|
+
"url": "git+https://github.com/Atomics-hub/token-estimate.git"
|
|
62
|
+
},
|
|
63
|
+
"homepage": "https://github.com/Atomics-hub/token-estimate#readme",
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://github.com/Atomics-hub/token-estimate/issues"
|
|
66
|
+
}
|
|
67
|
+
}
|