@trazum/core 1.80.1 → 1.82.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/README.md +34 -18
- package/dist/advisories.d.ts +33 -1
- package/dist/advisories.d.ts.map +1 -1
- package/dist/advisories.js +103 -16
- package/dist/advisories.js.map +1 -1
- package/dist/band.d.ts +131 -0
- package/dist/band.d.ts.map +1 -0
- package/dist/band.js +210 -0
- package/dist/band.js.map +1 -0
- package/dist/claude-code.d.ts +28 -0
- package/dist/claude-code.d.ts.map +1 -1
- package/dist/claude-code.js +25 -1
- package/dist/claude-code.js.map +1 -1
- package/dist/conform.d.ts +1 -1
- package/dist/conform.d.ts.map +1 -1
- package/dist/conform.js +84 -1
- package/dist/conform.js.map +1 -1
- package/dist/helicone.d.ts +92 -0
- package/dist/helicone.d.ts.map +1 -0
- package/dist/helicone.js +194 -0
- package/dist/helicone.js.map +1 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +26 -20
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +9 -3
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +43 -1
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/litellm.d.ts +98 -0
- package/dist/litellm.d.ts.map +1 -0
- package/dist/litellm.js +207 -0
- package/dist/litellm.js.map +1 -0
- package/dist/measured-profile.d.ts +4 -0
- package/dist/measured-profile.d.ts.map +1 -1
- package/dist/measured-profile.js +22 -1
- package/dist/measured-profile.js.map +1 -1
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +36 -0
- package/dist/schemas.js.map +1 -1
- package/dist/tokenizer.d.ts +45 -49
- package/dist/tokenizer.d.ts.map +1 -1
- package/dist/tokenizer.js +100 -51
- package/dist/tokenizer.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/verdict-bridge.d.ts +113 -0
- package/dist/verdict-bridge.d.ts.map +1 -0
- package/dist/verdict-bridge.js +127 -0
- package/dist/verdict-bridge.js.map +1 -0
- package/package.json +1 -1
- package/src/advisories.ts +132 -15
- package/src/band.ts +213 -0
- package/src/claude-code.ts +53 -1
- package/src/conform.ts +100 -1
- package/src/helicone.ts +227 -0
- package/src/i18n/en.ts +27 -20
- package/src/i18n/es.ts +10 -3
- package/src/i18n/types.ts +45 -1
- package/src/index.ts +17 -1
- package/src/litellm.ts +243 -0
- package/src/measured-profile.ts +26 -2
- package/src/schemas.ts +36 -0
- package/src/tokenizer.ts +101 -51
- package/src/types.ts +1 -0
- package/src/verdict-bridge.ts +200 -0
package/src/litellm.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiteLLM's spend log, read as a usage log.
|
|
3
|
+
*
|
|
4
|
+
* `from-claude-code` proved the pattern and `from-otel` generalised it: a pure
|
|
5
|
+
* converter turns one tool's export into Trazum's usage-log records, and every
|
|
6
|
+
* door prices it from there. LiteLLM is the gateway a great many teams already
|
|
7
|
+
* put in front of every provider, so its `LiteLLM_SpendLogs` table is the one
|
|
8
|
+
* export most likely to already exist on somebody's disk.
|
|
9
|
+
*
|
|
10
|
+
* **The format is derived, not guessed.** Every field below is read off
|
|
11
|
+
* `litellm/proxy/schema.prisma` in BerriAI/litellm, model `LiteLLM_SpendLogs`.
|
|
12
|
+
* A converter written from memory of an API is a converter that silently
|
|
13
|
+
* mis-reads somebody's bill, and this project has one rule above all others
|
|
14
|
+
* about numbers it cannot justify.
|
|
15
|
+
*
|
|
16
|
+
* ## What maps
|
|
17
|
+
*
|
|
18
|
+
* | LiteLLM | Trazum |
|
|
19
|
+
* | --- | --- |
|
|
20
|
+
* | `model` | `model` |
|
|
21
|
+
* | `prompt_tokens` | `usage.input_tokens` |
|
|
22
|
+
* | `completion_tokens` | `usage.output_tokens` |
|
|
23
|
+
* | `startTime` | `ts` |
|
|
24
|
+
* | `session_id` | `session` |
|
|
25
|
+
* | `request_tags[0]`, else `metadata.tags[0]`, else `model_group` | `label` |
|
|
26
|
+
*
|
|
27
|
+
* ## What deliberately does not cross
|
|
28
|
+
*
|
|
29
|
+
* The row carries `messages` and `response` — the prompt itself and the
|
|
30
|
+
* completion — plus `api_key` (hashed, still credential-shaped),
|
|
31
|
+
* `requester_ip_address`, `user` and `end_user`. **None of it is read.** The
|
|
32
|
+
* converter names the fields it takes and takes nothing else, and a fixture
|
|
33
|
+
* plants a marker in each of them and greps the whole output.
|
|
34
|
+
*
|
|
35
|
+
* ## What it refuses to invent
|
|
36
|
+
*
|
|
37
|
+
* `cache_hit` is a flag and `cache_key` an identifier; neither is a token
|
|
38
|
+
* count. A converted record therefore carries no cache split at all, and the
|
|
39
|
+
* caching questions come back `cannot-tell` rather than answered from a
|
|
40
|
+
* guess — the same refusal as inventing a price. `cacheFlagged` counts the
|
|
41
|
+
* rows that said "hit" so the operator can see the gap is real rather than
|
|
42
|
+
* empty.
|
|
43
|
+
*
|
|
44
|
+
* `spend` is LiteLLM's own priced figure and is **never** merged into
|
|
45
|
+
* Trazum's catalogue-priced total. It is a second measurement of the same
|
|
46
|
+
* calls, kept apart the way the store's provider-billed standing is kept
|
|
47
|
+
* apart from the log's, and returned on its own so a caller who wants to
|
|
48
|
+
* compare the two can, deliberately.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/** One converted record, shaped exactly as `parseUsageLine` reads it. */
|
|
52
|
+
export interface LiteLlmRecord {
|
|
53
|
+
model: string;
|
|
54
|
+
ts?: string;
|
|
55
|
+
label?: string;
|
|
56
|
+
session?: string;
|
|
57
|
+
usage: {
|
|
58
|
+
input_tokens: number;
|
|
59
|
+
output_tokens: number;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface LiteLlmConversion {
|
|
64
|
+
records: LiteLlmRecord[];
|
|
65
|
+
/** Rows that were spend logs and converted. */
|
|
66
|
+
rows: number;
|
|
67
|
+
/** Rows naming no model: counted, never guessed at, never dropped silently. */
|
|
68
|
+
unnamedModel: number;
|
|
69
|
+
/** Rows carrying no token counts at all — a logged call nobody can price. */
|
|
70
|
+
noTokens: number;
|
|
71
|
+
/** Rows LiteLLM marked as a cache hit, with no token split to act on. */
|
|
72
|
+
cacheFlagged: number;
|
|
73
|
+
/**
|
|
74
|
+
* LiteLLM's own total for the converted rows, in USD, or null when no row
|
|
75
|
+
* carried one. **Never** added to anything Trazum computes: it is the
|
|
76
|
+
* gateway's arithmetic over the gateway's price table, and merging two
|
|
77
|
+
* price tables into one figure is how a report becomes quietly wrong.
|
|
78
|
+
*/
|
|
79
|
+
reportedSpendUsd: number | null;
|
|
80
|
+
/** Lines or documents that did not parse as JSON at all. */
|
|
81
|
+
unparseable: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const asObject = (value: unknown): Record<string, unknown> | null =>
|
|
85
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
86
|
+
? (value as Record<string, unknown>)
|
|
87
|
+
: null;
|
|
88
|
+
|
|
89
|
+
const tryParse = (text: string): unknown => {
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(text);
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const asCount = (value: unknown): number | undefined => {
|
|
98
|
+
const n = typeof value === 'string' ? Number(value) : value;
|
|
99
|
+
return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The first tag LiteLLM carries for a row, from the two places it puts them.
|
|
104
|
+
*
|
|
105
|
+
* `request_tags` is the documented column and `metadata.tags` is where the
|
|
106
|
+
* proxy writes them when the caller passes them in the request body. First
|
|
107
|
+
* only: a workload has one name in a bill, and joining several tags into one
|
|
108
|
+
* label would invent a workload that does not exist.
|
|
109
|
+
*/
|
|
110
|
+
function labelOf(row: Record<string, unknown>): string | undefined {
|
|
111
|
+
const fromColumn = Array.isArray(row.request_tags) ? row.request_tags : null;
|
|
112
|
+
if (fromColumn !== null && typeof fromColumn[0] === 'string' && fromColumn[0] !== '') {
|
|
113
|
+
return fromColumn[0];
|
|
114
|
+
}
|
|
115
|
+
const metadata = asObject(row.metadata);
|
|
116
|
+
const fromMetadata = metadata !== null && Array.isArray(metadata.tags) ? metadata.tags : null;
|
|
117
|
+
if (fromMetadata !== null && typeof fromMetadata[0] === 'string' && fromMetadata[0] !== '') {
|
|
118
|
+
return fromMetadata[0];
|
|
119
|
+
}
|
|
120
|
+
// The public model name a proxy route is known by. A weaker label than a
|
|
121
|
+
// tag — it groups by route rather than by workload — and better than none.
|
|
122
|
+
if (typeof row.model_group === 'string' && row.model_group !== '') return row.model_group;
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** `startTime` as an ISO instant, or undefined when it is not a time. */
|
|
127
|
+
function timeOf(row: Record<string, unknown>): string | undefined {
|
|
128
|
+
const raw = row.startTime ?? row.start_time;
|
|
129
|
+
if (typeof raw !== 'string' && typeof raw !== 'number') return undefined;
|
|
130
|
+
const ms = typeof raw === 'number' ? raw : Date.parse(raw);
|
|
131
|
+
if (!Number.isFinite(ms)) return undefined;
|
|
132
|
+
return new Date(ms).toISOString();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Convert a LiteLLM spend-log export.
|
|
137
|
+
*
|
|
138
|
+
* Accepts a JSON array of rows, a single row, `{ data: [...] }` as the proxy's
|
|
139
|
+
* own endpoints return it, or newline-delimited rows — so a `psql --json`
|
|
140
|
+
* dump, an API response and a streamed capture all work. Pure over its input.
|
|
141
|
+
*/
|
|
142
|
+
export function litellmRecords(text: string): LiteLlmConversion {
|
|
143
|
+
const out: LiteLlmConversion = {
|
|
144
|
+
records: [],
|
|
145
|
+
rows: 0,
|
|
146
|
+
unnamedModel: 0,
|
|
147
|
+
noTokens: 0,
|
|
148
|
+
cacheFlagged: 0,
|
|
149
|
+
reportedSpendUsd: null,
|
|
150
|
+
unparseable: 0,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const rows: unknown[] = [];
|
|
154
|
+
const whole = tryParse(text);
|
|
155
|
+
if (whole !== undefined) {
|
|
156
|
+
if (Array.isArray(whole)) rows.push(...whole);
|
|
157
|
+
else {
|
|
158
|
+
const doc = asObject(whole);
|
|
159
|
+
// `{ data: [...] }` is what the proxy's spend endpoints return.
|
|
160
|
+
if (doc !== null && Array.isArray(doc.data)) rows.push(...doc.data);
|
|
161
|
+
else if (doc !== null) rows.push(doc);
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
for (const line of text.split('\n')) {
|
|
165
|
+
if (line.trim() === '') continue;
|
|
166
|
+
const parsed = tryParse(line);
|
|
167
|
+
if (parsed === undefined) out.unparseable += 1;
|
|
168
|
+
else if (Array.isArray(parsed)) rows.push(...parsed);
|
|
169
|
+
else rows.push(parsed);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let spend = 0;
|
|
174
|
+
let sawSpend = false;
|
|
175
|
+
|
|
176
|
+
for (const raw of rows) {
|
|
177
|
+
const row = asObject(raw);
|
|
178
|
+
if (row === null) continue;
|
|
179
|
+
// A spend log row is one that carries at least one of the two token
|
|
180
|
+
// columns. Anything else in the same file is not a call and is skipped
|
|
181
|
+
// rather than converted into a record of zero.
|
|
182
|
+
const input = asCount(row.prompt_tokens);
|
|
183
|
+
const output = asCount(row.completion_tokens);
|
|
184
|
+
if (input === undefined && output === undefined) continue;
|
|
185
|
+
|
|
186
|
+
out.rows += 1;
|
|
187
|
+
|
|
188
|
+
if (input === 0 && output === 0) out.noTokens += 1;
|
|
189
|
+
if (row.cache_hit === true || row.cache_hit === 'true' || row.cache_hit === 'True') {
|
|
190
|
+
out.cacheFlagged += 1;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const reported = asCount(row.spend);
|
|
194
|
+
if (reported !== undefined) {
|
|
195
|
+
spend += reported;
|
|
196
|
+
sawSpend = true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const model = typeof row.model === 'string' ? row.model.trim() : '';
|
|
200
|
+
if (model === '') {
|
|
201
|
+
// Counted, never guessed at. `model_group` is a route name, not a model,
|
|
202
|
+
// and pricing a call by the route it took would be a figure attributed
|
|
203
|
+
// to something it does not describe.
|
|
204
|
+
out.unnamedModel += 1;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const ts = timeOf(row);
|
|
209
|
+
const label = labelOf(row);
|
|
210
|
+
const session = typeof row.session_id === 'string' && row.session_id !== ''
|
|
211
|
+
? row.session_id
|
|
212
|
+
: undefined;
|
|
213
|
+
|
|
214
|
+
out.records.push({
|
|
215
|
+
model,
|
|
216
|
+
...(ts !== undefined ? { ts } : {}),
|
|
217
|
+
...(label !== undefined ? { label } : {}),
|
|
218
|
+
...(session !== undefined ? { session } : {}),
|
|
219
|
+
usage: { input_tokens: input ?? 0, output_tokens: output ?? 0 },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
out.reportedSpendUsd = sawSpend ? spend : null;
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Whether a file looks like a LiteLLM spend log, by shape.
|
|
229
|
+
*
|
|
230
|
+
* Deliberately narrow: `request_id` beside one of the two token columns, and
|
|
231
|
+
* a field no other export in this project carries. A looser test would claim
|
|
232
|
+
* an OpenAI usage response, whose rows also have `prompt_tokens`, and refuse
|
|
233
|
+
* it in this file's words instead of letting the code that can read it try.
|
|
234
|
+
*/
|
|
235
|
+
export function looksLikeLiteLlm(text: string, prefixBytes = 8192): boolean {
|
|
236
|
+
const head = text.slice(0, prefixBytes);
|
|
237
|
+
if (!head.includes('prompt_tokens') && !head.includes('completion_tokens')) return false;
|
|
238
|
+
return (
|
|
239
|
+
head.includes('"request_id"') ||
|
|
240
|
+
head.includes('"custom_llm_provider"') ||
|
|
241
|
+
head.includes('"model_group"')
|
|
242
|
+
);
|
|
243
|
+
}
|
package/src/measured-profile.ts
CHANGED
|
@@ -70,6 +70,10 @@ export interface MeasuredUsage {
|
|
|
70
70
|
* call is the same size. It is handed over as the better of two
|
|
71
71
|
* approximations — a measured token share beats a typed call share — and
|
|
72
72
|
* named honestly here so no rendering can call it something it is not.
|
|
73
|
+
*
|
|
74
|
+
* The denominator is every input token, **writes included**. Leaving writes
|
|
75
|
+
* out made a constantly-rewritten cache report a high hit rate, which is the
|
|
76
|
+
* one workload that most needs to be told otherwise.
|
|
73
77
|
*/
|
|
74
78
|
cacheReadShare: number;
|
|
75
79
|
/**
|
|
@@ -105,6 +109,7 @@ export function measuredUsage(
|
|
|
105
109
|
const outputTokens = slices.reduce((sum, row) => sum + row.breakdown.outputTokens, 0);
|
|
106
110
|
const inputTokens = slices.reduce((sum, row) => sum + row.breakdown.inputTokens, 0);
|
|
107
111
|
const cacheReadTokens = slices.reduce((sum, row) => sum + row.breakdown.cacheReadTokens, 0);
|
|
112
|
+
const cacheWriteTokens = slices.reduce((sum, row) => sum + row.breakdown.cacheWriteTokens, 0);
|
|
108
113
|
|
|
109
114
|
// The model carrying the most spend. Ties break on the larger call count, so
|
|
110
115
|
// the answer is stable rather than dependent on map ordering.
|
|
@@ -127,8 +132,27 @@ export function measuredUsage(
|
|
|
127
132
|
? { fromDays: spanDays, factor: SCALE_TO_DAYS / spanDays }
|
|
128
133
|
: null;
|
|
129
134
|
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Every input token in the denominator, writes included.
|
|
137
|
+
*
|
|
138
|
+
* This was `reads / (input + reads)`, which leaves cache *writes* out of the
|
|
139
|
+
* total it is a share of. A workload that rewrites its prefix on every call
|
|
140
|
+
* and reads it back rarely then reports a **high** hit rate: with 100 read
|
|
141
|
+
* tokens, no plain input and 10,000 written, the old expression answers 100%
|
|
142
|
+
* where the truth is 1%.
|
|
143
|
+
*
|
|
144
|
+
* That is the worst direction for this number to be wrong in. It is handed
|
|
145
|
+
* to `optimize` as `cacheHitRate`, which decides whether caching is paying
|
|
146
|
+
* off — so the shape that is burning money on writes was the shape most
|
|
147
|
+
* likely to be told its cache was working perfectly.
|
|
148
|
+
*
|
|
149
|
+
* Writes are input tokens: they are billed at the input rate times the write
|
|
150
|
+
* multiplier, they arrive in the same `usage` block, and any accounting of
|
|
151
|
+
* "what share of the input came from cache" that omits them is answering a
|
|
152
|
+
* narrower question than the one it is named for.
|
|
153
|
+
*/
|
|
154
|
+
const allInputTokens = inputTokens + cacheReadTokens + cacheWriteTokens;
|
|
155
|
+
const readShare = allInputTokens > 0 ? cacheReadTokens / allInputTokens : 0;
|
|
132
156
|
|
|
133
157
|
return {
|
|
134
158
|
profile: {
|
package/src/schemas.ts
CHANGED
|
@@ -151,6 +151,42 @@ export const CONTRACT_SCHEMAS: Record<ContractName, Record<string, unknown>> = {
|
|
|
151
151
|
cannotSay: array,
|
|
152
152
|
unpricedRecords: number,
|
|
153
153
|
}),
|
|
154
|
+
'routing-measurement': doc('routing-measurement', {
|
|
155
|
+
slice: object,
|
|
156
|
+
evaluation: object,
|
|
157
|
+
}),
|
|
158
|
+
'example-pruning': doc('example-pruning', {
|
|
159
|
+
provider: string,
|
|
160
|
+
model: string,
|
|
161
|
+
selfAgreement: number,
|
|
162
|
+
recoverableTokens: number,
|
|
163
|
+
callsMade: number,
|
|
164
|
+
contributions: array,
|
|
165
|
+
}),
|
|
166
|
+
'store-inventory': doc('store-inventory', {
|
|
167
|
+
providers: array,
|
|
168
|
+
totalRecords: number,
|
|
169
|
+
span: objectOrNull,
|
|
170
|
+
possiblyDouble: number,
|
|
171
|
+
unknownVersion: number,
|
|
172
|
+
totalUsd: number,
|
|
173
|
+
unreadable: array,
|
|
174
|
+
}),
|
|
175
|
+
conformance: doc('conformance', {
|
|
176
|
+
contract: stringOrNull,
|
|
177
|
+
because: stringOrNull,
|
|
178
|
+
problems: array,
|
|
179
|
+
unavailable: array,
|
|
180
|
+
records: { type: ['number', 'null'] },
|
|
181
|
+
conforms: boolean,
|
|
182
|
+
}),
|
|
183
|
+
'watch-cycle': doc('watch-cycle', {
|
|
184
|
+
firedAtMs: number,
|
|
185
|
+
crossings: array,
|
|
186
|
+
suppressed: array,
|
|
187
|
+
abstentions: array,
|
|
188
|
+
gap: objectOrNull,
|
|
189
|
+
}),
|
|
154
190
|
};
|
|
155
191
|
|
|
156
192
|
/** The schema for one named contract — the object `trazum schema` prints. */
|
package/src/tokenizer.ts
CHANGED
|
@@ -2,20 +2,22 @@
|
|
|
2
2
|
* Dependency-free token estimator.
|
|
3
3
|
*
|
|
4
4
|
* This is NOT a real tokenizer: it is a heuristic calibrated per character
|
|
5
|
-
* class
|
|
6
|
-
*
|
|
7
|
-
* two versions of the same prompt — but do NOT bill anyone from it.
|
|
5
|
+
* class, built for comparing two versions of the same prompt — but do NOT bill
|
|
6
|
+
* anyone from it.
|
|
8
7
|
*
|
|
9
|
-
* **
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
8
|
+
* **Its accuracy is not one number, and the years it was written as one were
|
|
9
|
+
* the mistake.** The branches below treat CJK, digits and punctuation quite
|
|
10
|
+
* differently from words, and there was never a reason those should land on the
|
|
11
|
+
* same accuracy. Measured over 47 samples against the official counting
|
|
12
|
+
* endpoint they do not: the worst error is 3.2% on CJK, 5.6% on Latin prose,
|
|
13
|
+
* 25.1% on code and quoting, and 32.5% on a digit-dominant table. `band.ts`
|
|
14
|
+
* holds the bands those measurements earn and `bandFor(text)` is what a caller
|
|
15
|
+
* should print; the constant below is only the widest of them, for callers that
|
|
16
|
+
* have no text in hand.
|
|
16
17
|
*
|
|
17
|
-
* `test/token-band.test.js`
|
|
18
|
-
*
|
|
18
|
+
* `test/token-band.test.js` holds every sample against the band `bandFor` gives
|
|
19
|
+
* it, and a sample edited since it was measured fails rather than passing
|
|
20
|
+
* quietly.
|
|
19
21
|
*
|
|
20
22
|
* For exact numbers use `countTokensAnthropic` (the official token-counting
|
|
21
23
|
* endpoint, which is free) or pass your own `TokenCounter`.
|
|
@@ -25,53 +27,47 @@ import { detectTextLanguage } from './language.js';
|
|
|
25
27
|
import { SAFE_FETCH_INIT, checkedEndpoint } from './net.js';
|
|
26
28
|
|
|
27
29
|
/**
|
|
28
|
-
* The
|
|
30
|
+
* The widest band this estimator is published under, as a percentage.
|
|
29
31
|
*
|
|
30
|
-
* **
|
|
31
|
-
* releases as a design target nobody had checked; the first
|
|
32
|
-
* of eight samples outside it and it went to `25`; fixing
|
|
33
|
-
* calibrating per language brought it back to `15
|
|
34
|
-
*
|
|
32
|
+
* **A history worth keeping, because each value was believed at the time.** It
|
|
33
|
+
* was `15` for eight releases as a design target nobody had checked; the first
|
|
34
|
+
* measurement found two of eight samples outside it and it went to `25`; fixing
|
|
35
|
+
* the digit divisor and calibrating per language brought it back to `15`, and
|
|
36
|
+
* splitting kana from han — Japanese had been +11.2% and Chinese −3.2% under one
|
|
37
|
+
* rule — brought it to `10`. Four values, each an improvement, and all four
|
|
38
|
+
* wrong in the same way.
|
|
35
39
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
+
* **The corpus they were measured on was the corpus they were fitted to.**
|
|
41
|
+
* Twenty-one samples held thirteen files of Latin prose and exactly one each of
|
|
42
|
+
* code, numeric and punctuation, and those single files were where the constants
|
|
43
|
+
* came from. The worst error it could show was 6.4%, and 10 looked like a
|
|
44
|
+
* comfortable margin over it. Neither figure was about the estimator.
|
|
40
45
|
*
|
|
41
|
-
* **
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* safe direction for a tool that reports money.
|
|
46
|
+
* **It is no longer one number, and this one is the widest.** A corpus of
|
|
47
|
+
* thirteen prose files and one each of code, numeric and punctuation put the
|
|
48
|
+
* band at 10, and those single files were the set the constants had been fitted
|
|
49
|
+
* to. Measured on forty-seven, the same estimator is 6% out on prose and 33%
|
|
50
|
+
* out on a CSV ledger. `bandFor` in `band.ts` is what a caller that has the
|
|
51
|
+
* text should use.
|
|
48
52
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* **Which samples the band rests on matters more than the number.** Eight of the
|
|
56
|
-
* twenty-one had a constant fitted to them — seven Latin divisors and the digit
|
|
57
|
-
* divisor — so their residuals are optimistic by construction. The two worst
|
|
58
|
-
* errors in the corpus, `code-heavy` at 6.4% and `punctuation-heavy` at 5.7%, are
|
|
59
|
-
* fitted to nothing at all, and they are what sets this figure.
|
|
60
|
-
*
|
|
61
|
-
* Exported so every report, README and tool description reads the same number. It
|
|
62
|
-
* was a literal in twenty-four files before this, with the only machine-readable
|
|
63
|
-
* copy in a test, and `token-band.test.js` now fails any file that states a
|
|
64
|
-
* different one.
|
|
53
|
+
* This stays for the callers that do not have it — a figure covering the whole
|
|
54
|
+
* catalogue of text, which can only be the worst of them. Widening it from 10
|
|
55
|
+
* to 33 makes every claim that reads it true where it had been false, at the
|
|
56
|
+
* cost of understating the estimator on prose. That trade is deliberate: a
|
|
57
|
+
* caller with no text cannot know which it is holding, and there is only one
|
|
58
|
+
* safe direction to guess in.
|
|
65
59
|
*/
|
|
66
|
-
export const ESTIMATE_ERROR_BAND_PCT =
|
|
60
|
+
export const ESTIMATE_ERROR_BAND_PCT = 33;
|
|
67
61
|
|
|
68
62
|
/**
|
|
69
63
|
* Whose tokenizer that band was measured against.
|
|
70
64
|
*
|
|
71
65
|
* The number above is not a property of the estimator; it is a property of the
|
|
72
|
-
* estimator **against Claude**.
|
|
73
|
-
* family. Trazum prices seven, and
|
|
74
|
-
*
|
|
66
|
+
* estimator **against Claude**. Forty-seven samples, one counting endpoint, one
|
|
67
|
+
* family. Trazum prices seven, and the two that have since been run are far
|
|
68
|
+
* outside anything published here: DeepSeek 94.5% at worst, Mistral 103.1%. On
|
|
69
|
+
* a GPT or a Gemini prompt nobody has measured it at all, and
|
|
70
|
+
* `measuredForeignError` answers null rather than the nearest number.
|
|
75
71
|
*
|
|
76
72
|
* Exported as a name rather than left implicit in a comment because the band
|
|
77
73
|
* had already leaked out of its domain in three places: an advisory that told a
|
|
@@ -173,6 +169,34 @@ const DIVISOR_BY_LANGUAGE: Readonly<Record<string, number>> = {
|
|
|
173
169
|
const KANA_TOKENS_PER_CHAR = 0.75;
|
|
174
170
|
const HAN_TOKENS_PER_CHAR = 1.05;
|
|
175
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Hangul, measured at last.
|
|
174
|
+
*
|
|
175
|
+
* The comment above used to end *"Hangul keeps the old cost of 1, because
|
|
176
|
+
* nothing here measures Korean. Guessing it from Japanese would be inventing a
|
|
177
|
+
* figure."* That was right, and it stayed right for exactly as long as nobody
|
|
178
|
+
* measured Korean. `cjk-korean.txt` now does: at 1 token per character the
|
|
179
|
+
* estimate came out **20.0% under** — 232 against 290 — and 1.20 takes it to
|
|
180
|
+
* -10.0%.
|
|
181
|
+
*
|
|
182
|
+
* **1.20 was the one-sample answer and it was wrong by ten points.** A second
|
|
183
|
+
* Korean sample in a different register — a deployment review against support
|
|
184
|
+
* copy — put both at -10.6% and -10.0% under it, and solving the constant on
|
|
185
|
+
* the two together lands on 1.35, where both come out at 0.0%.
|
|
186
|
+
*
|
|
187
|
+
* Two independent texts agreeing to within four tenths of a point across the
|
|
188
|
+
* whole search is what makes this a property of Hangul rather than a fit to a
|
|
189
|
+
* file. Their residuals move in lockstep at every candidate value: -10.6/-10.0
|
|
190
|
+
* at 1.20, -3.5/-3.1 at 1.30, 3.2/3.4 at 1.40. One sample cannot show that and
|
|
191
|
+
* two can, which is the entire argument for measuring more of the thin classes
|
|
192
|
+
* rather than tuning harder on the thin ones.
|
|
193
|
+
*
|
|
194
|
+
* Still in-sample: the honest test is a third Korean prompt, and the corpus
|
|
195
|
+
* grows one at a time.
|
|
196
|
+
*/
|
|
197
|
+
const HANGUL_TOKENS_PER_CHAR = 1.35;
|
|
198
|
+
const HANGUL = /[가-]/;
|
|
199
|
+
|
|
176
200
|
/**
|
|
177
201
|
* What a prompt gets when the language could not be told.
|
|
178
202
|
*
|
|
@@ -248,7 +272,11 @@ export function estimateTokens(text: string): number {
|
|
|
248
272
|
* where the loop happens to break, not of what the text costs.
|
|
249
273
|
*/
|
|
250
274
|
while (i < chars.length && CJK.test(chars[i]!)) {
|
|
251
|
-
cjkTokens += KANA.test(chars[i]!)
|
|
275
|
+
cjkTokens += KANA.test(chars[i]!)
|
|
276
|
+
? KANA_TOKENS_PER_CHAR
|
|
277
|
+
: HANGUL.test(chars[i]!)
|
|
278
|
+
? HANGUL_TOKENS_PER_CHAR
|
|
279
|
+
: HAN_TOKENS_PER_CHAR;
|
|
252
280
|
i++;
|
|
253
281
|
}
|
|
254
282
|
continue;
|
|
@@ -315,7 +343,29 @@ export function estimateTokens(text: string): number {
|
|
|
315
343
|
total += 1;
|
|
316
344
|
i++;
|
|
317
345
|
} else {
|
|
318
|
-
|
|
346
|
+
/**
|
|
347
|
+
* One token per symbol, not one per two.
|
|
348
|
+
*
|
|
349
|
+
* Two was the shipped value and every symbol-dense sample in the corpus
|
|
350
|
+
* undercounted under it, in the same direction, by amounts that grow with
|
|
351
|
+
* density: punctuation-heavy -5.7%, code-heavy -6.4%,
|
|
352
|
+
* punctuation-markup -11.4%, code-sql -25.6%, code-shell -29.8%. Five
|
|
353
|
+
* samples agreeing on a direction is a shape, not a fit — a merge table
|
|
354
|
+
* covers `, ` and `. ` and covers `${`, `"$`, `--` and `‑` far less
|
|
355
|
+
* well, and shell and SQL are made of the second kind.
|
|
356
|
+
*
|
|
357
|
+
* Searched over the whole corpus rather than the class it helps, so a
|
|
358
|
+
* value that fixed code by wrecking prose could not look like a win: at
|
|
359
|
+
* 1.0 the samples outside the published band drop from five to three and
|
|
360
|
+
* the mean error from 6.6% to 5.1%, with no sample moved more than a
|
|
361
|
+
* point in the wrong direction.
|
|
362
|
+
*
|
|
363
|
+
* The search wanted to go below 1.0 and was not allowed to. Below one is
|
|
364
|
+
* more than a token per symbol, which may well be true of SQL — code-sql
|
|
365
|
+
* is still 24.9% under — but it is a claim about a class this corpus has
|
|
366
|
+
* two samples of, and two samples do not earn a constant.
|
|
367
|
+
*/
|
|
368
|
+
total += Math.ceil(n / 1);
|
|
319
369
|
}
|
|
320
370
|
}
|
|
321
371
|
|