@tokenzero/cli 0.1.3 → 0.1.5

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 (2) hide show
  1. package/bin/tokenzero.mjs +115 -36
  2. package/package.json +1 -1
package/bin/tokenzero.mjs CHANGED
@@ -69,7 +69,18 @@ var PRICES = {
69
69
  "gemini-3-flash": P("gemini", "fast", 0.5, 3, 0.05),
70
70
  "gemini-2.5-pro": P("gemini", "frontier", 1.25, 10, 0.125, void 0, { longCtxAt: 2e5, longCtx: { input: 2.5, output: 15, cachedInput: 0.25 } }),
71
71
  "gemini-2.5-flash": P("gemini", "fast", 0.3, 2.5, 0.03),
72
- "gemini-2.5-flash-lite": P("gemini", "fast", 0.1, 0.4, 0.01)
72
+ "gemini-2.5-flash-lite": P("gemini", "fast", 0.1, 0.4, 0.01),
73
+ // ---- xAI Grok ----
74
+ "grok-4": P("xai", "frontier", 3, 15, 0.75),
75
+ "grok-4-fast": P("xai", "fast", 0.2, 0.8, 0.05),
76
+ "grok-4-mini": P("xai", "fast", 0.2, 0.8, 0.05),
77
+ "grok-3": P("xai", "mid", 3, 15, 0.75),
78
+ "grok-3-mini": P("xai", "fast", 0.3, 0.5, 0.075),
79
+ // ---- DeepSeek ----
80
+ "deepseek-v3": P("deepseek", "fast", 0.27, 1.1, 0.07),
81
+ "deepseek-chat": P("deepseek", "fast", 0.27, 1.1, 0.07),
82
+ "deepseek-r1": P("deepseek", "mid", 0.55, 2.19, 0.14),
83
+ "deepseek-reasoner": P("deepseek", "mid", 0.55, 2.19, 0.14)
73
84
  };
