@trazum/core 1.9.0 → 1.10.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 +12 -5
- package/dist/advisories.d.ts.map +1 -1
- package/dist/advisories.js +94 -4
- package/dist/advisories.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +18 -7
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +18 -7
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +28 -1
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tokenizer.d.ts +33 -26
- package/dist/tokenizer.d.ts.map +1 -1
- package/dist/tokenizer.js +95 -34
- package/dist/tokenizer.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/usage.d.ts +219 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +274 -0
- package/dist/usage.js.map +1 -0
- package/package.json +1 -1
- package/src/advisories.ts +96 -4
- package/src/i18n/en.ts +19 -6
- package/src/i18n/es.ts +19 -6
- package/src/i18n/types.ts +29 -1
- package/src/index.ts +14 -0
- package/src/tokenizer.ts +93 -30
- package/src/types.ts +1 -0
- package/src/usage.ts +479 -0
package/src/tokenizer.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* This is NOT a real tokenizer: it is a heuristic calibrated per character
|
|
5
5
|
* class. It is built to keep the typical error on ordinary text
|
|
6
|
-
* (English/Spanish, markdown, code) inside ±
|
|
6
|
+
* (English/Spanish, markdown, code) inside ±10%, which is plenty for comparing
|
|
7
7
|
* two versions of the same prompt — but do NOT bill anyone from it.
|
|
8
8
|
*
|
|
9
9
|
* **That band is a design target that has not been measured.** It is printed on
|
|
@@ -27,38 +27,67 @@ import { SAFE_FETCH_INIT, checkedEndpoint } from './net.js';
|
|
|
27
27
|
/**
|
|
28
28
|
* The error band this estimator is published under, as a percentage.
|
|
29
29
|
*
|
|
30
|
-
* **Measured, not chosen.** It was `15` for eight
|
|
31
|
-
* nobody had checked
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
30
|
+
* **Measured, not chosen, and it has moved three times.** It was `15` for eight
|
|
31
|
+
* releases as a design target nobody had checked; the first measurement found two
|
|
32
|
+
* of eight samples outside it and it went to `25`; fixing the digit divisor and
|
|
33
|
+
* calibrating per language brought it back to `15`. This is the fourth value and
|
|
34
|
+
* the first one that is comfortably above what the corpus actually shows.
|
|
35
35
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* samples across four languages and six text types.
|
|
36
|
+
* ```
|
|
37
|
+
* worst measured error 6.4% (code-heavy, which nothing is fitted to)
|
|
38
|
+
* published band 10%
|
|
39
|
+
* ```
|
|
41
40
|
*
|
|
42
|
-
* **
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
41
|
+
* **The margin is deliberate and it is not slack.** 6.4 rounded up is 7, and
|
|
42
|
+
* publishing 7 would be a tighter claim than twenty-one samples across six text
|
|
43
|
+
* types can support: the corpus has no Korean, no Arabic, no Cyrillic prose, no
|
|
44
|
+
* mixed-script document, and a seventh text type could easily land at eight. A
|
|
45
|
+
* band that becomes false the first time somebody measures something new is the
|
|
46
|
+
* exact fault this whole exercise was fixing. Overstating the uncertainty is the
|
|
47
|
+
* safe direction for a tool that reports money.
|
|
48
48
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
49
|
+
* What earned the drop from 15 was **splitting kana from han**. Every CJK
|
|
50
|
+
* character was charged one token, which put Japanese at +11.2% — the worst error
|
|
51
|
+
* anywhere in the corpus — while Chinese sat at −3.2% under the same rule. Kana
|
|
52
|
+
* measure 0.75 tokens per character and han 1.05, and that pair takes the two
|
|
53
|
+
* samples to −1.5% and +1.3%. See `KANA_TOKENS_PER_CHAR`.
|
|
54
54
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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.
|
|
58
65
|
*/
|
|
59
|
-
export const ESTIMATE_ERROR_BAND_PCT =
|
|
66
|
+
export const ESTIMATE_ERROR_BAND_PCT = 10;
|
|
60
67
|
|
|
61
68
|
const CJK = /[-ヿ㐀-䶿一-鿿가-]/;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Kana, separated from the rest of CJK because they do not cost the same.
|
|
72
|
+
*
|
|
73
|
+
* **This was the largest error left in the corpus.** Every CJK character was
|
|
74
|
+
* charged one token, and measured against the counting endpoint that put Japanese
|
|
75
|
+
* at **+11.2%** — the worst figure anywhere in twenty-one samples — while Chinese
|
|
76
|
+
* came out at −3.2% under the identical rule. One constant cannot be right for
|
|
77
|
+
* both, and the reason is visible in the samples: the Japanese one is 58% kana and
|
|
78
|
+
* the Chinese one is 0%.
|
|
79
|
+
*
|
|
80
|
+
* Kana are a small syllabary that appears in every sentence, so the merge table
|
|
81
|
+
* covers runs of them and several characters share a token. Han are tens of
|
|
82
|
+
* thousands of rare characters; a merge table cannot cover them and they cost
|
|
83
|
+
* about one each, sometimes more.
|
|
84
|
+
*
|
|
85
|
+
* The signal needs no detector. A character is kana or it is not, and the two
|
|
86
|
+
* samples separate perfectly — 58.3% against 0.00% — so this is a property of the
|
|
87
|
+
* character rather than a guess about the document. That is the difference between
|
|
88
|
+
* this and `language.ts`, which has to decide and is allowed to refuse.
|
|
89
|
+
*/
|
|
90
|
+
const KANA = /[-ヿ]/;
|
|
62
91
|
const LETTER = /[A-Za-zÀ-ɏͰ-ϿЀ-ӿ]/;
|
|
63
92
|
const DIGIT = /[0-9]/;
|
|
64
93
|
|
|
@@ -95,6 +124,27 @@ const DIVISOR_BY_LANGUAGE: Readonly<Record<string, number>> = {
|
|
|
95
124
|
nl: 2.65,
|
|
96
125
|
};
|
|
97
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Tokens per character for the two halves of CJK, measured.
|
|
129
|
+
*
|
|
130
|
+
* `0.75` and `1.05` come from a search over the two CJK samples in
|
|
131
|
+
* `test/fixtures/token-ground-truth.json`, and they take that pair from
|
|
132
|
+
* +11.2% / −3.2% to −1.5% / +1.3%.
|
|
133
|
+
*
|
|
134
|
+
* **Two samples fitted two constants, so those residuals are in-sample and
|
|
135
|
+
* optimistic by construction** — the same caveat the Latin divisors carry, stated
|
|
136
|
+
* for the same reason. What makes them worth having anyway is the size of the
|
|
137
|
+
* error they replace and the fact that they move in opposite directions: a single
|
|
138
|
+
* constant could not have been within four points of both, whatever it was set to.
|
|
139
|
+
* The honest test is a third CJK sample, and the corpus grows one at a time.
|
|
140
|
+
*
|
|
141
|
+
* Hangul keeps the old cost of 1, because nothing here measures Korean. Guessing
|
|
142
|
+
* it from Japanese would be inventing a figure — the two scripts have nothing in
|
|
143
|
+
* common that would make one predict the other.
|
|
144
|
+
*/
|
|
145
|
+
const KANA_TOKENS_PER_CHAR = 0.75;
|
|
146
|
+
const HAN_TOKENS_PER_CHAR = 1.05;
|
|
147
|
+
|
|
98
148
|
/**
|
|
99
149
|
* What a prompt gets when the language could not be told.
|
|
100
150
|
*
|
|
@@ -133,6 +183,11 @@ export function estimateTokens(text: string): number {
|
|
|
133
183
|
const divisor = (language === null ? undefined : DIVISOR_BY_LANGUAGE[language]) ?? DEFAULT_DIVISOR;
|
|
134
184
|
|
|
135
185
|
let total = 0;
|
|
186
|
+
/**
|
|
187
|
+
* CJK is summed separately because it is the one class counted in fractions of
|
|
188
|
+
* a token. Everything else is whole tokens by the character class it belongs to.
|
|
189
|
+
*/
|
|
190
|
+
let cjkTokens = 0;
|
|
136
191
|
let i = 0;
|
|
137
192
|
const chars = Array.from(text);
|
|
138
193
|
|
|
@@ -155,12 +210,19 @@ export function estimateTokens(text: string): number {
|
|
|
155
210
|
}
|
|
156
211
|
|
|
157
212
|
if (CJK.test(ch)) {
|
|
158
|
-
|
|
213
|
+
/**
|
|
214
|
+
* Accumulated as a fraction and rounded once, at the end.
|
|
215
|
+
*
|
|
216
|
+
* Rounding up per run was the first attempt and it was wrong by five
|
|
217
|
+
* points. Ordinary Japanese alternates kana and han inside every sentence,
|
|
218
|
+
* so the runs are short and there are many of them — and a `Math.ceil` per
|
|
219
|
+
* run charges most of a token for each boundary. That is an artefact of
|
|
220
|
+
* where the loop happens to break, not of what the text costs.
|
|
221
|
+
*/
|
|
159
222
|
while (i < chars.length && CJK.test(chars[i]!)) {
|
|
160
|
-
|
|
223
|
+
cjkTokens += KANA.test(chars[i]!) ? KANA_TOKENS_PER_CHAR : HAN_TOKENS_PER_CHAR;
|
|
161
224
|
i++;
|
|
162
225
|
}
|
|
163
|
-
total += n;
|
|
164
226
|
continue;
|
|
165
227
|
}
|
|
166
228
|
|
|
@@ -229,7 +291,8 @@ export function estimateTokens(text: string): number {
|
|
|
229
291
|
}
|
|
230
292
|
}
|
|
231
293
|
|
|
232
|
-
|
|
294
|
+
// Rounded once, over the whole document, rather than per run — see the CJK branch.
|
|
295
|
+
return total + Math.ceil(cjkTokens);
|
|
233
296
|
}
|
|
234
297
|
|
|
235
298
|
/** Asynchronous token counter, for remote sources. */
|
package/src/types.ts
CHANGED
|
@@ -44,6 +44,7 @@ export type AdvisorySeverity = 'info' | 'opportunity' | 'warning';
|
|
|
44
44
|
/** Every advisory the core can emit. */
|
|
45
45
|
export type AdvisoryId =
|
|
46
46
|
| 'context-overflow'
|
|
47
|
+
| 'context-near-limit'
|
|
47
48
|
| 'prompt-caching'
|
|
48
49
|
| 'prompt-caching-not-worth-it'
|
|
49
50
|
| 'below-cache-minimum'
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { effectivePricing, multipliersFor } from './pricing.js';
|
|
2
|
+
import type { PricingCatalogue } from './pricing.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where the money actually went, from calls that actually happened.
|
|
6
|
+
*
|
|
7
|
+
* ## Why this exists
|
|
8
|
+
*
|
|
9
|
+
* Everything else in this package reads a **prompt file** and reasons about what
|
|
10
|
+
* it would cost. That is the smallest line item on most bills, and the gap is not
|
|
11
|
+
* small enough to argue about: measured on an ordinary support prompt, the
|
|
12
|
+
* deterministic rules recover about **1%** of the monthly figure, while output
|
|
13
|
+
* tokens alone were **87%** of it. A tool that reads `prompts/*.txt` cannot see
|
|
14
|
+
* retrieved context, conversation history, tool results or answers, and on a RAG
|
|
15
|
+
* or agent workload those are nearly the whole invoice.
|
|
16
|
+
*
|
|
17
|
+
* So this reads the other direction: **what the provider actually charged**, per
|
|
18
|
+
* call, and says where it went. The sentence it is built to produce is "63% of
|
|
19
|
+
* your bill is retrieved context and nothing is watching it", which is a fact
|
|
20
|
+
* about a system rather than an estimate about a file.
|
|
21
|
+
*
|
|
22
|
+
* ## It reads a file, and that is the design
|
|
23
|
+
*
|
|
24
|
+
* Not a proxy, not an SDK wrapper, not a callback. Trazum's whole security
|
|
25
|
+
* position is that prompts do not leave the machine they are on — asserted by
|
|
26
|
+
* tests, not promised — and a tool that sits in the request path trades that away
|
|
27
|
+
* for convenience. A JSON Lines file is something you already have or can produce
|
|
28
|
+
* in three lines, and it keeps the guarantee intact.
|
|
29
|
+
*
|
|
30
|
+
* ## The format is the one the API already gives you
|
|
31
|
+
*
|
|
32
|
+
* Nothing is invented here. Every Anthropic response carries a `usage` object
|
|
33
|
+
* with exactly these fields, so recording a call is:
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* appendFileSync('usage.jsonl', JSON.stringify({
|
|
37
|
+
* model: response.model,
|
|
38
|
+
* ...response.usage,
|
|
39
|
+
* }) + '\n');
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* OpenAI's `usage` maps onto the same shape with different names, and
|
|
43
|
+
* `parseUsageLine` accepts both. Asking somebody to transform their logs into a
|
|
44
|
+
* bespoke schema before a tool will read them is how a tool goes unused.
|
|
45
|
+
*
|
|
46
|
+
* ## What it refuses to do
|
|
47
|
+
*
|
|
48
|
+
* **It does not read prompt text and there is nowhere to put it.** The record
|
|
49
|
+
* shape has no field for content, so a usage log handed to Trazum cannot contain
|
|
50
|
+
* a prompt even by accident. That is a stronger promise than "we do not look at
|
|
51
|
+
* it", and it is the reason this takes counts rather than calls.
|
|
52
|
+
*
|
|
53
|
+
* **It reports no saving.** Attributing "you could have saved X" to a call that
|
|
54
|
+
* already happened means guessing what the call should have been, and this module
|
|
55
|
+
* exists precisely because guessing is what the rest of the package has to do.
|
|
56
|
+
* It reports what was spent, split by where it went. What to do about it is a
|
|
57
|
+
* different question and belongs to the advisories.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/** One recorded call, after parsing. All counts, no content. */
|
|
61
|
+
export interface UsageRecord {
|
|
62
|
+
/** Model id as the provider reported it. */
|
|
63
|
+
model: string;
|
|
64
|
+
/** Uncached input tokens billed at the full rate. */
|
|
65
|
+
inputTokens: number;
|
|
66
|
+
/** Tokens billed at the cache-read rate. Zero when nothing was cached. */
|
|
67
|
+
cacheReadTokens: number;
|
|
68
|
+
/** Cache writes at the 5-minute rate — 1.25x input on Anthropic. */
|
|
69
|
+
cacheWrite5mTokens: number;
|
|
70
|
+
/** Cache writes at the 1-hour rate, which is **2x** input, not 1.25x. */
|
|
71
|
+
cacheWrite1hTokens: number;
|
|
72
|
+
/**
|
|
73
|
+
* Whether the log said which TTL those writes used.
|
|
74
|
+
*
|
|
75
|
+
* `false` when only the flat `cache_creation_input_tokens` was present and it
|
|
76
|
+
* was non-zero: the writes are then priced at the cheaper 5-minute rate because
|
|
77
|
+
* one of the two has to be assumed, and the report says so. Choosing the cheaper
|
|
78
|
+
* rate silently understates a 1-hour workload by 37.5% on its largest line.
|
|
79
|
+
*/
|
|
80
|
+
writeTtlKnown: boolean;
|
|
81
|
+
outputTokens: number;
|
|
82
|
+
/**
|
|
83
|
+
* Optional label for grouping — an endpoint, a feature, a prompt name.
|
|
84
|
+
*
|
|
85
|
+
* The whole value of a profile is answering "which part of the product costs
|
|
86
|
+
* this", and without a label every call looks alike. Unlabelled records are
|
|
87
|
+
* grouped under a single bucket rather than dropped, because a profile that
|
|
88
|
+
* refuses to read a log until it is annotated is a profile nobody runs.
|
|
89
|
+
*/
|
|
90
|
+
label: string | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What a set of calls cost, split by where the money went. */
|
|
94
|
+
export interface UsageBreakdown {
|
|
95
|
+
calls: number;
|
|
96
|
+
inputTokens: number;
|
|
97
|
+
cacheReadTokens: number;
|
|
98
|
+
cacheWriteTokens: number;
|
|
99
|
+
outputTokens: number;
|
|
100
|
+
/**
|
|
101
|
+
* Calls whose cache-write TTL the log did not state, so the cheaper rate was
|
|
102
|
+
* assumed. Non-zero means this total is a floor on those calls, not a figure.
|
|
103
|
+
*/
|
|
104
|
+
assumedWriteTtlCalls: number;
|
|
105
|
+
inputUsd: number;
|
|
106
|
+
cacheReadUsd: number;
|
|
107
|
+
cacheWriteUsd: number;
|
|
108
|
+
outputUsd: number;
|
|
109
|
+
totalUsd: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface UsageProfileReport {
|
|
113
|
+
/** Everything, combined. */
|
|
114
|
+
total: UsageBreakdown;
|
|
115
|
+
/** Per `label`, largest bill first — the order somebody would act in. */
|
|
116
|
+
byLabel: Array<{ label: string; breakdown: UsageBreakdown }>;
|
|
117
|
+
/** Per model, largest bill first. */
|
|
118
|
+
byModel: Array<{ model: string; breakdown: UsageBreakdown }>;
|
|
119
|
+
/**
|
|
120
|
+
* Models in the log that the pricing catalogue does not know.
|
|
121
|
+
*
|
|
122
|
+
* Named rather than silently costed at zero. A profile that quietly omits a
|
|
123
|
+
* model reports a total lower than the real bill, which is the flattering
|
|
124
|
+
* direction and the one this repository refuses.
|
|
125
|
+
*/
|
|
126
|
+
unpricedModels: string[];
|
|
127
|
+
/**
|
|
128
|
+
* What those models used, kept entirely out of `total`.
|
|
129
|
+
*
|
|
130
|
+
* The first version added their **tokens** to the totals and their **dollars**
|
|
131
|
+
* to nothing, because pricing failed after the counts had been accumulated. So
|
|
132
|
+
* `total.inputTokens` included them and `total.inputUsd` did not, and anybody
|
|
133
|
+
* dividing one by the other got a cost per token that was wrong by however much
|
|
134
|
+
* of the log was unpriced — silently, and low.
|
|
135
|
+
*
|
|
136
|
+
* They are separated now. `total` is what could be priced, tokens and dollars
|
|
137
|
+
* describing the same calls. This is what could not, so the size of the gap is
|
|
138
|
+
* visible instead of being folded into a number that looks complete.
|
|
139
|
+
*/
|
|
140
|
+
unpriced: UsageBreakdown;
|
|
141
|
+
/**
|
|
142
|
+
* Lines that could not be read, with their 1-based position.
|
|
143
|
+
*
|
|
144
|
+
* Reported rather than thrown on. A log with three malformed lines out of forty
|
|
145
|
+
* thousand should still produce a profile, and a parser that dies on the first
|
|
146
|
+
* one makes the tool unusable on real data — but a parser that skips quietly
|
|
147
|
+
* produces a total that is wrong by an unknown amount.
|
|
148
|
+
*/
|
|
149
|
+
skippedLines: number[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The share of the bill each part accounts for, as fractions of 1. */
|
|
153
|
+
export interface UsageShares {
|
|
154
|
+
input: number;
|
|
155
|
+
cacheRead: number;
|
|
156
|
+
cacheWrite: number;
|
|
157
|
+
output: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const EMPTY = (): UsageBreakdown => ({
|
|
161
|
+
calls: 0,
|
|
162
|
+
inputTokens: 0,
|
|
163
|
+
cacheReadTokens: 0,
|
|
164
|
+
cacheWriteTokens: 0,
|
|
165
|
+
outputTokens: 0,
|
|
166
|
+
assumedWriteTtlCalls: 0,
|
|
167
|
+
inputUsd: 0,
|
|
168
|
+
cacheReadUsd: 0,
|
|
169
|
+
cacheWriteUsd: 0,
|
|
170
|
+
outputUsd: 0,
|
|
171
|
+
totalUsd: 0,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A count, and whether the log actually said it.
|
|
176
|
+
*
|
|
177
|
+
* **Absent and corrupt are different, and conflating them cost the whole bill.**
|
|
178
|
+
* The first version used one helper that returned a fallback for both, so a field
|
|
179
|
+
* present as `"200000"` or `null` — a string count out of `jq`, a null out of a
|
|
180
|
+
* Postgres JSON round-trip — became a clean zero indistinguishable from a real
|
|
181
|
+
* one. The record survived, its token class vanished, and it was never added to
|
|
182
|
+
* `skippedLines`, so nothing on screen said a number had been thrown away.
|
|
183
|
+
*
|
|
184
|
+
* Measured on a two-line log with a stringified `input_tokens`: the report came to
|
|
185
|
+
* $0.0150 against a true $2.015, and the headline flipped to "output is 100% of
|
|
186
|
+
* this bill, so shortening prompts has a low ceiling" — the opposite of the truth
|
|
187
|
+
* on a workload that was almost entirely prompt.
|
|
188
|
+
*
|
|
189
|
+
* So: absent is a zero anybody may legitimately mean, and corrupt rejects the
|
|
190
|
+
* line.
|
|
191
|
+
*/
|
|
192
|
+
type Count = { kind: 'ok'; value: number } | { kind: 'absent' } | { kind: 'corrupt' };
|
|
193
|
+
|
|
194
|
+
const OK = (value: number): Count => ({ kind: 'ok', value });
|
|
195
|
+
|
|
196
|
+
function readCount(...candidates: unknown[]): Count {
|
|
197
|
+
let sawCorrupt = false;
|
|
198
|
+
for (const value of candidates) {
|
|
199
|
+
if (value === undefined) continue;
|
|
200
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return OK(value);
|
|
201
|
+
// Present and unusable: a string, a null, a negative, a NaN.
|
|
202
|
+
sawCorrupt = true;
|
|
203
|
+
}
|
|
204
|
+
return sawCorrupt ? { kind: 'corrupt' } : { kind: 'absent' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Zero for an absent count. Callers reject corrupt ones before reaching this. */
|
|
208
|
+
const valueOf = (count: Count): number => (count.kind === 'ok' ? count.value : 0);
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* One line of a usage log, or `null` when it is not one.
|
|
212
|
+
*
|
|
213
|
+
* Accepts the Anthropic shape and the OpenAI one, because those are the two
|
|
214
|
+
* things people actually have. The alternative — a Trazum-specific schema — asks
|
|
215
|
+
* for a transformation step before the tool will read anything, and a tool with a
|
|
216
|
+
* setup cost that exceeds its payoff does not get run twice.
|
|
217
|
+
*
|
|
218
|
+
* `null` in three cases, and the third is the one that was wrong:
|
|
219
|
+
*
|
|
220
|
+
* 1. Not JSON, or not an object, or no `model`.
|
|
221
|
+
* 2. **No** token counts at all — counting it would inflate the call count while
|
|
222
|
+
* contributing nothing, which lowers every per-call figure.
|
|
223
|
+
* 3. **Any** count present but unreadable. A field that is there and unusable is
|
|
224
|
+
* corruption, and a corrupt line belongs in `skippedLines` where the report
|
|
225
|
+
* names it, not in the totals as a silent zero.
|
|
226
|
+
*/
|
|
227
|
+
export function parseUsageLine(line: string): UsageRecord | null {
|
|
228
|
+
let raw: unknown;
|
|
229
|
+
try {
|
|
230
|
+
raw = JSON.parse(line);
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null;
|
|
235
|
+
|
|
236
|
+
const record = raw as Record<string, unknown>;
|
|
237
|
+
// Anthropic nests usage on a response; a hand-rolled log usually flattens it.
|
|
238
|
+
const usage =
|
|
239
|
+
typeof record.usage === 'object' && record.usage !== null
|
|
240
|
+
? (record.usage as Record<string, unknown>)
|
|
241
|
+
: record;
|
|
242
|
+
|
|
243
|
+
const model = typeof record.model === 'string' ? record.model : null;
|
|
244
|
+
if (!model) return null;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* OpenAI reports cached tokens inside `prompt_tokens_details` **and counts them
|
|
248
|
+
* in `prompt_tokens`**, while Anthropic reports them separately and does not.
|
|
249
|
+
* Subtracting in one case and not the other is the difference between a correct
|
|
250
|
+
* bill and one that charges the cached half twice.
|
|
251
|
+
*/
|
|
252
|
+
const details =
|
|
253
|
+
typeof usage.prompt_tokens_details === 'object' && usage.prompt_tokens_details !== null
|
|
254
|
+
? (usage.prompt_tokens_details as Record<string, unknown>)
|
|
255
|
+
: null;
|
|
256
|
+
const openAiCached = details ? readCount(details.cached_tokens) : ({ kind: 'absent' } as Count);
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Anthropic splits cache writes by time-to-live, and the two cost different
|
|
260
|
+
* amounts: 1.25x input for the 5-minute entry, **2x** for the 1-hour one.
|
|
261
|
+
*
|
|
262
|
+
* Reading only the flat `cache_creation_input_tokens` threw that distinction
|
|
263
|
+
* away and then priced everything at the cheaper rate — a 1-hour workload
|
|
264
|
+
* reported 37.5% under, silently, on its largest line. The split is in the log
|
|
265
|
+
* whenever the recording recipe in the README is followed, because it is part of
|
|
266
|
+
* the `usage` object the API returns.
|
|
267
|
+
*/
|
|
268
|
+
const creation =
|
|
269
|
+
typeof usage.cache_creation === 'object' && usage.cache_creation !== null
|
|
270
|
+
? (usage.cache_creation as Record<string, unknown>)
|
|
271
|
+
: null;
|
|
272
|
+
const write5m = creation ? readCount(creation.ephemeral_5m_input_tokens) : ({ kind: 'absent' } as Count);
|
|
273
|
+
const write1h = creation ? readCount(creation.ephemeral_1h_input_tokens) : ({ kind: 'absent' } as Count);
|
|
274
|
+
|
|
275
|
+
const counts: Record<string, Count> = {
|
|
276
|
+
input: readCount(usage.input_tokens, usage.inputTokens, usage.prompt_tokens),
|
|
277
|
+
output: readCount(usage.output_tokens, usage.outputTokens, usage.completion_tokens),
|
|
278
|
+
cacheRead: readCount(usage.cache_read_input_tokens, usage.cacheReadTokens),
|
|
279
|
+
cacheWrite: readCount(usage.cache_creation_input_tokens, usage.cacheWriteTokens),
|
|
280
|
+
openAiCached,
|
|
281
|
+
write5m,
|
|
282
|
+
write1h,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// Any field present and unreadable rejects the line. See `readCount`.
|
|
286
|
+
if (Object.values(counts).some((c) => c.kind === 'corrupt')) return null;
|
|
287
|
+
// Nothing to count at all.
|
|
288
|
+
if (Object.values(counts).every((c) => c.kind === 'absent')) return null;
|
|
289
|
+
|
|
290
|
+
const cached = valueOf(counts.openAiCached!);
|
|
291
|
+
const flatWrite = valueOf(counts.cacheWrite!);
|
|
292
|
+
const split5m = valueOf(counts.write5m!);
|
|
293
|
+
const split1h = valueOf(counts.write1h!);
|
|
294
|
+
const hasSplit = counts.write5m!.kind === 'ok' || counts.write1h!.kind === 'ok';
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
model,
|
|
298
|
+
inputTokens: Math.max(0, valueOf(counts.input!) - cached),
|
|
299
|
+
cacheReadTokens: counts.cacheRead!.kind === 'ok' ? counts.cacheRead!.value : cached,
|
|
300
|
+
/**
|
|
301
|
+
* The split when the log carries it, the flat number otherwise — and
|
|
302
|
+
* `writeTtlKnown` says which, so the report can admit that a rate was assumed
|
|
303
|
+
* rather than quietly choosing the cheaper one.
|
|
304
|
+
*/
|
|
305
|
+
cacheWrite5mTokens: hasSplit ? split5m : flatWrite,
|
|
306
|
+
cacheWrite1hTokens: hasSplit ? split1h : 0,
|
|
307
|
+
writeTtlKnown: hasSplit || flatWrite === 0,
|
|
308
|
+
outputTokens: valueOf(counts.output!),
|
|
309
|
+
label:
|
|
310
|
+
typeof record.label === 'string' && record.label.trim() !== ''
|
|
311
|
+
? record.label.trim()
|
|
312
|
+
: null,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The bucket unlabelled calls land in, named so a report can say so. */
|
|
317
|
+
export const UNLABELLED = 'unlabelled';
|
|
318
|
+
|
|
319
|
+
/** Token counts only. Used for both halves, because both need them. */
|
|
320
|
+
function countInto(into: UsageBreakdown, record: UsageRecord): void {
|
|
321
|
+
into.calls += 1;
|
|
322
|
+
into.inputTokens += record.inputTokens;
|
|
323
|
+
into.cacheReadTokens += record.cacheReadTokens;
|
|
324
|
+
into.cacheWriteTokens += record.cacheWrite5mTokens + record.cacheWrite1hTokens;
|
|
325
|
+
if (!record.writeTtlKnown) into.assumedWriteTtlCalls += 1;
|
|
326
|
+
into.outputTokens += record.outputTokens;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function add(into: UsageBreakdown, record: UsageRecord, catalogue: PricingCatalogue, on: Date): boolean {
|
|
330
|
+
/**
|
|
331
|
+
* Looked up directly rather than through `modelFrom`, which **throws** on an id
|
|
332
|
+
* it does not know. A usage log is somebody's production traffic and will
|
|
333
|
+
* contain models this catalogue has never heard of — a fine-tune, a preview, a
|
|
334
|
+
* competitor. Throwing means one unfamiliar id destroys the whole profile;
|
|
335
|
+
* naming it separately means the report is honest about what it could not price
|
|
336
|
+
* and useful about everything else.
|
|
337
|
+
*
|
|
338
|
+
* **Priced first, counted second.** The other order was the bug: counts landed
|
|
339
|
+
* before the lookup could fail, so an unpriced call contributed tokens to a
|
|
340
|
+
* total whose dollars excluded it.
|
|
341
|
+
*/
|
|
342
|
+
const model = catalogue.byId.get(record.model);
|
|
343
|
+
if (!model) return false;
|
|
344
|
+
|
|
345
|
+
countInto(into, record);
|
|
346
|
+
const { inputPerMTok, outputPerMTok } = effectivePricing(model, on);
|
|
347
|
+
const rates = multipliersFor(model);
|
|
348
|
+
const per = (tokens: number, rate: number): number => (tokens / 1_000_000) * rate;
|
|
349
|
+
|
|
350
|
+
into.inputUsd += per(record.inputTokens, inputPerMTok);
|
|
351
|
+
into.cacheReadUsd += per(record.cacheReadTokens, inputPerMTok * rates.cacheRead);
|
|
352
|
+
/**
|
|
353
|
+
* Each TTL at its own rate. Anthropic charges 1.25x input for a 5-minute entry
|
|
354
|
+
* and 2x for a 1-hour one, and the first version applied 1.25x to both — 37.5%
|
|
355
|
+
* under on a 1-hour workload, on the largest line, with nothing on screen
|
|
356
|
+
* saying a rate had been chosen.
|
|
357
|
+
*/
|
|
358
|
+
into.cacheWriteUsd += per(record.cacheWrite5mTokens, inputPerMTok * rates.cacheWrite5m);
|
|
359
|
+
into.cacheWriteUsd += per(record.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h);
|
|
360
|
+
into.outputUsd += per(record.outputTokens, outputPerMTok);
|
|
361
|
+
into.totalUsd =
|
|
362
|
+
into.inputUsd + into.cacheReadUsd + into.cacheWriteUsd + into.outputUsd;
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export interface UsageProfileOptions {
|
|
367
|
+
catalogue: PricingCatalogue;
|
|
368
|
+
/** Date the prices are read at, so a promotional rate resolves the same way. */
|
|
369
|
+
on?: Date;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Reads a usage log and says where the money went.
|
|
374
|
+
*
|
|
375
|
+
* Takes the whole text rather than a stream: a usage log is measured in megabytes
|
|
376
|
+
* and this package imports no Node builtins, so streaming would mean an interface
|
|
377
|
+
* the browser build cannot satisfy. `@trazum/core/node` is where file reading
|
|
378
|
+
* lives, and it can chunk if it ever needs to.
|
|
379
|
+
*/
|
|
380
|
+
export function profileUsage(text: string, options: UsageProfileOptions): UsageProfileReport {
|
|
381
|
+
const { catalogue, on = new Date() } = options;
|
|
382
|
+
|
|
383
|
+
const total = EMPTY();
|
|
384
|
+
const unpriced = EMPTY();
|
|
385
|
+
const byLabel = new Map<string, UsageBreakdown>();
|
|
386
|
+
const byModel = new Map<string, UsageBreakdown>();
|
|
387
|
+
const unpricedModels = new Set<string>();
|
|
388
|
+
const skippedLines: number[] = [];
|
|
389
|
+
|
|
390
|
+
const lines = text.split('\n');
|
|
391
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
392
|
+
const line = lines[i]!.trim();
|
|
393
|
+
if (line === '') continue;
|
|
394
|
+
|
|
395
|
+
const record = parseUsageLine(line);
|
|
396
|
+
if (!record) {
|
|
397
|
+
skippedLines.push(i + 1);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (!add(total, record, catalogue, on)) {
|
|
402
|
+
unpricedModels.add(record.model);
|
|
403
|
+
countInto(unpriced, record);
|
|
404
|
+
// Still grouped by model, so the reader can see which unknown id is costing
|
|
405
|
+
// them attention — but with zero dollars, which the grouping makes obvious.
|
|
406
|
+
if (!byModel.has(record.model)) byModel.set(record.model, EMPTY());
|
|
407
|
+
countInto(byModel.get(record.model)!, record);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const labelKey = record.label ?? UNLABELLED;
|
|
412
|
+
if (!byLabel.has(labelKey)) byLabel.set(labelKey, EMPTY());
|
|
413
|
+
add(byLabel.get(labelKey)!, record, catalogue, on);
|
|
414
|
+
|
|
415
|
+
if (!byModel.has(record.model)) byModel.set(record.model, EMPTY());
|
|
416
|
+
add(byModel.get(record.model)!, record, catalogue, on);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const sorted = <K extends string>(
|
|
420
|
+
map: Map<string, UsageBreakdown>,
|
|
421
|
+
key: K,
|
|
422
|
+
): Array<Record<K, string> & { breakdown: UsageBreakdown }> =>
|
|
423
|
+
[...map.entries()]
|
|
424
|
+
.sort((a, b) => b[1].totalUsd - a[1].totalUsd || a[0].localeCompare(b[0]))
|
|
425
|
+
.map(([name, breakdown]) => ({ [key]: name, breakdown }) as Record<K, string> & {
|
|
426
|
+
breakdown: UsageBreakdown;
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
total,
|
|
431
|
+
byLabel: sorted(byLabel, 'label'),
|
|
432
|
+
byModel: sorted(byModel, 'model'),
|
|
433
|
+
unpricedModels: [...unpricedModels].sort(),
|
|
434
|
+
unpriced,
|
|
435
|
+
skippedLines,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* What share of the bill each part is.
|
|
441
|
+
*
|
|
442
|
+
* The point of the whole module in one function: a caller can print "output is
|
|
443
|
+
* 87% of this" without doing arithmetic that would drift from the arithmetic
|
|
444
|
+
* here.
|
|
445
|
+
*
|
|
446
|
+
* All zeroes when nothing was spent, rather than `NaN`. A profile of an empty log
|
|
447
|
+
* is a legitimate result — no calls yet — and a report full of `NaN%` is a bug
|
|
448
|
+
* report from somebody who did nothing wrong.
|
|
449
|
+
*/
|
|
450
|
+
export function sharesOf(breakdown: UsageBreakdown): UsageShares {
|
|
451
|
+
const { totalUsd } = breakdown;
|
|
452
|
+
if (totalUsd <= 0) return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
453
|
+
return {
|
|
454
|
+
input: breakdown.inputUsd / totalUsd,
|
|
455
|
+
cacheRead: breakdown.cacheReadUsd / totalUsd,
|
|
456
|
+
cacheWrite: breakdown.cacheWriteUsd / totalUsd,
|
|
457
|
+
output: breakdown.outputUsd / totalUsd,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* How much of the input that could have been cached was.
|
|
463
|
+
*
|
|
464
|
+
* `null` when nothing was cacheable-looking at all — no reads and no writes —
|
|
465
|
+
* because a hit rate over zero attempts is not zero, it is undefined, and
|
|
466
|
+
* printing "0% cache hit rate" for somebody who never turned caching on is a
|
|
467
|
+
* finding about nothing.
|
|
468
|
+
*
|
|
469
|
+
* Reads against reads-plus-full-price-input, deliberately. Cache *writes* are
|
|
470
|
+
* excluded from the denominator: a write is the cost of establishing an entry,
|
|
471
|
+
* not a missed read, and counting it as a miss makes a healthy cache look broken
|
|
472
|
+
* on the day it warms.
|
|
473
|
+
*/
|
|
474
|
+
export function cacheHitRate(breakdown: UsageBreakdown): number | null {
|
|
475
|
+
const attempts = breakdown.cacheReadTokens + breakdown.inputTokens;
|
|
476
|
+
if (breakdown.cacheReadTokens === 0 && breakdown.cacheWriteTokens === 0) return null;
|
|
477
|
+
if (attempts === 0) return null;
|
|
478
|
+
return breakdown.cacheReadTokens / attempts;
|
|
479
|
+
}
|