tokstat 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/README.md +84 -0
- package/dist/index.js +921 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# tokstat
|
|
2
|
+
|
|
3
|
+
A beautiful, interactive explorer for the token economics of your LLM-generated JSON.
|
|
4
|
+
|
|
5
|
+
Point it at a corpus of structured outputs and see exactly where your tokens — and dollars — are going. Treemap, sunburst, circle pack, or icicle chart. Click into any field to drill down. Animated transitions between views. Every interaction designed to make schema auditing something you want to do, not something you have to.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx tokstat ./data/**/*.json
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Bakeoff
|
|
12
|
+
|
|
13
|
+
This project is currently a bakeoff between two AI coding agents building the same tool from the same spec:
|
|
14
|
+
|
|
15
|
+
| Branch | Agent | Model |
|
|
16
|
+
|--------|-------|-------|
|
|
17
|
+
| [`claude/tokstat`](https://github.com/TomNeyland/tokstat/tree/claude/tokstat) | Claude Code | Opus 4.6 |
|
|
18
|
+
| [`codex/tokstat`](https://github.com/TomNeyland/tokstat/tree/codex/tokstat) | Codex | GPT 5.3 (high) |
|
|
19
|
+
|
|
20
|
+
Both start from the same design system, specs, and fixtures. Same requirements, different implementations.
|
|
21
|
+
|
|
22
|
+
**Claude Code (Opus 4.6):**
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
|
|
26
|
+
**Codex (GPT 5.3 high):**
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
**Live demo:** [tomneyland.github.io/tokstat](https://tomneyland.github.io/tokstat/)
|
|
31
|
+
|
|
32
|
+
## What it does
|
|
33
|
+
|
|
34
|
+
You're running structured generation (OpenAI, Anthropic, Gemini) with JSON schemas. You're spending hundreds or thousands of dollars per run. You have no idea which fields cost what, which optional fields are rarely populated, or whether your 200-character field names are bleeding you dry.
|
|
35
|
+
|
|
36
|
+
tokstat reads your generated JSON files, walks the schema hierarchy, tokenizes every field name and value, and gives you an interactive visualization of where the weight is.
|
|
37
|
+
|
|
38
|
+
**Per-field analytics:**
|
|
39
|
+
- Token count: avg, min, max, p50, p95 across your corpus
|
|
40
|
+
- Fill rate for optional/nullable fields
|
|
41
|
+
- Schema overhead (field names, braces, brackets, colons, commas) vs. value payload
|
|
42
|
+
- Estimated cost per field per model/provider
|
|
43
|
+
|
|
44
|
+
**Multiple visualization modes:**
|
|
45
|
+
- **Treemap** — where is the money going (relative area = relative cost)
|
|
46
|
+
- **Sunburst** — drill into nested structure radially
|
|
47
|
+
- **Circle pack** — spot outliers and clustering
|
|
48
|
+
- **Icicle** — linear depth exploration
|
|
49
|
+
|
|
50
|
+
Animated transitions between views. Click any node to zoom into that subtree.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npm install -g tokstat
|
|
56
|
+
# or
|
|
57
|
+
npx tokstat ./path/to/files/**/*.json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Point at JSON files
|
|
64
|
+
tokstat ./outputs/**/*.json
|
|
65
|
+
|
|
66
|
+
# Specify model for cost estimation
|
|
67
|
+
tokstat ./outputs/**/*.json --model gpt-4o
|
|
68
|
+
|
|
69
|
+
# JSON output for LLM consumption / CI pipelines
|
|
70
|
+
tokstat ./outputs/**/*.json --format json
|
|
71
|
+
|
|
72
|
+
# LLM-optimized context output
|
|
73
|
+
tokstat ./outputs/**/*.json --format llm
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Stack
|
|
77
|
+
|
|
78
|
+
- **Analysis**: Node/Bun — file walking, schema inference, tokenization (js-tiktoken)
|
|
79
|
+
- **Visualization**: Svelte 5 + D3 layouts — reactive rendering with hand-crafted SVG
|
|
80
|
+
- **Distribution**: npm package, opens local browser. No Electron, no desktop wrapper.
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { writeFileSync } from "fs";
|
|
6
|
+
import open2 from "open";
|
|
7
|
+
|
|
8
|
+
// src/engine/pipeline.ts
|
|
9
|
+
import fg from "fast-glob";
|
|
10
|
+
import { readFileSync } from "fs";
|
|
11
|
+
import { resolve } from "path";
|
|
12
|
+
|
|
13
|
+
// src/engine/schemaInference.ts
|
|
14
|
+
function getJsonType(value) {
|
|
15
|
+
if (value === null) return "null";
|
|
16
|
+
if (Array.isArray(value)) return "array";
|
|
17
|
+
const t = typeof value;
|
|
18
|
+
if (t === "string") return "string";
|
|
19
|
+
if (t === "number") return "number";
|
|
20
|
+
if (t === "boolean") return "boolean";
|
|
21
|
+
if (t === "object") return "object";
|
|
22
|
+
throw new Error(`Unexpected JSON value type: ${t}`);
|
|
23
|
+
}
|
|
24
|
+
function createNode(name, path, depth) {
|
|
25
|
+
return {
|
|
26
|
+
name,
|
|
27
|
+
path,
|
|
28
|
+
depth,
|
|
29
|
+
type: "null",
|
|
30
|
+
observed_types: /* @__PURE__ */ new Set(),
|
|
31
|
+
instance_count: 0,
|
|
32
|
+
present_count: 0,
|
|
33
|
+
array_item_counts: [],
|
|
34
|
+
children: /* @__PURE__ */ new Map()
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function mergeValue(node, value) {
|
|
38
|
+
node.instance_count++;
|
|
39
|
+
if (value === null) {
|
|
40
|
+
node.observed_types.add("null");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
node.present_count++;
|
|
44
|
+
const type = getJsonType(value);
|
|
45
|
+
node.observed_types.add(type);
|
|
46
|
+
if (type === "object") {
|
|
47
|
+
const obj = value;
|
|
48
|
+
for (const key of Object.keys(obj)) {
|
|
49
|
+
const childPath = `${node.path}.${key}`;
|
|
50
|
+
if (!node.children.has(key)) {
|
|
51
|
+
node.children.set(key, createNode(key, childPath, node.depth + 1));
|
|
52
|
+
}
|
|
53
|
+
mergeValue(node.children.get(key), obj[key]);
|
|
54
|
+
}
|
|
55
|
+
} else if (type === "array") {
|
|
56
|
+
const arr = value;
|
|
57
|
+
node.array_item_counts.push(arr.length);
|
|
58
|
+
const itemPath = `${node.path}[]`;
|
|
59
|
+
if (!node.children.has("[]")) {
|
|
60
|
+
node.children.set("[]", createNode("[]", itemPath, node.depth + 1));
|
|
61
|
+
}
|
|
62
|
+
const itemNode = node.children.get("[]");
|
|
63
|
+
for (const item of arr) {
|
|
64
|
+
mergeValue(itemNode, item);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function resolveTypes(node) {
|
|
69
|
+
const types = [...node.observed_types].filter((t) => t !== "null");
|
|
70
|
+
if (types.length > 0) {
|
|
71
|
+
node.type = types[0];
|
|
72
|
+
} else if (node.observed_types.has("null")) {
|
|
73
|
+
node.type = "null";
|
|
74
|
+
}
|
|
75
|
+
for (const child of node.children.values()) {
|
|
76
|
+
resolveTypes(child);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function fixInstanceCounts(node) {
|
|
80
|
+
if (node.type === "object") {
|
|
81
|
+
for (const child of node.children.values()) {
|
|
82
|
+
if (child.instance_count < node.present_count) {
|
|
83
|
+
child.instance_count = node.present_count;
|
|
84
|
+
}
|
|
85
|
+
fixInstanceCounts(child);
|
|
86
|
+
}
|
|
87
|
+
} else if (node.type === "array") {
|
|
88
|
+
for (const child of node.children.values()) {
|
|
89
|
+
fixInstanceCounts(child);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function inferSchema(documents) {
|
|
94
|
+
const root = createNode("root", "root", 0);
|
|
95
|
+
for (const doc of documents) {
|
|
96
|
+
mergeValue(root, doc);
|
|
97
|
+
}
|
|
98
|
+
resolveTypes(root);
|
|
99
|
+
fixInstanceCounts(root);
|
|
100
|
+
return root;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/engine/tokenization.ts
|
|
104
|
+
import { getEncoding as getTiktokenEncoding } from "js-tiktoken";
|
|
105
|
+
function buildOffsetMap(value, node) {
|
|
106
|
+
const chars = [];
|
|
107
|
+
const map = [];
|
|
108
|
+
function emit(s, path, category) {
|
|
109
|
+
for (const ch of s) {
|
|
110
|
+
chars.push(ch);
|
|
111
|
+
map.push({ path, category });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function walk(val, schemaNode) {
|
|
115
|
+
if (val === null) {
|
|
116
|
+
emit("null", schemaNode.path, "null_value");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (Array.isArray(val)) {
|
|
120
|
+
emit("[", schemaNode.path, "structural");
|
|
121
|
+
const itemNode = schemaNode.children.get("[]");
|
|
122
|
+
for (let i = 0; i < val.length; i++) {
|
|
123
|
+
if (i > 0) emit(",", schemaNode.path, "structural");
|
|
124
|
+
walk(val[i], itemNode);
|
|
125
|
+
}
|
|
126
|
+
emit("]", schemaNode.path, "structural");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (typeof val === "object") {
|
|
130
|
+
emit("{", schemaNode.path, "structural");
|
|
131
|
+
const keys = Object.keys(val);
|
|
132
|
+
for (let i = 0; i < keys.length; i++) {
|
|
133
|
+
const key = keys[i];
|
|
134
|
+
if (i > 0) emit(",", schemaNode.path, "structural");
|
|
135
|
+
const childNode = schemaNode.children.get(key);
|
|
136
|
+
emit(JSON.stringify(key), childNode.path, "key");
|
|
137
|
+
emit(":", childNode.path, "key");
|
|
138
|
+
const childValue = val[key];
|
|
139
|
+
walk(childValue, childNode);
|
|
140
|
+
}
|
|
141
|
+
emit("}", schemaNode.path, "structural");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const serialized = JSON.stringify(val);
|
|
145
|
+
emit(serialized, schemaNode.path, "value");
|
|
146
|
+
}
|
|
147
|
+
walk(value, node);
|
|
148
|
+
return { json: chars.join(""), map };
|
|
149
|
+
}
|
|
150
|
+
function tokenizeFile(parsed, schema, encoding) {
|
|
151
|
+
const { json, map } = buildOffsetMap(parsed, schema);
|
|
152
|
+
const tokens = encoding.encode(json);
|
|
153
|
+
const accumulators = /* @__PURE__ */ new Map();
|
|
154
|
+
function ensureAccumulator(path) {
|
|
155
|
+
let acc = accumulators.get(path);
|
|
156
|
+
if (!acc) {
|
|
157
|
+
acc = { schema_overhead: 0, value_payload: 0, null_waste: 0 };
|
|
158
|
+
accumulators.set(path, acc);
|
|
159
|
+
}
|
|
160
|
+
return acc;
|
|
161
|
+
}
|
|
162
|
+
let charPos = 0;
|
|
163
|
+
for (const tokenId of tokens) {
|
|
164
|
+
const tokenText = encoding.decode([tokenId]);
|
|
165
|
+
const tokenLen = tokenText.length;
|
|
166
|
+
const votes = /* @__PURE__ */ new Map();
|
|
167
|
+
for (let i = 0; i < tokenLen && charPos + i < map.length; i++) {
|
|
168
|
+
const entry = map[charPos + i];
|
|
169
|
+
const key = `${entry.path}|${entry.category}`;
|
|
170
|
+
const existing = votes.get(key);
|
|
171
|
+
if (existing) {
|
|
172
|
+
existing.count++;
|
|
173
|
+
} else {
|
|
174
|
+
votes.set(key, { path: entry.path, category: entry.category, count: 1 });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
let winner = null;
|
|
178
|
+
let maxCount = 0;
|
|
179
|
+
for (const vote of votes.values()) {
|
|
180
|
+
if (vote.count > maxCount) {
|
|
181
|
+
maxCount = vote.count;
|
|
182
|
+
winner = { path: vote.path, category: vote.category };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (winner) {
|
|
186
|
+
const acc = ensureAccumulator(winner.path);
|
|
187
|
+
if (winner.category === "key" || winner.category === "structural") {
|
|
188
|
+
acc.schema_overhead++;
|
|
189
|
+
} else if (winner.category === "null_value") {
|
|
190
|
+
acc.null_waste++;
|
|
191
|
+
} else {
|
|
192
|
+
acc.value_payload++;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
charPos += tokenLen;
|
|
196
|
+
}
|
|
197
|
+
const result = /* @__PURE__ */ new Map();
|
|
198
|
+
for (const [path, acc] of accumulators) {
|
|
199
|
+
result.set(path, {
|
|
200
|
+
total: acc.schema_overhead + acc.value_payload + acc.null_waste,
|
|
201
|
+
schema_overhead: acc.schema_overhead,
|
|
202
|
+
value_payload: acc.value_payload,
|
|
203
|
+
null_waste: acc.null_waste
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
var cachedEncoding = null;
|
|
209
|
+
var cachedEncodingName = null;
|
|
210
|
+
function getEncoding(tokenizer) {
|
|
211
|
+
if (cachedEncoding && cachedEncodingName === tokenizer) {
|
|
212
|
+
return cachedEncoding;
|
|
213
|
+
}
|
|
214
|
+
cachedEncoding = getTiktokenEncoding(tokenizer);
|
|
215
|
+
cachedEncodingName = tokenizer;
|
|
216
|
+
return cachedEncoding;
|
|
217
|
+
}
|
|
218
|
+
function collectValues(parsed, schema) {
|
|
219
|
+
const result = /* @__PURE__ */ new Map();
|
|
220
|
+
function ensure(path) {
|
|
221
|
+
let arr = result.get(path);
|
|
222
|
+
if (!arr) {
|
|
223
|
+
arr = [];
|
|
224
|
+
result.set(path, arr);
|
|
225
|
+
}
|
|
226
|
+
return arr;
|
|
227
|
+
}
|
|
228
|
+
function walk(val, node) {
|
|
229
|
+
if (val === null) return;
|
|
230
|
+
if (Array.isArray(val)) {
|
|
231
|
+
const itemNode = node.children.get("[]");
|
|
232
|
+
for (const item of val) {
|
|
233
|
+
walk(item, itemNode);
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (typeof val === "object") {
|
|
238
|
+
const obj = val;
|
|
239
|
+
for (const [key, childVal] of Object.entries(obj)) {
|
|
240
|
+
const childNode = node.children.get(key);
|
|
241
|
+
if (childNode) {
|
|
242
|
+
walk(childVal, childNode);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
ensure(node.path).push(val);
|
|
248
|
+
}
|
|
249
|
+
walk(parsed, schema);
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/engine/aggregation.ts
|
|
254
|
+
function computeStats(values) {
|
|
255
|
+
if (values.length === 0) {
|
|
256
|
+
return { avg: 0, min: 0, max: 0, p50: 0, p95: 0 };
|
|
257
|
+
}
|
|
258
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
259
|
+
const sum = sorted.reduce((a, b) => a + b, 0);
|
|
260
|
+
return {
|
|
261
|
+
avg: sum / sorted.length,
|
|
262
|
+
min: sorted[0],
|
|
263
|
+
max: sorted[sorted.length - 1],
|
|
264
|
+
p50: percentile(sorted, 50),
|
|
265
|
+
p95: percentile(sorted, 95)
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
function percentile(sorted, p) {
|
|
269
|
+
if (sorted.length === 1) return sorted[0];
|
|
270
|
+
const idx = p / 100 * (sorted.length - 1);
|
|
271
|
+
const lower = Math.floor(idx);
|
|
272
|
+
const upper = Math.ceil(idx);
|
|
273
|
+
if (lower === upper) return sorted[lower];
|
|
274
|
+
return sorted[lower] + (sorted[upper] - sorted[lower]) * (idx - lower);
|
|
275
|
+
}
|
|
276
|
+
function aggregate(schema, perFileTokens, perFileValues, sampleCount) {
|
|
277
|
+
const allValues = /* @__PURE__ */ new Map();
|
|
278
|
+
for (const fileValues of perFileValues) {
|
|
279
|
+
for (const [path, values] of fileValues) {
|
|
280
|
+
let arr = allValues.get(path);
|
|
281
|
+
if (!arr) {
|
|
282
|
+
arr = [];
|
|
283
|
+
allValues.set(path, arr);
|
|
284
|
+
}
|
|
285
|
+
arr.push(...values);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return aggregateNode(schema, perFileTokens, allValues, sampleCount);
|
|
289
|
+
}
|
|
290
|
+
function aggregateNode(schema, perFileTokens, allValues, sampleCount) {
|
|
291
|
+
const path = schema.path;
|
|
292
|
+
const fileTotals = [];
|
|
293
|
+
const fileOverheads = [];
|
|
294
|
+
const filePayloads = [];
|
|
295
|
+
const fileNullWastes = [];
|
|
296
|
+
for (const fileTokenMap of perFileTokens) {
|
|
297
|
+
const subtreeTokens = sumSubtree(schema, fileTokenMap);
|
|
298
|
+
fileTotals.push(subtreeTokens.total);
|
|
299
|
+
fileOverheads.push(subtreeTokens.schema_overhead);
|
|
300
|
+
filePayloads.push(subtreeTokens.value_payload);
|
|
301
|
+
fileNullWastes.push(subtreeTokens.null_waste);
|
|
302
|
+
}
|
|
303
|
+
const totalStats = computeStats(fileTotals);
|
|
304
|
+
const avgOverhead = avg(fileOverheads);
|
|
305
|
+
const avgPayload = avg(filePayloads);
|
|
306
|
+
const avgNullWaste = avg(fileNullWastes);
|
|
307
|
+
const fillRate = schema.instance_count > 0 ? schema.present_count / schema.instance_count : 0;
|
|
308
|
+
let arrayStats = null;
|
|
309
|
+
if (schema.type === "array" && schema.array_item_counts.length > 0) {
|
|
310
|
+
const counts = schema.array_item_counts;
|
|
311
|
+
const sorted = [...counts].sort((a, b) => a - b);
|
|
312
|
+
arrayStats = {
|
|
313
|
+
avg_items: avg(counts),
|
|
314
|
+
min_items: sorted[0],
|
|
315
|
+
max_items: sorted[sorted.length - 1],
|
|
316
|
+
p95_items: percentile(sorted, 95)
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
let stringStats = null;
|
|
320
|
+
const values = allValues.get(path);
|
|
321
|
+
if (schema.type === "string" && values && values.length > 0) {
|
|
322
|
+
const stringValues = values.filter((v) => typeof v === "string");
|
|
323
|
+
if (stringValues.length > 0) {
|
|
324
|
+
const uniqueSet = new Set(stringValues);
|
|
325
|
+
const totalLength = stringValues.reduce((sum, s) => sum + s.length, 0);
|
|
326
|
+
stringStats = {
|
|
327
|
+
avg_length: totalLength / stringValues.length,
|
|
328
|
+
value_diversity: uniqueSet.size / stringValues.length,
|
|
329
|
+
unique_count: uniqueSet.size
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const examples = sampleValues(values, sampleCount);
|
|
334
|
+
const children = [];
|
|
335
|
+
for (const childSchema of schema.children.values()) {
|
|
336
|
+
children.push(aggregateNode(childSchema, perFileTokens, allValues, sampleCount));
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
name: schema.name,
|
|
340
|
+
path: schema.path,
|
|
341
|
+
depth: schema.depth,
|
|
342
|
+
type: schema.type,
|
|
343
|
+
tokens: {
|
|
344
|
+
total: totalStats,
|
|
345
|
+
schema_overhead: avgOverhead,
|
|
346
|
+
value_payload: avgPayload,
|
|
347
|
+
null_waste: avgNullWaste
|
|
348
|
+
},
|
|
349
|
+
fill_rate: fillRate,
|
|
350
|
+
instance_count: schema.instance_count,
|
|
351
|
+
array_stats: arrayStats,
|
|
352
|
+
string_stats: stringStats,
|
|
353
|
+
examples,
|
|
354
|
+
children,
|
|
355
|
+
cost: {
|
|
356
|
+
per_instance: 0,
|
|
357
|
+
// filled in by cost calculation stage
|
|
358
|
+
total_corpus: 0
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function sumSubtree(schema, fileTokenMap) {
|
|
363
|
+
const self = fileTokenMap.get(schema.path);
|
|
364
|
+
let total = self ? self.total : 0;
|
|
365
|
+
let schema_overhead = self ? self.schema_overhead : 0;
|
|
366
|
+
let value_payload = self ? self.value_payload : 0;
|
|
367
|
+
let null_waste = self ? self.null_waste : 0;
|
|
368
|
+
for (const child of schema.children.values()) {
|
|
369
|
+
const childTokens = sumSubtree(child, fileTokenMap);
|
|
370
|
+
total += childTokens.total;
|
|
371
|
+
schema_overhead += childTokens.schema_overhead;
|
|
372
|
+
value_payload += childTokens.value_payload;
|
|
373
|
+
null_waste += childTokens.null_waste;
|
|
374
|
+
}
|
|
375
|
+
return { total, schema_overhead, value_payload, null_waste };
|
|
376
|
+
}
|
|
377
|
+
function avg(values) {
|
|
378
|
+
if (values.length === 0) return 0;
|
|
379
|
+
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
380
|
+
}
|
|
381
|
+
function sampleValues(values, maxCount) {
|
|
382
|
+
if (!values || values.length === 0) return [];
|
|
383
|
+
if (values.length <= maxCount) return [...values];
|
|
384
|
+
const reservoir = values.slice(0, maxCount);
|
|
385
|
+
for (let i = maxCount; i < values.length; i++) {
|
|
386
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
387
|
+
if (j < maxCount) {
|
|
388
|
+
reservoir[j] = values[i];
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return reservoir;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/engine/insights.ts
|
|
395
|
+
function detectInsights(tree, pricePerToken) {
|
|
396
|
+
const insights = [];
|
|
397
|
+
walkTree(tree, (node) => {
|
|
398
|
+
detectNullTax(node, pricePerToken, insights);
|
|
399
|
+
detectHollowObject(node, pricePerToken, insights);
|
|
400
|
+
detectArrayRepetitionTax(node, pricePerToken, insights);
|
|
401
|
+
detectBoilerplate(node, pricePerToken, insights);
|
|
402
|
+
detectLengthVariance(node, pricePerToken, insights);
|
|
403
|
+
});
|
|
404
|
+
insights.sort((a, b) => b.savings_tokens - a.savings_tokens);
|
|
405
|
+
return insights;
|
|
406
|
+
}
|
|
407
|
+
function walkTree(node, fn) {
|
|
408
|
+
fn(node);
|
|
409
|
+
for (const child of node.children) {
|
|
410
|
+
walkTree(child, fn);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function detectNullTax(node, pricePerToken, insights) {
|
|
414
|
+
if (node.fill_rate >= 0.5) return;
|
|
415
|
+
if (node.instance_count === 0) return;
|
|
416
|
+
if (node.name === "root" || node.name === "[]") return;
|
|
417
|
+
const nullPct = Math.round((1 - node.fill_rate) * 100);
|
|
418
|
+
const savingsPerInstance = node.tokens.schema_overhead * (1 - node.fill_rate) + node.tokens.null_waste;
|
|
419
|
+
if (savingsPerInstance < 1) return;
|
|
420
|
+
const savingsUsdPer10k = savingsPerInstance * pricePerToken * 1e4;
|
|
421
|
+
const severity = savingsPerInstance > 20 ? "high" : savingsPerInstance > 5 ? "medium" : "low";
|
|
422
|
+
insights.push({
|
|
423
|
+
type: "null_tax",
|
|
424
|
+
path: node.path,
|
|
425
|
+
severity,
|
|
426
|
+
message: `${node.name} is null ${nullPct}% of the time. Making it optional saves ${Math.round(savingsPerInstance)} tok/instance.`,
|
|
427
|
+
detail: `This field exists in the schema but is null in ${nullPct}% of instances. Each null instance still costs ${node.tokens.schema_overhead.toFixed(1)} tokens in structural overhead plus ${node.tokens.null_waste.toFixed(1)} tokens for the null literal. Making it optional would eliminate these costs when the field has no value.`,
|
|
428
|
+
savings_tokens: savingsPerInstance,
|
|
429
|
+
savings_usd_per_10k: savingsUsdPer10k
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
function detectHollowObject(node, pricePerToken, insights) {
|
|
433
|
+
if (node.type !== "object") return;
|
|
434
|
+
if (node.tokens.total.avg < 5) return;
|
|
435
|
+
const overheadRatio = node.tokens.schema_overhead / node.tokens.total.avg;
|
|
436
|
+
if (overheadRatio <= 0.7) return;
|
|
437
|
+
const overheadPct = Math.round(overheadRatio * 100);
|
|
438
|
+
const overheadTokens = Math.round(node.tokens.schema_overhead);
|
|
439
|
+
const totalTokens = Math.round(node.tokens.total.avg);
|
|
440
|
+
const savingsPerInstance = node.tokens.schema_overhead * 0.3;
|
|
441
|
+
const severity = overheadRatio > 0.85 ? "high" : overheadRatio > 0.75 ? "medium" : "low";
|
|
442
|
+
insights.push({
|
|
443
|
+
type: "hollow_object",
|
|
444
|
+
path: node.path,
|
|
445
|
+
severity,
|
|
446
|
+
message: `${node.name} is ${overheadPct}% structural overhead. ${overheadTokens} of ${totalTokens} tokens are field names and braces.`,
|
|
447
|
+
detail: `This object's structural elements (field names, braces, colons, commas) consume ${overheadPct}% of its total token cost. The actual value payload is only ${totalTokens - overheadTokens} tokens. Consider flattening or restructuring to reduce overhead.`,
|
|
448
|
+
savings_tokens: savingsPerInstance,
|
|
449
|
+
savings_usd_per_10k: savingsPerInstance * pricePerToken * 1e4
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
function detectArrayRepetitionTax(node, pricePerToken, insights) {
|
|
453
|
+
if (node.type !== "array") return;
|
|
454
|
+
if (!node.array_stats) return;
|
|
455
|
+
if (node.array_stats.avg_items <= 1) return;
|
|
456
|
+
const itemChildren = node.children.filter((c) => c.name === "[]");
|
|
457
|
+
if (itemChildren.length === 0) return;
|
|
458
|
+
const itemNode = itemChildren[0];
|
|
459
|
+
const perItemKeyCost = itemNode.children.reduce(
|
|
460
|
+
(sum, child) => sum + child.tokens.schema_overhead,
|
|
461
|
+
0
|
|
462
|
+
);
|
|
463
|
+
if (perItemKeyCost < 1) return;
|
|
464
|
+
const repetitionTax = perItemKeyCost * (node.array_stats.avg_items - 1);
|
|
465
|
+
const avgItems = node.array_stats.avg_items;
|
|
466
|
+
const severity = repetitionTax > 50 ? "high" : repetitionTax > 15 ? "medium" : "low";
|
|
467
|
+
insights.push({
|
|
468
|
+
type: "array_repetition_tax",
|
|
469
|
+
path: node.path,
|
|
470
|
+
severity,
|
|
471
|
+
message: `Field names in ${node.name} repeat ${avgItems.toFixed(1)}x per instance, costing ${Math.round(repetitionTax)} tokens in repetition.`,
|
|
472
|
+
detail: `Each item in this array repeats ${itemNode.children.length} field names, costing ~${perItemKeyCost.toFixed(1)} tokens per item. With an average of ${avgItems.toFixed(1)} items, the first item's field names are repeated ${(avgItems - 1).toFixed(1)} additional times. A header+values format would eliminate this repetition.`,
|
|
473
|
+
savings_tokens: repetitionTax,
|
|
474
|
+
savings_usd_per_10k: repetitionTax * pricePerToken * 1e4
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
function detectBoilerplate(node, pricePerToken, insights) {
|
|
478
|
+
if (node.type !== "string") return;
|
|
479
|
+
if (!node.string_stats) return;
|
|
480
|
+
if (node.fill_rate <= 0.5) return;
|
|
481
|
+
if (node.string_stats.value_diversity >= 0.1) return;
|
|
482
|
+
const uniqueCount = node.string_stats.unique_count;
|
|
483
|
+
const totalInstances = node.instance_count;
|
|
484
|
+
const savingsPerInstance = node.tokens.value_payload * 0.7;
|
|
485
|
+
const severity = savingsPerInstance > 10 ? "high" : savingsPerInstance > 3 ? "medium" : "low";
|
|
486
|
+
insights.push({
|
|
487
|
+
type: "boilerplate",
|
|
488
|
+
path: node.path,
|
|
489
|
+
severity,
|
|
490
|
+
message: `${node.name} has ${uniqueCount} unique values across ${totalInstances} instances. Consider replacing with an enum.`,
|
|
491
|
+
detail: `This string field has very low value diversity (${(node.string_stats.value_diversity * 100).toFixed(1)}%). Only ${uniqueCount} distinct values appear across ${totalInstances} instances. The repetitive content costs ~${node.tokens.value_payload.toFixed(1)} tokens per instance. Replacing with an enum or shorter values would significantly reduce cost.`,
|
|
492
|
+
savings_tokens: savingsPerInstance,
|
|
493
|
+
savings_usd_per_10k: savingsPerInstance * pricePerToken * 1e4
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
function detectLengthVariance(node, pricePerToken, insights) {
|
|
497
|
+
if (node.type !== "string") return;
|
|
498
|
+
if (node.tokens.total.p50 === 0) return;
|
|
499
|
+
const ratio = node.tokens.total.p95 / node.tokens.total.p50;
|
|
500
|
+
if (ratio <= 5) return;
|
|
501
|
+
const p50 = Math.round(node.tokens.total.p50);
|
|
502
|
+
const p95 = Math.round(node.tokens.total.p95);
|
|
503
|
+
const savingsPerInstance = (node.tokens.total.p95 - node.tokens.total.p50) * 0.05;
|
|
504
|
+
const severity = ratio > 20 ? "high" : ratio > 10 ? "medium" : "low";
|
|
505
|
+
insights.push({
|
|
506
|
+
type: "length_variance",
|
|
507
|
+
path: node.path,
|
|
508
|
+
severity,
|
|
509
|
+
message: `${node.name} length varies ${ratio.toFixed(0)}x (p50: ${p50} tok, p95: ${p95} tok). Consider adding length guidance.`,
|
|
510
|
+
detail: `This string field has high length variance with a ${ratio.toFixed(1)}x spread between median and 95th percentile. The median instance costs ${p50} tokens but the 95th percentile costs ${p95} tokens. Adding max_length guidance in your schema description would reduce outlier costs.`,
|
|
511
|
+
savings_tokens: savingsPerInstance,
|
|
512
|
+
savings_usd_per_10k: savingsPerInstance * pricePerToken * 1e4
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// src/engine/costCalculation.ts
|
|
517
|
+
function calculateCosts(tree, pricing, fileCount) {
|
|
518
|
+
const pricePerToken = pricing.output_per_1m / 1e6;
|
|
519
|
+
walkAndComputeCost(tree, pricePerToken, fileCount);
|
|
520
|
+
}
|
|
521
|
+
function walkAndComputeCost(node, pricePerToken, fileCount) {
|
|
522
|
+
node.cost = {
|
|
523
|
+
per_instance: node.tokens.total.avg * pricePerToken,
|
|
524
|
+
total_corpus: node.tokens.total.avg * pricePerToken * fileCount
|
|
525
|
+
};
|
|
526
|
+
for (const child of node.children) {
|
|
527
|
+
walkAndComputeCost(child, pricePerToken, fileCount);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// src/engine/pricing.ts
|
|
532
|
+
var MODELS = {
|
|
533
|
+
"gpt-4o": {
|
|
534
|
+
model_id: "gpt-4o",
|
|
535
|
+
provider: "openai",
|
|
536
|
+
output_per_1m: 10,
|
|
537
|
+
tokenizer: "o200k_base"
|
|
538
|
+
},
|
|
539
|
+
"gpt-4o-mini": {
|
|
540
|
+
model_id: "gpt-4o-mini",
|
|
541
|
+
provider: "openai",
|
|
542
|
+
output_per_1m: 0.6,
|
|
543
|
+
tokenizer: "o200k_base"
|
|
544
|
+
},
|
|
545
|
+
"claude-sonnet-4-5": {
|
|
546
|
+
model_id: "claude-sonnet-4-5",
|
|
547
|
+
provider: "anthropic",
|
|
548
|
+
output_per_1m: 15,
|
|
549
|
+
tokenizer: "o200k_base"
|
|
550
|
+
// Anthropic uses its own tokenizer but o200k_base is a reasonable proxy
|
|
551
|
+
},
|
|
552
|
+
"claude-haiku-4-5": {
|
|
553
|
+
model_id: "claude-haiku-4-5",
|
|
554
|
+
provider: "anthropic",
|
|
555
|
+
output_per_1m: 5,
|
|
556
|
+
tokenizer: "o200k_base"
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
function getModelPricing(modelId) {
|
|
560
|
+
const pricing = MODELS[modelId];
|
|
561
|
+
if (!pricing) {
|
|
562
|
+
throw new Error(`Unknown model: "${modelId}". Available: ${Object.keys(MODELS).join(", ")}`);
|
|
563
|
+
}
|
|
564
|
+
return pricing;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// src/engine/cohorts.ts
|
|
568
|
+
function getJsonType2(value) {
|
|
569
|
+
if (value === null) return "null";
|
|
570
|
+
if (Array.isArray(value)) return "array";
|
|
571
|
+
const t = typeof value;
|
|
572
|
+
if (t === "string") return "string";
|
|
573
|
+
if (t === "number") return "number";
|
|
574
|
+
if (t === "boolean") return "boolean";
|
|
575
|
+
if (t === "object") return "object";
|
|
576
|
+
return "string";
|
|
577
|
+
}
|
|
578
|
+
function fingerprint(doc) {
|
|
579
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
|
|
580
|
+
return `_root:${getJsonType2(doc)}`;
|
|
581
|
+
}
|
|
582
|
+
return Object.keys(doc).sort().join("|");
|
|
583
|
+
}
|
|
584
|
+
function detectCohorts(documents) {
|
|
585
|
+
const groups = /* @__PURE__ */ new Map();
|
|
586
|
+
for (let i = 0; i < documents.length; i++) {
|
|
587
|
+
const fp = fingerprint(documents[i]);
|
|
588
|
+
let indices = groups.get(fp);
|
|
589
|
+
if (!indices) {
|
|
590
|
+
indices = [];
|
|
591
|
+
groups.set(fp, indices);
|
|
592
|
+
}
|
|
593
|
+
indices.push(i);
|
|
594
|
+
}
|
|
595
|
+
const cohorts = [];
|
|
596
|
+
for (const [id, file_indices] of groups) {
|
|
597
|
+
cohorts.push({
|
|
598
|
+
id,
|
|
599
|
+
label: generateCohortLabel(id, file_indices.length),
|
|
600
|
+
file_count: file_indices.length,
|
|
601
|
+
file_indices
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
cohorts.sort((a, b) => b.file_count - a.file_count);
|
|
605
|
+
return cohorts;
|
|
606
|
+
}
|
|
607
|
+
function generateCohortLabel(fp, _fileCount) {
|
|
608
|
+
const keys = fp.split("|").map((entry) => entry.split(":")[0]);
|
|
609
|
+
if (keys.length <= 3) return keys.join(", ");
|
|
610
|
+
return keys.slice(0, 3).join(", ") + ` +${keys.length - 3}`;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// src/engine/pipeline.ts
|
|
614
|
+
function analyzeParsedDocuments(documents, pricing, encoding, glob2, sampleValues2) {
|
|
615
|
+
const schema = inferSchema(documents);
|
|
616
|
+
const perFileTokens = [];
|
|
617
|
+
const perFileValues = [];
|
|
618
|
+
for (const doc of documents) {
|
|
619
|
+
perFileTokens.push(tokenizeFile(doc, schema, encoding));
|
|
620
|
+
perFileValues.push(collectValues(doc, schema));
|
|
621
|
+
}
|
|
622
|
+
const tree = aggregate(schema, perFileTokens, perFileValues, sampleValues2);
|
|
623
|
+
calculateCosts(tree, pricing, documents.length);
|
|
624
|
+
const pricePerToken = pricing.output_per_1m / 1e6;
|
|
625
|
+
const insights = detectInsights(tree, pricePerToken);
|
|
626
|
+
const avgTokens = tree.tokens.total.avg;
|
|
627
|
+
const costPerInstance = avgTokens * pricePerToken;
|
|
628
|
+
const summary = {
|
|
629
|
+
file_count: documents.length,
|
|
630
|
+
glob: glob2,
|
|
631
|
+
model: pricing.model_id,
|
|
632
|
+
tokenizer: pricing.tokenizer,
|
|
633
|
+
output_price_per_1m: pricing.output_per_1m,
|
|
634
|
+
corpus_total_tokens: avgTokens * documents.length,
|
|
635
|
+
corpus_total_cost: costPerInstance * documents.length,
|
|
636
|
+
avg_tokens_per_instance: avgTokens,
|
|
637
|
+
cost_per_instance: costPerInstance,
|
|
638
|
+
overhead_ratio: avgTokens > 0 ? tree.tokens.schema_overhead / avgTokens : 0,
|
|
639
|
+
null_waste_ratio: avgTokens > 0 ? tree.tokens.null_waste / avgTokens : 0,
|
|
640
|
+
cost_at_1k: costPerInstance * 1e3,
|
|
641
|
+
cost_at_10k: costPerInstance * 1e4,
|
|
642
|
+
cost_at_100k: costPerInstance * 1e5,
|
|
643
|
+
cost_at_1m: costPerInstance * 1e6,
|
|
644
|
+
top_insights: insights.slice(0, 5)
|
|
645
|
+
};
|
|
646
|
+
return { schema: "tokstat/v1", summary, tree, insights };
|
|
647
|
+
}
|
|
648
|
+
function runPipeline(options2) {
|
|
649
|
+
const { pricing, tokenizerName } = resolvePricing(options2);
|
|
650
|
+
const files = readFiles(options2.glob);
|
|
651
|
+
const documents = files.map((f) => f.parsed);
|
|
652
|
+
const encoding = getEncoding(tokenizerName);
|
|
653
|
+
return analyzeParsedDocuments(documents, pricing, encoding, options2.glob, options2.sampleValues);
|
|
654
|
+
}
|
|
655
|
+
function runCohortedPipeline(options2) {
|
|
656
|
+
const { pricing, tokenizerName } = resolvePricing(options2);
|
|
657
|
+
const files = readFiles(options2.glob);
|
|
658
|
+
const documents = files.map((f) => f.parsed);
|
|
659
|
+
const encoding = getEncoding(tokenizerName);
|
|
660
|
+
const cohorts = detectCohorts(documents);
|
|
661
|
+
const combined = analyzeParsedDocuments(documents, pricing, encoding, options2.glob, options2.sampleValues);
|
|
662
|
+
const per_cohort = {};
|
|
663
|
+
for (const cohort of cohorts) {
|
|
664
|
+
const cohortDocs = cohort.file_indices.map((i) => documents[i]);
|
|
665
|
+
per_cohort[cohort.id] = analyzeParsedDocuments(
|
|
666
|
+
cohortDocs,
|
|
667
|
+
pricing,
|
|
668
|
+
encoding,
|
|
669
|
+
`${options2.glob} [${cohort.label}]`,
|
|
670
|
+
options2.sampleValues
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
return { schema: "tokstat/v1", cohorts, combined, per_cohort };
|
|
674
|
+
}
|
|
675
|
+
function resolvePricing(options2) {
|
|
676
|
+
const resolvedTokenizer = options2.tokenizer !== "auto" ? options2.tokenizer : options2.costPer1k ? "o200k_base" : getModelPricing(options2.model).tokenizer;
|
|
677
|
+
const pricing = options2.costPer1k ? {
|
|
678
|
+
model_id: options2.model,
|
|
679
|
+
provider: "custom",
|
|
680
|
+
output_per_1m: options2.costPer1k * 1e3,
|
|
681
|
+
tokenizer: resolvedTokenizer
|
|
682
|
+
} : getModelPricing(options2.model);
|
|
683
|
+
const tokenizerName = options2.tokenizer === "auto" ? pricing.tokenizer : options2.tokenizer;
|
|
684
|
+
return { pricing, tokenizerName };
|
|
685
|
+
}
|
|
686
|
+
function readFiles(glob2) {
|
|
687
|
+
const paths = fg.sync(glob2).sort();
|
|
688
|
+
if (paths.length === 0) {
|
|
689
|
+
throw new Error(`No files matched glob: "${glob2}"`);
|
|
690
|
+
}
|
|
691
|
+
const files = [];
|
|
692
|
+
for (const filePath of paths) {
|
|
693
|
+
const absPath = resolve(filePath);
|
|
694
|
+
const content = readFileSync(absPath, "utf-8");
|
|
695
|
+
const parsed = JSON.parse(content);
|
|
696
|
+
files.push({ path: absPath, parsed });
|
|
697
|
+
}
|
|
698
|
+
return files;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// src/formatters/jsonFormatter.ts
|
|
702
|
+
function formatJson(output2) {
|
|
703
|
+
return JSON.stringify(output2, null, 2);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// src/formatters/llmFormatter.ts
|
|
707
|
+
function formatLlm(output2) {
|
|
708
|
+
const { summary, tree, insights } = output2;
|
|
709
|
+
const lines = [];
|
|
710
|
+
lines.push(
|
|
711
|
+
`tokstat analysis: ${summary.file_count} files, ${summary.model} (${summary.tokenizer})`
|
|
712
|
+
);
|
|
713
|
+
lines.push("");
|
|
714
|
+
lines.push(
|
|
715
|
+
`HEADLINE: $${summary.cost_per_instance.toFixed(4)}/instance, ${Math.round(summary.overhead_ratio * 100)}% schema overhead, ${Math.round(summary.null_waste_ratio * 100)}% null waste`
|
|
716
|
+
);
|
|
717
|
+
lines.push("");
|
|
718
|
+
lines.push("SCALE:");
|
|
719
|
+
lines.push(` 1K: $${summary.cost_at_1k.toFixed(2)}`);
|
|
720
|
+
lines.push(` 10K: $${summary.cost_at_10k.toFixed(2)}`);
|
|
721
|
+
lines.push(` 100K: $${summary.cost_at_100k.toFixed(2)}`);
|
|
722
|
+
lines.push(` 1M: $${summary.cost_at_1m.toFixed(2)}`);
|
|
723
|
+
lines.push("");
|
|
724
|
+
if (insights.length > 0) {
|
|
725
|
+
lines.push("TOP SAVINGS:");
|
|
726
|
+
const topInsights = insights.slice(0, 5);
|
|
727
|
+
for (let i = 0; i < topInsights.length; i++) {
|
|
728
|
+
const insight = topInsights[i];
|
|
729
|
+
lines.push(` ${i + 1}. ${formatInsightLine(insight)}`);
|
|
730
|
+
}
|
|
731
|
+
lines.push("");
|
|
732
|
+
}
|
|
733
|
+
const hotspots = findOverheadHotspots(tree);
|
|
734
|
+
if (hotspots.length > 0) {
|
|
735
|
+
lines.push("SCHEMA OVERHEAD HOTSPOTS:");
|
|
736
|
+
for (const hotspot of hotspots.slice(0, 5)) {
|
|
737
|
+
lines.push(` ${hotspot}`);
|
|
738
|
+
}
|
|
739
|
+
lines.push("");
|
|
740
|
+
}
|
|
741
|
+
const wasteFields = findHighWasteFields(tree);
|
|
742
|
+
if (wasteFields.length > 0) {
|
|
743
|
+
lines.push("HIGH WASTE (low fill, high cost):");
|
|
744
|
+
for (const field of wasteFields.slice(0, 5)) {
|
|
745
|
+
lines.push(` ${field}`);
|
|
746
|
+
}
|
|
747
|
+
lines.push("");
|
|
748
|
+
}
|
|
749
|
+
const boilerplateInsights = insights.filter((i) => i.type === "boilerplate");
|
|
750
|
+
if (boilerplateInsights.length > 0) {
|
|
751
|
+
lines.push("BOILERPLATE:");
|
|
752
|
+
for (const insight of boilerplateInsights.slice(0, 3)) {
|
|
753
|
+
lines.push(` ${insight.message}`);
|
|
754
|
+
}
|
|
755
|
+
lines.push("");
|
|
756
|
+
}
|
|
757
|
+
return lines.join("\n");
|
|
758
|
+
}
|
|
759
|
+
function formatInsightLine(insight) {
|
|
760
|
+
const savings = Math.round(insight.savings_tokens);
|
|
761
|
+
const usd = insight.savings_usd_per_10k.toFixed(2);
|
|
762
|
+
return `${insight.path} \u2014 ${insight.message.split(".")[0]}, saves ${savings} tok/inst ($${usd}/10K)`;
|
|
763
|
+
}
|
|
764
|
+
function findOverheadHotspots(tree) {
|
|
765
|
+
const hotspots = [];
|
|
766
|
+
function walk(node) {
|
|
767
|
+
if (node.type === "array" && node.array_stats && node.array_stats.avg_items > 1) {
|
|
768
|
+
const itemNode = node.children.find((c) => c.name === "[]");
|
|
769
|
+
if (itemNode) {
|
|
770
|
+
const fieldCount = itemNode.children.length;
|
|
771
|
+
const avgItems = node.array_stats.avg_items;
|
|
772
|
+
hotspots.push({
|
|
773
|
+
text: `${node.path} items: ${fieldCount} field names x ${avgItems.toFixed(1)} avg items = ${(fieldCount * avgItems).toFixed(1)} key emissions/inst`,
|
|
774
|
+
overhead: fieldCount * avgItems
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
if (node.type === "object" && node.tokens.total.avg > 0) {
|
|
779
|
+
const ratio = node.tokens.schema_overhead / node.tokens.total.avg;
|
|
780
|
+
if (ratio > 0.6 && node.children.length > 2) {
|
|
781
|
+
hotspots.push({
|
|
782
|
+
text: `${node.path} object: ${node.children.length} field names, ${Math.round(ratio * 100)}% overhead ratio`,
|
|
783
|
+
overhead: node.tokens.schema_overhead
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
for (const child of node.children) {
|
|
788
|
+
walk(child);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
walk(tree);
|
|
792
|
+
hotspots.sort((a, b) => b.overhead - a.overhead);
|
|
793
|
+
return hotspots.map((h) => h.text);
|
|
794
|
+
}
|
|
795
|
+
function findHighWasteFields(tree) {
|
|
796
|
+
const fields = [];
|
|
797
|
+
function walk(node) {
|
|
798
|
+
if (node.fill_rate < 0.5 && node.fill_rate > 0 && node.tokens.total.avg > 0 && node.name !== "root") {
|
|
799
|
+
const fillPct = Math.round(node.fill_rate * 100);
|
|
800
|
+
const avgTokens = Math.round(node.tokens.total.avg);
|
|
801
|
+
fields.push({
|
|
802
|
+
text: `${node.path} \u2014 ${avgTokens} tok avg, ${fillPct}% fill`,
|
|
803
|
+
waste: node.tokens.total.avg * (1 - node.fill_rate)
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
for (const child of node.children) {
|
|
807
|
+
walk(child);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
walk(tree);
|
|
811
|
+
fields.sort((a, b) => b.waste - a.waste);
|
|
812
|
+
return fields.map((f) => f.text);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// src/cli/server.ts
|
|
816
|
+
import { createServer } from "http";
|
|
817
|
+
import open from "open";
|
|
818
|
+
var APP_URL = "https://tomneyland.github.io/tokstat/other/index.html";
|
|
819
|
+
async function startServer(data, port, autoOpen) {
|
|
820
|
+
const resp = await fetch(APP_URL);
|
|
821
|
+
const html = await resp.text();
|
|
822
|
+
const dataScript = `<script id="tokstat-data" type="application/json">${JSON.stringify(data)}</script>`;
|
|
823
|
+
const injectedHtml = html.replace("</head>", `${dataScript}
|
|
824
|
+
</head>`);
|
|
825
|
+
const server = createServer((_req, res) => {
|
|
826
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
827
|
+
res.end(injectedHtml);
|
|
828
|
+
});
|
|
829
|
+
server.listen(port, () => {
|
|
830
|
+
const url = `http://localhost:${port}`;
|
|
831
|
+
console.log(`
|
|
832
|
+
Report available at ${url}`);
|
|
833
|
+
console.log(" Press Ctrl+C to exit\n");
|
|
834
|
+
if (autoOpen) open(url);
|
|
835
|
+
});
|
|
836
|
+
process.on("SIGINT", () => {
|
|
837
|
+
server.close();
|
|
838
|
+
process.exit(0);
|
|
839
|
+
});
|
|
840
|
+
process.on("SIGTERM", () => {
|
|
841
|
+
server.close();
|
|
842
|
+
process.exit(0);
|
|
843
|
+
});
|
|
844
|
+
await new Promise(() => {
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// src/cli/index.ts
|
|
849
|
+
var APP_URL2 = "https://tomneyland.github.io/tokstat/other/";
|
|
850
|
+
var program = new Command().name("tokstat").description("Visualize per-field token costs across a corpus of LLM-generated JSON").argument("[glob]", "Glob pattern for JSON files").option("--model <model>", "Model for cost estimation", "gpt-4o").option("--format <fmt>", "Output format: interactive, json, llm", "interactive").option("--tokenizer <enc>", "Tokenizer encoding", "auto").option("--out <path>", "Write to file instead of stdout/browser").option("--port <port>", "Dev server port", "3742").option("--no-open", "Don't auto-open browser").option("--cost-per-1k <n>", "Custom output token price per 1K tokens").option("--sample-values <n>", "Example values per field", "5").parse(process.argv);
|
|
851
|
+
var opts = program.opts();
|
|
852
|
+
var glob = program.args[0];
|
|
853
|
+
if (!glob) {
|
|
854
|
+
console.log(` Opening ${APP_URL2}`);
|
|
855
|
+
await open2(APP_URL2);
|
|
856
|
+
process.exit(0);
|
|
857
|
+
}
|
|
858
|
+
var options = {
|
|
859
|
+
glob,
|
|
860
|
+
model: opts.model,
|
|
861
|
+
format: opts.format,
|
|
862
|
+
tokenizer: opts.tokenizer,
|
|
863
|
+
out: opts.out ?? null,
|
|
864
|
+
port: parseInt(opts.port, 10),
|
|
865
|
+
noOpen: opts.open === false,
|
|
866
|
+
costPer1k: opts.costPer1k ? parseFloat(opts.costPer1k) : null,
|
|
867
|
+
sampleValues: parseInt(opts.sampleValues, 10)
|
|
868
|
+
};
|
|
869
|
+
var isQuiet = options.format === "json" || options.format === "llm";
|
|
870
|
+
function log(msg) {
|
|
871
|
+
if (!isQuiet) {
|
|
872
|
+
process.stderr.write(msg + "\n");
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
log(` Analyzing ${options.glob}...`);
|
|
876
|
+
var output = runPipeline(options);
|
|
877
|
+
log(` ${output.summary.file_count} files, ${Math.round(output.summary.avg_tokens_per_instance)} avg tokens/instance`);
|
|
878
|
+
log(` ${Math.round(output.summary.overhead_ratio * 100)}% schema overhead, ${Math.round(output.summary.null_waste_ratio * 100)}% null waste`);
|
|
879
|
+
log(` ${output.insights.length} insights detected`);
|
|
880
|
+
switch (options.format) {
|
|
881
|
+
case "json": {
|
|
882
|
+
const formatted = formatJson(output);
|
|
883
|
+
if (options.out) {
|
|
884
|
+
writeFileSync(options.out, formatted, "utf-8");
|
|
885
|
+
console.log(`Output written to ${options.out}`);
|
|
886
|
+
} else {
|
|
887
|
+
console.log(formatted);
|
|
888
|
+
}
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
891
|
+
case "llm": {
|
|
892
|
+
const formatted = formatLlm(output);
|
|
893
|
+
if (options.out) {
|
|
894
|
+
writeFileSync(options.out, formatted, "utf-8");
|
|
895
|
+
console.log(`Output written to ${options.out}`);
|
|
896
|
+
} else {
|
|
897
|
+
console.log(formatted);
|
|
898
|
+
}
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
901
|
+
case "interactive": {
|
|
902
|
+
const cohortedOutput = runCohortedPipeline(options);
|
|
903
|
+
log(` ${cohortedOutput.cohorts.length} schema cohort(s) detected`);
|
|
904
|
+
if (options.out) {
|
|
905
|
+
log(" Building self-contained HTML...");
|
|
906
|
+
const resp = await fetch("https://tomneyland.github.io/tokstat/other/index.html");
|
|
907
|
+
const html = await resp.text();
|
|
908
|
+
const dataScript = `<script id="tokstat-data" type="application/json">${JSON.stringify(cohortedOutput)}</script>`;
|
|
909
|
+
const injectedHtml = html.replace("</head>", `${dataScript}
|
|
910
|
+
</head>`);
|
|
911
|
+
writeFileSync(options.out, injectedHtml, "utf-8");
|
|
912
|
+
log(` Written to ${options.out}`);
|
|
913
|
+
} else {
|
|
914
|
+
log(" Starting visualization server...");
|
|
915
|
+
await startServer(cohortedOutput, options.port, !options.noOpen);
|
|
916
|
+
}
|
|
917
|
+
break;
|
|
918
|
+
}
|
|
919
|
+
default:
|
|
920
|
+
throw new Error(`Unknown format: ${options.format}`);
|
|
921
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tokstat",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Visualize per-field token costs across a corpus of LLM-generated JSON",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tokstat": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"dev": "vite",
|
|
18
|
+
"build": "vite build",
|
|
19
|
+
"build:cli": "tsup",
|
|
20
|
+
"preview": "vite preview",
|
|
21
|
+
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:watch": "vitest",
|
|
24
|
+
"prepublishOnly": "tsup"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"commander": "^14.0.3",
|
|
28
|
+
"fast-glob": "^3.3.3",
|
|
29
|
+
"js-tiktoken": "^1.0.21",
|
|
30
|
+
"open": "^11.0.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
|
34
|
+
"@tsconfig/svelte": "^5.0.6",
|
|
35
|
+
"@types/d3-hierarchy": "^3.1.7",
|
|
36
|
+
"@types/d3-interpolate": "^3.0.4",
|
|
37
|
+
"@types/d3-scale": "^4.0.9",
|
|
38
|
+
"@types/node": "^24.10.1",
|
|
39
|
+
"d3-hierarchy": "^3.1.2",
|
|
40
|
+
"d3-interpolate": "^3.0.1",
|
|
41
|
+
"d3-scale": "^4.0.2",
|
|
42
|
+
"svelte": "^5.45.2",
|
|
43
|
+
"svelte-check": "^4.3.4",
|
|
44
|
+
"tsup": "^8.5.1",
|
|
45
|
+
"typescript": "~5.9.3",
|
|
46
|
+
"vite": "^7.3.1",
|
|
47
|
+
"vite-plugin-singlefile": "^2.3.0",
|
|
48
|
+
"vitest": "^4.0.18"
|
|
49
|
+
}
|
|
50
|
+
}
|