74
85
  var CASCADE_TARGET = {
75
86
  "gpt-6-astra": "gpt-6.1-sol",
@@ -100,13 +111,19 @@ var CASCADE_TARGET = {
100
111
  "gemini-3.1-pro": "gemini-3.8-flash",
101
112
  "gemini-3.8-flash": "gemini-3.5-flash-lite",
102
113
  "gemini-2.5-pro": "gemini-2.5-flash",
103
- "gemini-2.5-flash": "gemini-2.5-flash-lite"
114
+ "gemini-2.5-flash": "gemini-2.5-flash-lite",
115
+ "grok-4": "grok-4-fast",
116
+ "grok-3": "grok-3-mini",
117
+ "deepseek-r1": "deepseek-v3",
118
+ "deepseek-reasoner": "deepseek-chat"
104
119
  };
105
120
  var KEYS_BY_LENGTH = Object.keys(PRICES).sort((a, b) => b.length - a.length);
106
121
  function normalizeModel(model) {
107
122
  if (typeof model !== "string" || !model) return null;
108
123
  let m = model.toLowerCase().trim();
109
- m = m.replace(/^(openai|anthropic|google|models|vertex_ai)\//, "");
124
+ m = m.replace(/^(openai|anthropic|google|gemini|x-ai|xai|deepseek|bedrock|models|vertex_ai)\//, "");
125
+ m = m.replace(/^(?:us\.|eu\.|apac\.)?anthropic\./, "");
126
+ m = m.replace(/-v\d+:\d+$/, "");
110
127
  m = m.replace(/@\d{8}$/, "");
111
128
  m = m.replace(/-(\d{8}|\d{4}-\d{2}-\d{2})$/, "");
112
129
  m = m.replace(/-(latest|preview|customtools|thinking)$/, "");
@@ -118,7 +135,7 @@ function priceFor(model) {
118
135
  const key = normalizeModel(model);
119
136
  return key ? { key, ...PRICES[key] } : null;
120
137
  }
121
- function costOf(model, u) {
138
+ function costOf(model, u, factor = 1) {
122
139
  const p = priceFor(model);
123
140
  if (!p) return null;
124
141
  const tier = p.longCtx && p.longCtxAt && u.input > p.longCtxAt ? { ...p, ...p.longCtx } : p;
@@ -126,7 +143,7 @@ function costOf(model, u) {
126
143
  const written = u.cacheWrite ?? 0;
127
144
  const fresh = Math.max(0, u.input - cached - written);
128
145
  const M = 1e6;
129
- return fresh * tier.input / M + cached * (tier.cachedInput ?? tier.input) / M + written * (tier.cacheWrite ?? tier.input) / M + u.output * tier.output / M;
146
+ return factor * (fresh * tier.input / M + cached * (tier.cachedInput ?? tier.input) / M + written * (tier.cacheWrite ?? tier.input) / M + u.output * tier.output / M);
130
147
  }
131
148
 
132
149
  // cli/src/tokens-lite.ts
@@ -224,6 +241,7 @@ function promptLike(t) {
224
241
  const words = (t.match(/[A-Za-z]{2,}/g) ?? []).length;
225
242
  return tags < 4 && cssRules < 3 && codeChars / t.length < 0.03 && words / (t.length / 6) > 0.5;
226
243
  }
244
+ var REPEAT_TASK = /\b(classify|classification|categori[sz]e|sentiment|label (this|the|each)|is (this|it) spam|yes or no|true or false)\b/i;
227
245
  var SIMPLE_TASK = /\b(classify|classification|categori[sz]e|sentiment|extract (the|all|every|any)|return (only |just )?(valid |a )?json|respond (only )?(with|in) (valid )?json|output (only )?(valid )?json|translate (this|the following|into)|summari[sz]e (this|the following|in \d+)|label (this|the|each)|is (this|it) spam|yes or no|true or false)\b/i;
228
246
  var LLM_LIB_EXACT = /* @__PURE__ */ new Set(["openai", "anthropic", "@anthropic-ai/sdk", "ai", "litellm", "openai-agents", "@openai/agents", "instructor", "dspy", "dspy-ai", "guidance", "semantic-kernel", "haystack-ai", "autogen", "pyautogen", "autogen-agentchat", "crewai", "google-genai", "@google/genai", "mistralai", "@mistralai/mistralai", "cohere", "cohere-ai", "llama-index", "llama_index", "llamaindex", "langchain", "langgraph", "@langchain/core", "groq-sdk", "together-ai", "ollama"]);
229
247
  var LLM_LIB_FAMILY = /^(@langchain\/[\w-]+|langchain[-_][\w-]+|langgraph[-_][\w-]+|llama[-_]index[-_][\w-]+|@ai-sdk\/[\w-]+|openai[-_]agents[-_][\w-]+)$/i;
@@ -359,7 +377,7 @@ function analyzeFiles(files, repo = null, manifests2 = []) {
359
377
  const { kind, reason } = detectRepoKind(manifests2, sources);
360
378
  if (kind === "library") notes.push(`Library/SDK layout detected (${reason}): call sites are most likely pass-through wrappers where the caller chooses the model and prompt, so findings are downgraded one level and the estimate is discounted.`);
361
379
  const promptLibrary = [];
362
- const repoCaches = sources.some((f) => /cache_control|cacheControl|prompt_cache_key|promptCacheKey|cachedContent|cached_content/.test(f.content));
380
+ const repoCaches = sources.some((f) => /cache_control|cacheControl|prompt_cache_key|promptCacheKey|prompt_cache_options|promptCacheOptions|prompt_cache_breakpoint|promptCacheBreakpoint|prompt_cache_retention|promptCacheRetention|cachedContent|cached_content/.test(f.content));
363
381
  const PROMPT_FILE = /(^|\/)[^/]*(prompt|instruction|template|persona)s?[^/]*\.[a-z]+$|(^|\/)prompts?\//i;
364
382
  const PROMPT_NAME = /\b[\w.]*(prompt|system|instruction|template|persona)\w*\s*(:\s*[\w[\]|. ]+)?\s*[:=]\s*(f|r|rf|fr|b)?$/i;
365
383
  for (const f of sources) {
@@ -372,7 +390,7 @@ function analyzeFiles(files, repo = null, manifests2 = []) {
372
390
  const lead = (masked[l.line - 1] ?? "").slice(0, Math.max(0, (masked[l.line - 1] ?? "").search(/["'`]|$/))).trimEnd();
373
391
  if (PROMPT_FILE.test(f.path) || PROMPT_NAME.test(lead) || PROMPT_NAME.test((masked[l.line - 2] ?? "").trimEnd())) promptLibrary.push({ file: f.path, line: l.line, tokens });
374
392
  }
375
- const hasCacheControl = /cache_control|cacheControl|prompt_cache_key|promptCacheKey|cachedContent|cached_content/.test(src);
393
+ const hasCacheControl = /cache_control|cacheControl|prompt_cache_key|promptCacheKey|prompt_cache_options|promptCacheOptions|prompt_cache_breakpoint|promptCacheBreakpoint|prompt_cache_retention|promptCacheRetention|cachedContent|cached_content/.test(src);
376
394
  const illustrative = EXAMPLE_PATH.test(f.path);
377
395
  let repeatFlagged = false;
378
396
  const loc = makeLocator(masked);
@@ -393,6 +411,16 @@ function analyzeFiles(files, repo = null, manifests2 = []) {
393
411
  local.push({ ...fd, severity: sev });
394
412
  };
395
413
  const refs = resolveScopes(loc, masked, scope);
414
+ if (sdk.startsWith("langchain")) {
415
+ const assigned = /^\s*(?:const|let|var|self\.|this\.)?\s*([A-Za-z_]\w*)\s*(?::[^=\n]+)?=/.exec(masked[i])?.[1];
416
+ if (assigned) for (let l = 0; l < masked.length; l++) {
417
+ const inv = new RegExp(`\\b${assigned}\\.(invoke|ainvoke|stream|astream|batch|abatch|call|predict|generate)\\s*\\(`).exec(masked[l]);
418
+ if (!inv) continue;
419
+ const close = matchClose(loc.text, loc.starts[l] + inv.index + inv[0].length - 1);
420
+ const sc = { start: l, end: Math.max(l, loc.lineOf(close)) };
421
+ refs.push(sc, ...resolveScopes(loc, masked, sc));
422
+ }
423
+ }
396
424
  const inScopes = (l, scs) => scs.some((sc) => l.line - 1 >= sc.start && l.line - 1 <= sc.end);
397
425
  let near = literals.filter((l) => inScopes(l, [scope, ...refs]));
398
426
  if (!near.some((l) => l.text.trim().length > 20)) {
@@ -411,6 +439,7 @@ function analyzeFiles(files, repo = null, manifests2 = []) {
411
439
  const prompts = near.filter((l) => promptLike(l.text));
412
440
  const codeCtx = masked.slice(Math.max(0, i - 25), i + 25).join("\n");
413
441
  const simpleTask = near.some((l) => SIMPLE_TASK.test(l.text));
442
+ const repeatTask = near.some((l) => REPEAT_TASK.test(l.text));
414
443
  const isAnthropic = sdk.includes("anthropic") || (model?.startsWith("claude") ?? false);
415
444
  const bigTok = prompts.reduce((a, l) => a + countTokens(l.text), 0);
416
445
  if (bigTok >= 300 && !hasCacheControl) {
@@ -440,7 +469,7 @@ function analyzeFiles(files, repo = null, manifests2 = []) {
440
469
  line: i + 1,
441
470
  title: `${key} used for a task whose prompt reads like extraction/classification`,
442
471
  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.`,
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.`,
472
+ fix: `Opt into TokenZero cascade routing (\`X-TokenZero-Cascade: auto\`) so this call is routed to ${to} only when the classifier is confident (an opt-in shadow parity monitor is coming; nothing is promised), or switch the model here behind an eval.`,
444
473
  est_savings_pct: Math.round((1 - b / a) * 100)
445
474
  });
446
475
  }
@@ -451,12 +480,12 @@ function analyzeFiles(files, repo = null, manifests2 = []) {
451
480
  file: f.path,
452
481
  line: i + 1,
453
482
  title: "LLM call inside a loop",
454
- detail: "Per-item calls repeat the full prompt prefix for every element. If items are independent and latency-tolerant, the provider Batch API is 50% cheaper; if the prefix is shared, caching pays off immediately.",
455
- fix: "Batch where possible; otherwise keep the shared prefix cache-formatted (TokenZero does this automatically).",
483
+ detail: 'Per-item calls repeat the full prompt prefix for every element. If items are independent and latency-tolerant, the provider Batch API is 50% cheaper, and OpenAI Flex processing (`service_tier: "flex"`) gives the same 50% on ordinary calls without restructuring; if the prefix is shared, caching pays off immediately.',
484
+ fix: 'Batch where possible, or set `service_tier: "flex"` on OpenAI calls that can wait; otherwise keep the shared prefix cache-formatted (TokenZero does this automatically).',
456
485
  est_savings_pct: 25
457
486
  });
458
487
  }
459
- if (simpleTask && !repeatFlagged && !/seed["']?\s*[:=]|temperature["']?\s*[:=]\s*0\b|X-TokenZero-Cache/i.test(spanText)) {
488
+ if (repeatTask && !repeatFlagged && !/seed["']?\s*[:=]|temperature["']?\s*[:=]\s*0\b|X-TokenZero-Cache/i.test(spanText)) {
460
489
  repeatFlagged = true;
461
490
  push({
462
491
  severity: "low",
@@ -526,17 +555,32 @@ function analyzeFiles(files, repo = null, manifests2 = []) {
526
555
  function filesFromDiff(diff) {
527
556
  const files = [];
528
557
  let cur = null;
558
+ let lineNo = 0;
529
559
  for (const line of diff.split("\n")) {
530
560
  const m = /^\+\+\+ b\/(.+)$/.exec(line);
531
561
  if (m) {
532
562
  cur = { path: m[1], content: "" };
533
563
  files.push(cur);
564
+ lineNo = 0;
534
565
  continue;
535
566
  }
536
567
  if (!cur) continue;
537
- if (line.startsWith("+") && !line.startsWith("+++")) cur.content += line.slice(1) + "\n";
538
- else if (line.startsWith(" ")) cur.content += line.slice(1) + "\n";
539
- else if (line.startsWith("@@")) cur.content += "\n";
568
+ const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
569
+ if (h) {
570
+ const start = Number(h[1]);
571
+ while (lineNo < start - 1) {
572
+ cur.content += "\n";
573
+ lineNo++;
574
+ }
575
+ continue;
576
+ }
577
+ if (line.startsWith("+") && !line.startsWith("+++")) {
578
+ cur.content += line.slice(1) + "\n";
579
+ lineNo++;
580
+ } else if (line.startsWith(" ")) {
581
+ cur.content += line.slice(1) + "\n";
582
+ lineNo++;
583
+ } else if (line === "\") continue;
540
584
  }
541
585
  return files;
542
586
  }
@@ -622,7 +666,7 @@ function canonical(obj) {
622
666
  }
623
667
 
624
668
  // cli/src/main.ts
625
- var VERSION = "0.1.3";
669
+ var VERSION = "0.1.5";
626
670
  var BAKED_URL = true ? "https://tokenzero.sh" : "__TOKENZERO_DEFAULT_URL__";
627
671
  function parseArgs(argv) {
628
672
  const a = { _: [], flags: {} };
@@ -829,42 +873,77 @@ ${bold("Your code")}`);
829
873
  if (sn) out(` ${s.padEnd(10)} ${sn}`);
830
874
  }
831
875
  }
876
+ if (a.flags["github-action"]) await setupGithubAction(root, a);
832
877
  if (a.flags.mcp) await setupMcp(root, base, a);
833
878
  out(`
834
879
  ${bold("Next")}`);
835
880
  if (wsId) out(` dashboard ${base}/dashboard?workspace=${wsId}`);
836
881
  out(` usage ${selfCmd(base)} usage`);
837
882
  if (wsId) out(` badge turn on public stats in the dashboard, then: [![LLM spend verified by TokenZero](${base}/v1/badge/${wsId}.svg)](${base}/dashboard?workspace=${wsId})`);
883
+ if (!a.flags["github-action"]) out(` PR checks ${selfCmd(base)} init --github-action`);
838
884
  if (!a.flags.mcp) out(` coding agents: ${selfCmd(base)} init --mcp`);
839
885
  }
840
- var selfCmd = (base) => `npx -y ${base}/cli/tokenzero.tgz`;
886
+ var selfCmd = (_base) => "npx -y @tokenzero/cli";
841
887
  var redactKey = (s, key) => key ? s.replace(key, key.slice(0, 12) + "\u2026" + key.slice(-4)) : s;
842
- async function setupMcp(root, base, a) {
888
+ var GH_ACTION_YAML = `name: TokenZero LLM Cost Check
889
+ on: [pull_request]
890
+ permissions:
891
+ contents: read
892
+ pull-requests: write
893
+ jobs:
894
+ cost-audit:
895
+ runs-on: ubuntu-latest
896
+ steps:
897
+ - uses: actions/checkout@v4
898
+ with:
899
+ fetch-depth: 0
900
+ - uses: tokenzero-ai/tokenzero/action@v0
901
+ with:
902
+ github-token: \${{ secrets.GITHUB_TOKEN }}
903
+ `;
904
+ async function setupGithubAction(root, a) {
843
905
  out(`
844
- ${bold("MCP")} ${dim("(audit_repo_token_waste, optimize_prompt_payload, calculate_model_arbitrage)")}`);
845
- out(` Claude Code: claude mcp add --transport http tokenzero ${base}/mcp`);
846
- const cursorDir = path2.join(root, ".cursor");
847
- const file = path2.join(cursorDir, "mcp.json");
848
- let cfg = {};
906
+ ${bold("GitHub Action")} ${dim("(PR token-waste & model-arbitrage check)")}`);
907
+ const wfDir = path2.join(root, ".github", "workflows");
908
+ const file = path2.join(wfDir, "tokenzero.yml");
849
909
  if (fs2.existsSync(file)) {
850
- try {
851
- cfg = JSON.parse(fs2.readFileSync(file, "utf8"));
852
- } catch {
853
- out(yellow(` ! ${file} is not valid JSON; not touching it`));
854
- return;
855
- }
856
- }
857
- if (cfg?.mcpServers?.tokenzero?.url === `${base}/mcp`) {
858
- out(dim(" Cursor: already configured"));
910
+ out(dim(` ${path2.relative(root, file)} already exists`));
859
911
  return;
860
912
  }
861
- if (await confirm(` Add TokenZero to ${path2.relative(root, file)} for Cursor?`, a)) {
862
- cfg.mcpServers = { ...cfg.mcpServers ?? {}, tokenzero: { url: `${base}/mcp` } };
863
- fs2.mkdirSync(cursorDir, { recursive: true });
864
- fs2.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
913
+ if (await confirm(` Add ${path2.relative(root, file)} to audit every PR?`, a)) {
914
+ fs2.mkdirSync(wfDir, { recursive: true });
915
+ fs2.writeFileSync(file, GH_ACTION_YAML);
865
916
  out(green(` \u2713 wrote ${path2.relative(root, file)}`));
866
917
  }
867
918
  }
919
+ async function setupMcp(root, base, a) {
920
+ out(`
921
+ ${bold("MCP")} ${dim("(audit_repo_token_waste, optimize_prompt_payload, calculate_model_arbitrage)")}`);
922
+ out(` Claude Code: claude mcp add --transport http tokenzero ${base}/mcp`);
923
+ out(` Local stdio: npx -y @tokenzero/mcp`);
924
+ for (const rel of [path2.join(".cursor", "mcp.json"), ".mcp.json"]) {
925
+ const file = path2.join(root, rel);
926
+ let cfg = {};
927
+ if (fs2.existsSync(file)) {
928
+ try {
929
+ cfg = JSON.parse(fs2.readFileSync(file, "utf8"));
930
+ } catch {
931
+ out(yellow(` ! ${file} is not valid JSON; not touching it`));
932
+ continue;
933
+ }
934
+ }
935
+ if (cfg?.mcpServers?.tokenzero?.url === `${base}/mcp`) {
936
+ out(dim(` ${rel}: already configured`));
937
+ continue;
938
+ }
939
+ if (await confirm(` Add TokenZero to ${rel}?`, a)) {
940
+ cfg.mcpServers = { ...cfg.mcpServers ?? {}, tokenzero: { url: `${base}/mcp` } };
941
+ fs2.mkdirSync(path2.dirname(file), { recursive: true });
942
+ fs2.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
943
+ out(green(` \u2713 wrote ${rel}`));
944
+ }
945
+ }
946
+ }
868
947
  async function cmdAudit(a) {
869
948
  const root = path2.resolve(a._[1] ?? ".");
870
949
  let report;
@@ -980,7 +1059,7 @@ async function cmdVerify(a) {
980
1059
  function help() {
981
1060
  out(`${bold("tokenzero")} v${VERSION} \u2014 cut your LLM bill with a BYOK gateway
982
1061
 
983
- ${bold("tokenzero init")} [dir] [--url URL] [--name NAME] [--yes] [--no-env] [--mcp]
1062
+ ${bold("tokenzero init")} [dir] [--url URL] [--name NAME] [--yes] [--no-env] [--mcp] [--github-action]
984
1063
  Audit locally, create a workspace, point OPENAI_BASE_URL / ANTHROPIC_BASE_URL at the gateway.
985
1064
  ${bold("tokenzero audit")} [dir] [--json | --format markdown] [--diff file|-] [--fail-over PCT]
986
1065
  Static token-waste audit. Runs locally; nothing is uploaded. --fail-over makes it a CI gate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenzero/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",