@tokenzero/cli 0.1.0 → 0.1.3

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.
Files changed (3) hide show
  1. package/README.md +27 -0
  2. package/bin/tokenzero.mjs +148 -27
  3. package/package.json +22 -5
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # @tokenzero/cli
2
+
3
+ Command line for [TokenZero](https://tokenzero.sh): audit LLM token waste in a project, connect it to the TokenZero bring-your-own-key gateway, check usage, and verify signed savings receipts.
4
+
5
+ ```sh
6
+ npx -y @tokenzero/cli audit # local static audit; nothing is uploaded
7
+ npx -y @tokenzero/cli audit --json
8
+ npx -y @tokenzero/cli audit --fail-over 30 # CI gate: exit 1 above 30% recoverable
9
+ git diff origin/main | npx -y @tokenzero/cli audit --diff - --format markdown
10
+ ```
11
+
12
+ Audits are **static heuristics**: they read code, not traffic, and report estimates at list prices. Library and SDK repositories are detected and their findings downgraded.
13
+
14
+ ## Connecting to the gateway
15
+
16
+ These commands talk to the hosted gateway at `https://tokenzero.sh`. To use another deployment, pass `--url` or set `TOKENZERO_URL`.
17
+
18
+ ```sh
19
+ npx -y @tokenzero/cli init # audit, create a workspace, write base URLs to .env (asks first)
20
+ npx -y @tokenzero/cli usage # routed spend vs the free tier, savings
21
+ npx -y @tokenzero/cli verify <request_id> # check a receipt's Ed25519 signature locally
22
+ npx -y @tokenzero/cli rotate-key # new workspace key; rewrites it in .env
23
+ ```
24
+
25
+ **Authentication.** Send your workspace key as the `X-TokenZero-Key` header; this is the recommended form. Your provider key is sent exactly as you would send it to the provider and passes through untouched. For setups where you cannot add headers, `init` can embed the workspace key in the base URL (`<gateway>/w/<key>/v1`). Treat such URLs as secrets, and run `rotate-key` if one leaks into logs.
26
+
27
+ Node >= 18. Single file, no dependencies. Apache-2.0.
package/bin/tokenzero.mjs CHANGED
@@ -203,7 +203,9 @@ function lex(src, path3) {
203
203
  j++;
204
204
  }
205
205
  const end = Math.min(j, src.length);
206
- literals.push({ text: src.slice(i + 1, end), line });
206
+ const rep = (py ? /^\s*\*\s*(\d{1,6})\b/ : /^\.repeat\(\s*(\d{1,6})\s*\)/).exec(src.slice(end + 1, end + 40));
207
+ const body = src.slice(i + 1, end);
208
+ literals.push({ text: rep ? body.repeat(Math.max(1, Math.min(Number(rep[1]), Math.floor(2e5 / Math.max(1, body.length))))) : body, line });
207
209
  blank(i + 1, end);
208
210
  advance(i, end + 1);
209
211
  i = end + 1;
@@ -254,6 +256,98 @@ function detectRepoKind(manifests2, files) {
254
256
  return { kind: "unknown", reason: "no manifest signal" };
255
257
  }
256
258
  var DOWN = { high: "medium", medium: "low", low: "info", info: "info" };
259
+ var OPEN = "([{";
260
+ var CLOSE = ")]}";
261
+ function matchClose(m, from, limit = 4e4) {
262
+ let depth = 0;
263
+ for (let k = from; k < Math.min(m.length, from + limit); k++) {
264
+ const c2 = m[k];
265
+ if (OPEN.includes(c2)) depth++;
266
+ else if (CLOSE.includes(c2) && --depth === 0) return k;
267
+ }
268
+ return Math.min(m.length, from + limit) - 1;
269
+ }
270
+ function makeLocator(masked) {
271
+ const text = masked.join("\n");
272
+ const starts = [];
273
+ let o = 0;
274
+ for (const l of masked) {
275
+ starts.push(o);
276
+ o += l.length + 1;
277
+ }
278
+ const lineOf = (off) => {
279
+ let lo = 0, hi = starts.length - 1;
280
+ while (lo < hi) {
281
+ const mid = lo + hi + 1 >> 1;
282
+ if (starts[mid] <= off) lo = mid;
283
+ else hi = mid - 1;
284
+ }
285
+ return lo;
286
+ };
287
+ return { text, starts, lineOf };
288
+ }
289
+ var ASSIGN = (name) => new RegExp(`^\\s*(?:export\\s+)?(?:const|let|var|final|static|private|public|readonly|\\s)*\\s*(?:self\\.|this\\.)?${name}\\s*(?::[^=\\n]+)?=(?![=>])`);
290
+ var KEYWORDS = /* @__PURE__ */ new Set(["self", "this", "await", "async", "return", "model", "messages", "role", "content", "system", "user", "true", "false", "True", "False", "None", "null", "undefined", "new", "max_tokens", "temperature", "client", "resp", "response"]);
291
+ function assignmentScope(loc, masked, line) {
292
+ const eq = masked[line].search(/=(?![=>])/);
293
+ let end = line;
294
+ let k = loc.starts[line] + eq + 1;
295
+ for (; ; ) {
296
+ const lineEnd = loc.starts[end] + masked[end].length;
297
+ let opened = -1;
298
+ for (let q = k; q < lineEnd; q++) if (OPEN.includes(loc.text[q])) {
299
+ opened = q;
300
+ break;
301
+ }
302
+ if (opened >= 0) {
303
+ const close = matchClose(loc.text, opened, 2e4);
304
+ end = loc.lineOf(close);
305
+ k = close + 1;
306
+ if (k < loc.starts[end] + masked[end].length) continue;
307
+ }
308
+ const tail = masked[end].trimEnd();
309
+ if (end + 1 < masked.length && (/[+\\,(]$/.test(tail) || /^\s*[+.]/.test(masked[end + 1] ?? "")) && end - line < 60) {
310
+ end++;
311
+ k = loc.starts[end];
312
+ continue;
313
+ }
314
+ return { start: line, end };
315
+ }
316
+ }
317
+ function identifiersIn(masked, sc) {
318
+ const ids = /* @__PURE__ */ new Set();
319
+ for (let l = sc.start; l <= sc.end; l++) for (const m of (masked[l] ?? "").matchAll(/\b([A-Za-z_]\w{1,60})\b/g)) if (!KEYWORDS.has(m[1])) ids.add(m[1]);
320
+ return ids;
321
+ }
322
+ function resolveScopes(loc, masked, from, depth = 2) {
323
+ const out2 = [], seen = /* @__PURE__ */ new Set();
324
+ let frontier = [from];
325
+ for (let d = 0; d < depth && frontier.length; d++) {
326
+ const next = [];
327
+ for (const sc of frontier) for (const id of identifiersIn(masked, sc)) {
328
+ if (seen.has(id)) continue;
329
+ seen.add(id);
330
+ const re = ASSIGN(id);
331
+ let found = -1;
332
+ for (let l = sc.start; l >= 0; l--) if (re.test(masked[l])) {
333
+ found = l;
334
+ break;
335
+ }
336
+ if (found < 0) {
337
+ for (let l = sc.end + 1; l < masked.length; l++) if (re.test(masked[l])) {
338
+ found = l;
339
+ break;
340
+ }
341
+ }
342
+ if (found < 0 || found >= from.start && found <= from.end) continue;
343
+ const a = assignmentScope(loc, masked, found);
344
+ out2.push(a);
345
+ next.push(a);
346
+ }
347
+ frontier = next;
348
+ }
349
+ return out2;
350
+ }
257
351
  function analyzeFiles(files, repo = null, manifests2 = []) {
258
352
  const findings = [];
259
353
  const callSites = [];
@@ -281,9 +375,16 @@ function analyzeFiles(files, repo = null, manifests2 = []) {
281
375
  const hasCacheControl = /cache_control|cacheControl|prompt_cache_key|promptCacheKey|cachedContent|cached_content/.test(src);
282
376
  const illustrative = EXAMPLE_PATH.test(f.path);
283
377
  let repeatFlagged = false;
284
- masked.forEach((code, i) => {
285
- const sdk = CALL_RE.find(([re]) => re.test(code))?.[1];
286
- if (!sdk) return;
378
+ const loc = makeLocator(masked);
379
+ const callLines = [];
380
+ masked.forEach((code2, i) => {
381
+ const hit = CALL_RE.find(([re]) => re.test(code2));
382
+ if (!hit) return;
383
+ const m = hit[0].exec(code2);
384
+ const close = matchClose(loc.text, loc.starts[i] + m.index + m[0].length - 1);
385
+ callLines.push({ line: i, sdk: hit[1], scope: { start: i, end: Math.max(i, loc.lineOf(close)) } });
386
+ });
387
+ callLines.forEach(({ line: i, sdk, scope }, n) => {
287
388
  const local = [];
288
389
  const push = (fd) => {
289
390
  let sev = fd.severity;
@@ -291,13 +392,22 @@ function analyzeFiles(files, repo = null, manifests2 = []) {
291
392
  if (illustrative) sev = DOWN[sev];
292
393
  local.push({ ...fd, severity: sev });
293
394
  };
294
- const window = lines.slice(i, Math.min(lines.length, i + 30)).join("\n");
295
- const model = [...window.matchAll(MODEL_RE)].map((m) => m[1])[0] ?? null;
395
+ const refs = resolveScopes(loc, masked, scope);
396
+ const inScopes = (l, scs) => scs.some((sc) => l.line - 1 >= sc.start && l.line - 1 <= sc.end);
397
+ let near = literals.filter((l) => inScopes(l, [scope, ...refs]));
398
+ if (!near.some((l) => l.text.trim().length > 20)) {
399
+ const prevEnd = n > 0 ? callLines[n - 1].scope.end : -1;
400
+ near = near.concat(literals.filter((l) => l.line - 1 > prevEnd && l.line - 1 >= i - 40 && l.line - 1 < i));
401
+ }
402
+ const spanText = lines.slice(scope.start, scope.end + 1).join("\n");
403
+ const nextStart = n + 1 < callLines.length ? callLines[n + 1].line : lines.length;
404
+ const fallbackWindow = lines.slice(i, Math.min(nextStart, i + 30)).join("\n");
405
+ const modelFrom = (t) => [...t.matchAll(MODEL_RE)].map((m) => m[1])[0] ?? null;
406
+ const model = modelFrom(spanText) ?? modelFrom(refs.map((r) => lines.slice(r.start, r.end + 1).join("\n")).join("\n")) ?? modelFrom(fallbackWindow);
296
407
  callSites.push({ file: f.path, line: i + 1, model, sdk });
297
408
  if (model) models[model] = (models[model] ?? 0) + 1;
298
409
  const key = normalizeModel(model);
299
410
  const price = key ? priceFor(key) : null;
300
- const near = literals.filter((l) => l.line >= i + 1 - 40 && l.line <= i + 1 + 40);
301
411
  const prompts = near.filter((l) => promptLike(l.text));
302
412
  const codeCtx = masked.slice(Math.max(0, i - 25), i + 25).join("\n");
303
413
  const simpleTask = near.some((l) => SIMPLE_TASK.test(l.text));
@@ -311,23 +421,25 @@ function analyzeFiles(files, repo = null, manifests2 = []) {
311
421
  severity: isAnthropic ? bigTok >= 1500 ? "high" : cacheable ? "medium" : "low" : cacheable ? "medium" : "low",
312
422
  file: f.path,
313
423
  line: prompts[0].line,
314
- title: `~${bigTok.toLocaleString()} static prompt tokens re-billed at full price on every call`,
315
- detail: isAnthropic ? `This call sends a large fixed prompt but never marks a cache breakpoint, so Anthropic bills all ${bigTok} tokens at the full input rate each time instead of the cache-read rate (${readRate}% cheaper).` : `A fixed prefix of ~${bigTok} tokens is sent on every call. Prefix caching applies only to stable prefixes of at least ${min} tokens on this model; keep the static part first and nothing volatile (timestamps, ids) interpolated into it.`,
424
+ title: isAnthropic ? `~${bigTok.toLocaleString()} static prompt tokens re-billed at full price on every call` : `~${bigTok.toLocaleString()} static prompt tokens sent on every call; only a stable prefix gets the cache discount`,
425
+ detail: (isAnthropic ? `This call sends a large fixed prompt but never marks a cache breakpoint, so Anthropic bills all ${bigTok} tokens at the full input rate each time instead of the cache-read rate (${readRate}% cheaper).` : `A fixed block of ~${bigTok} tokens goes out with every call. ${model ? model : "This provider"} discounts cached input ${readRate}%, but only for a byte-identical prefix of at least ${min} tokens that is routed to the same cache; anything volatile (dates, ids, user text) placed before or inside it, or traffic spread across cache shards, means paying full price.`) + (prompts[0].line !== i + 1 ? ` Sent by the call at ${f.path}:${i + 1}.` : ""),
316
426
  fix: isAnthropic ? 'Route through TokenZero (breakpoints are injected automatically) or add `cache_control: {type: "ephemeral"}` to the last system block and the last tool.' : "Route through TokenZero to get a stable `prompt_cache_key`, or move dynamic content below the static prefix and set `prompt_cache_key` yourself.",
317
427
  // OpenAI/Gemini already cache stable prefixes automatically; the recoverable part
318
428
  // is prefix hygiene and cache routing, not the whole cache discount.
319
429
  est_savings_pct: !cacheable ? 0 : isAnthropic ? Math.min(85, Math.round(bigTok / (bigTok + 400) * readRate)) : 15
320
430
  });
321
431
  }
322
- if (key && price?.tier === "frontier" && CASCADE_TARGET[key] && simpleTask) {
323
- const to = CASCADE_TARGET[key];
432
+ const cascadeTo = key ? CASCADE_TARGET[key] : void 0;
433
+ const ratio = cascadeTo ? costOf(cascadeTo, { input: 1500, output: 200 }) / costOf(key, { input: 1500, output: 200 }) : 1;
434
+ if (key && price && cascadeTo && simpleTask && (price.tier === "frontier" || ratio <= 0.5)) {
435
+ const to = cascadeTo;
324
436
  const a = costOf(key, { input: 1500, output: 200 }), b = costOf(to, { input: 1500, output: 200 });
325
437
  push({
326
- severity: "high",
438
+ severity: price.tier === "frontier" || ratio <= 0.2 ? "high" : "medium",
327
439
  file: f.path,
328
440
  line: i + 1,
329
441
  title: `${key} used for a task whose prompt reads like extraction/classification`,
330
- detail: `Frontier pricing ($${price.input}/M in, $${price.output}/M out) on a constrained task. ${to} costs ~${Math.round((1 - b / a) * 100)}% less per call at this shape.`,
442
+ detail: `${price.tier === "frontier" ? "Frontier" : "Full-size"} pricing ($${price.input}/M in, $${price.output}/M out) on a constrained task. ${to} costs ~${Math.round((1 - b / a) * 100)}% less per call at this shape.`,
331
443
  fix: `Opt into TokenZero cascade routing (\`X-TokenZero-Cascade: auto\`) so this call is routed to ${to} only when the classifier is confident, with parity reported in the ledger, or switch the model here behind an eval.`,
332
444
  est_savings_pct: Math.round((1 - b / a) * 100)
333
445
  });
@@ -344,10 +456,10 @@ function analyzeFiles(files, repo = null, manifests2 = []) {
344
456
  est_savings_pct: 25
345
457
  });
346
458
  }
347
- if (simpleTask && !repeatFlagged && !/seed\s*[:=]|temperature\s*[:=]\s*0\b/.test(codeCtx)) {
459
+ if (simpleTask && !repeatFlagged && !/seed["']?\s*[:=]|temperature["']?\s*[:=]\s*0\b|X-TokenZero-Cache/i.test(spanText)) {
348
460
  repeatFlagged = true;
349
461
  push({
350
- severity: "info",
462
+ severity: "low",
351
463
  file: f.path,
352
464
  line: i + 1,
353
465
  title: "Repeat-prone task with no response-cache opt-in",
@@ -356,7 +468,7 @@ function analyzeFiles(files, repo = null, manifests2 = []) {
356
468
  est_savings_pct: 10
357
469
  });
358
470
  }
359
- if (isAnthropic && /\btools\s*[:=]/.test(masked.slice(i, i + 30).join("\n")) && !hasCacheControl) {
471
+ if (isAnthropic && /\btools\s*[:=]/.test(masked.slice(scope.start, scope.end + 1).join("\n")) && !hasCacheControl) {
360
472
  push({
361
473
  severity: "low",
362
474
  file: f.path,
@@ -428,14 +540,23 @@ function filesFromDiff(diff) {
428
540
  }
429
541
  return files;
430
542
  }
431
- function prComment(report, publicUrl) {
543
+ var PR_COMMENT_MARKER = "<!-- tokenzero-cost-check -->";
544
+ var PUBLIC_REPO = "https://github.com/tokenzero-ai/tokenzero";
545
+ var inert = (t) => t.replace(/[\\`*_[\]<>|]/g, (c2) => "\\" + c2).replace(/\r?\n/g, " ");
546
+ var pipeSafe = (t) => t.replace(/\|/g, "\\|");
547
+ var code = (t) => "`" + t.replace(/`/g, "'").replace(/\|/g, "\\|").replace(/\r?\n/g, " ").slice(0, 200) + "`";
548
+ var usd2 = (n) => "$" + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
549
+ function prComment(report, opts = {}) {
432
550
  const c2 = report.cost_per_10k_runs;
433
- const head = c2 ? `**TokenZero FinOps check** \u2014 estimated cost per 10k runs: \`$${c2.before_usd}\` \u2192 \`$${c2.after_usd}\` (${report.estimated_waste_pct}% recoverable) on \`${c2.model}\`` : `**TokenZero FinOps check** \u2014 ${report.findings.length} finding(s), ${report.call_sites.length} LLM call site(s)`;
434
- const rows = report.findings.slice(0, 8).map((f) => `| ${f.severity} | \`${f.file}:${f.line}\` | ${f.title} | ${f.est_savings_pct}% |`).join("\n");
551
+ const head = c2 ? `**TokenZero cost check:** estimated cost per 10k runs ${code(usd2(c2.before_usd))} \u2192 ${code(usd2(c2.after_usd))} (${report.estimated_waste_pct}% recoverable) on ${code(c2.model)}` : `**TokenZero cost check:** ${report.findings.length} finding(s), ${report.call_sites.length} LLM call site(s)`;
552
+ const rows = report.findings.slice(0, 8).map((f) => `| ${f.severity} | ${code(`${f.file}:${f.line}`)} | ${pipeSafe(f.title)} | ${f.severity === "info" ? "not counted" : f.est_savings_pct + "%"} |`).join("\n");
553
+ const g = opts.gatewayUrl?.replace(/\/+$/, "");
554
+ const fix = g ? `One-line fix for everything above: point your SDK at ${code(g + "/v1")} (BYOK; your keys pass straight through).${opts.hostedReport ? ` [Full report](${g}/audit/${report.id})` : ""}` : `How to fix: route calls through the TokenZero gateway, or apply the fixes above by hand. [TokenZero tools](${PUBLIC_REPO})`;
435
555
  return [
556
+ PR_COMMENT_MARKER,
436
557
  head,
437
558
  "",
438
- report.findings.length ? "| severity | where | finding | est. savings |\n|---|---|---|---|\n" + rows : "_No token-waste patterns found in the changed lines._",
559
+ report.findings.length ? "| severity | where | finding | savings on that call |\n|---|---|---|---|\n" + rows : "_No token-waste patterns found in the changed lines._",
439
560
  "",
440
561
  report.findings.slice(0, 3).map((f) => `<details><summary>${f.title}</summary>
441
562
 
@@ -444,10 +565,10 @@ ${f.detail}
444
565
  **Fix:** ${f.fix}
445
566
  </details>`).join("\n"),
446
567
  "",
447
- `One-line fix for everything above: point your SDK at \`${publicUrl}/v1\` (BYOK; your keys pass straight through). [How it works](${publicUrl}) \xB7 [Full report](${publicUrl}/audit/${report.id})`,
448
- ...report.notes.map((n) => `> ${n}`),
568
+ fix,
569
+ ...report.notes.map((n) => `> ${inert(n)}`),
449
570
  "",
450
- "<sub>Estimates are static heuristics at list prices, not measurements. Verified numbers come from the signed ledger once traffic flows.</sub>"
571
+ "<sub>Static heuristics at list prices, not measurements. Each row is the saving on the call it points at; the headline weights rows by confidence and averages over every call site in the change. Verified numbers come from the signed ledger once traffic flows.</sub>"
451
572
  ].join("\n");
452
573
  }
453
574
 
@@ -501,8 +622,8 @@ function canonical(obj) {
501
622
  }
502
623
 
503
624
  // cli/src/main.ts
504
- var VERSION = "0.1.0";
505
- var BAKED_URL = typeof __TOKENZERO_BAKED__ === "string" ? __TOKENZERO_BAKED__ : "__TOKENZERO_DEFAULT_URL__";
625
+ var VERSION = "0.1.3";
626
+ var BAKED_URL = true ? "https://tokenzero.sh" : "__TOKENZERO_DEFAULT_URL__";
506
627
  function parseArgs(argv) {
507
628
  const a = { _: [], flags: {} };
508
629
  for (let i = 0; i < argv.length; i++) {
@@ -523,7 +644,7 @@ function parseArgs(argv) {
523
644
  }
524
645
  var VALUE_FLAGS = /* @__PURE__ */ new Set(["url", "name", "key", "format", "diff", "fail-over", "env-file"]);
525
646
  var tty = process.stdout.isTTY && !process.env.NO_COLOR;
526
- var c = (code) => (s) => tty ? `\x1B[${code}m${s}\x1B[0m` : s;
647
+ var c = (code2) => (s) => tty ? `\x1B[${code2}m${s}\x1B[0m` : s;
527
648
  var bold = c("1");
528
649
  var dim = c("2");
529
650
  var green = c("32");
@@ -753,7 +874,7 @@ async function cmdAudit(a) {
753
874
  } else report = analyzeFiles(walk(root), null, manifests(root));
754
875
  const fmt = a.flags.format ?? (a.flags.json ? "json" : "text");
755
876
  if (fmt === "json") out(JSON.stringify(report, null, 2));
756
- else if (fmt === "markdown") out(prComment(report, baseUrl(a) ?? "https://tokenzero.sh"));
877
+ else if (fmt === "markdown") out(prComment(report, { gatewayUrl: baseUrl(a) }));
757
878
  else {
758
879
  summarize(report, 100);
759
880
  for (const f of report.findings.slice(0, 3)) out(`
package/package.json CHANGED
@@ -1,13 +1,30 @@
1
1
  {
2
2
  "name": "@tokenzero/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "TokenZero CLI: connect a project to the TokenZero BYOK gateway, audit LLM token waste locally, check usage and verify signed savings receipts.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "author": "TokenZero Contributors <support@tokenzero.sh>",
8
8
  "homepage": "https://tokenzero.sh",
9
- "bin": { "tokenzero": "bin/tokenzero.mjs" },
10
- "files": ["bin/tokenzero.mjs"],
11
- "engines": { "node": ">=18" },
12
- "keywords": ["llm", "openai", "anthropic", "gemini", "cost", "finops", "prompt-caching", "mcp"]
9
+ "bin": {
10
+ "tokenzero": "bin/tokenzero.mjs"
11
+ },
12
+ "files": [
13
+ "bin/tokenzero.mjs",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "keywords": [
21
+ "llm",
22
+ "openai",
23
+ "anthropic",
24
+ "gemini",
25
+ "cost",
26
+ "finops",
27
+ "prompt-caching",
28
+ "mcp"
29
+ ]
13
30
  }