@dropalltables/yacu 0.1.0 → 1.0.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 CHANGED
@@ -15,7 +15,7 @@ bun add --global @dropalltables/yacu
15
15
  yacu
16
16
  ```
17
17
 
18
- `yacu` reads local Claude Code, Codex, Cursor, Gemini CLI, OpenCode, and Grok session stores. It does not require accounts, API keys, or network access. Cursor token totals are estimated from local agent transcripts; the other sources use locally recorded usage fields.
18
+ `yacu` reads local Claude Code, Codex, Cursor, Gemini CLI, OpenCode, and Grok session stores. It fetches current model pricing from [models.dev](https://models.dev) and does not require an account or API key. Cursor token totals are estimated from local agent transcripts; the other sources use locally recorded usage fields.
19
19
 
20
20
  ## Keys
21
21
 
package/dist/yacu.js CHANGED
@@ -134,46 +134,6 @@ function buildDashboard(dataset, range, metric) {
134
134
  import { homedir } from "os";
135
135
  import { join, resolve } from "path";
136
136
 
137
- // src/data/pricing.ts
138
- import {
139
- estimateUsdCost,
140
- normalizeTokenUsage,
141
- pricingFromUsdPerMillion
142
- } from "tokentally";
143
- var PRICING = [
144
- [/claude.*opus/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 5, outputUsdPerMillion: 25, cachedInputUsdPerMillion: 0.5, cacheCreationInputUsdPerMillion: 6.25 })],
145
- [/claude.*sonnet/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 3, outputUsdPerMillion: 15, cachedInputUsdPerMillion: 0.3, cacheCreationInputUsdPerMillion: 3.75 })],
146
- [/claude.*haiku/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 1, outputUsdPerMillion: 5, cachedInputUsdPerMillion: 0.1, cacheCreationInputUsdPerMillion: 1.25 })],
147
- [/(^|\/)gpt-5|codex/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 1.75, outputUsdPerMillion: 14, cachedInputUsdPerMillion: 0.175 })],
148
- [/grok|composer/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 2, outputUsdPerMillion: 10, cachedInputUsdPerMillion: 0.2 })],
149
- [/deepseek/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 0.27, outputUsdPerMillion: 1.1, cachedInputUsdPerMillion: 0.07 })],
150
- [/gemini-3\.1-pro/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 2, outputUsdPerMillion: 12, cachedInputUsdPerMillion: 0.2 })],
151
- [/gemini-2\.5-pro/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 1.25, outputUsdPerMillion: 10, cachedInputUsdPerMillion: 0.125 })],
152
- [/gemini-3\.[67]-flash/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 0.75, outputUsdPerMillion: 3.75, cachedInputUsdPerMillion: 0.075 })],
153
- [/gemini-3\.5-flash/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 1.5, outputUsdPerMillion: 9, cachedInputUsdPerMillion: 0.15 })],
154
- [/gemini-3\.1-flash-lite/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 0.25, outputUsdPerMillion: 1.5, cachedInputUsdPerMillion: 0.025 })],
155
- [/gemini.*flash/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 0.3, outputUsdPerMillion: 2.5, cachedInputUsdPerMillion: 0.03 })]
156
- ];
157
- function resolvePricing(model) {
158
- return PRICING.find(([pattern]) => pattern.test(model))?.[1] ?? null;
159
- }
160
- function estimateCost(model, tokens) {
161
- const usage = normalizeTokenUsage({
162
- inputTokens: tokens.inputTokens,
163
- outputTokens: tokens.outputTokens,
164
- cachedInputTokens: tokens.cacheReadTokens,
165
- cacheCreationInputTokens: tokens.cacheCreationTokens
166
- });
167
- return estimateUsdCost({ usage, pricing: resolvePricing(model) })?.totalUsd ?? 0;
168
- }
169
- function estimateCacheSavings(model, cachedReadTokens) {
170
- const pricing = resolvePricing(model);
171
- if (pricing == null)
172
- return 0;
173
- const cachedRate = pricing.cachedInputUsdPerToken ?? pricing.inputUsdPerToken;
174
- return Math.max(0, cachedReadTokens * (pricing.inputUsdPerToken - cachedRate));
175
- }
176
-
177
137
  // src/data/jsonl.ts
178
138
  import { stat } from "fs/promises";
179
139
  async function forEachJsonLine(path, callback) {
@@ -244,7 +204,7 @@ function stringValue(value) {
244
204
  }
245
205
 
246
206
  // src/data/sources/claude.ts
247
- async function loadClaudeUsage() {
207
+ async function loadClaudeUsage(pricing) {
248
208
  const files = [...new Set((await Promise.all(claudeRoots().map((root) => globFiles(join(root, "projects"), "**/*.jsonl", { includeSymlinks: true })))).flat())];
249
209
  const records = [];
250
210
  const sessions = new Map;
@@ -275,8 +235,8 @@ async function loadClaudeUsage() {
275
235
  model: entry.model,
276
236
  sessionId,
277
237
  ...tokens,
278
- costUsd: estimateCost(entry.model, tokens),
279
- cacheSavingsUsd: estimateCacheSavings(entry.model, entry.cacheReadTokens)
238
+ costUsd: pricing.estimateCost("anthropic", entry.model, tokens),
239
+ cacheSavingsUsd: pricing.estimateCacheSavings("anthropic", entry.model, tokens)
280
240
  });
281
241
  if (sessionId != null) {
282
242
  const existing = sessions.get(sessionId);
@@ -326,7 +286,7 @@ function sumTokens(tokens) {
326
286
  // src/data/sources/codex.ts
327
287
  import { homedir as homedir2 } from "os";
328
288
  import { join as join2 } from "path";
329
- async function loadCodexUsage() {
289
+ async function loadCodexUsage(pricing) {
330
290
  const root = join2(process.env.CODEX_HOME ?? join2(homedir2(), ".codex"), "sessions");
331
291
  const files = await globFiles(root, "**/*.jsonl");
332
292
  const records = [];
@@ -334,6 +294,7 @@ async function loadCodexUsage() {
334
294
  for (const path of files) {
335
295
  let sessionId = path;
336
296
  let model = "codex";
297
+ let provider = "openai";
337
298
  let skipSession = false;
338
299
  let previousTotal = "";
339
300
  await forEachJsonLine(path, (unknownValue) => {
@@ -342,9 +303,10 @@ async function loadCodexUsage() {
342
303
  const type = stringValue(value?.type);
343
304
  if (type === "session_meta") {
344
305
  sessionId = stringValue(payload?.id) ?? sessionId;
306
+ provider = stringValue(payload?.model_provider) ?? provider;
345
307
  const timestamp2 = stringValue(payload?.timestamp) ?? stringValue(value?.timestamp);
346
- const threadSource = payload?.thread_source;
347
- skipSession = typeof threadSource !== "string" && JSON.stringify(threadSource).toLowerCase().includes("sub");
308
+ const sessionSource = JSON.stringify([payload?.thread_source, payload?.source]).toLowerCase();
309
+ skipSession = sessionSource.includes("subagent");
348
310
  previousTotal = "";
349
311
  if (timestamp2 != null && !skipSession) {
350
312
  sessions.set(sessionId, { id: `codex:${sessionId}`, source: "codex", date: localDate(timestamp2) });
@@ -380,8 +342,8 @@ async function loadCodexUsage() {
380
342
  model,
381
343
  sessionId: `codex:${sessionId}`,
382
344
  ...tokens,
383
- costUsd: estimateCost(model, tokens),
384
- cacheSavingsUsd: estimateCacheSavings(model, cached)
345
+ costUsd: pricing.estimateCost(provider, model, tokens),
346
+ cacheSavingsUsd: pricing.estimateCacheSavings(provider, model, tokens)
385
347
  });
386
348
  });
387
349
  }
@@ -393,7 +355,7 @@ import { stat as stat2 } from "fs/promises";
393
355
  import { homedir as homedir3 } from "os";
394
356
  import { basename, dirname, join as join3 } from "path";
395
357
  import { countTokens } from "gpt-tokenizer/encoding/o200k_base";
396
- async function loadCursorUsage() {
358
+ async function loadCursorUsage(pricing) {
397
359
  const root = process.env.CURSOR_CONFIG_DIR ?? join3(homedir3(), ".cursor");
398
360
  const files = await globFiles(join3(root, "projects"), "**/agent-transcripts/**/*.jsonl");
399
361
  const fallbackModel = await readConfiguredModel(join3(root, "cli-config.json"));
@@ -426,7 +388,7 @@ async function loadCursorUsage() {
426
388
  model,
427
389
  sessionId,
428
390
  ...tokens,
429
- costUsd: estimateCost(model, tokens),
391
+ costUsd: pricing.estimateCost("cursor", model, tokens),
430
392
  cacheSavingsUsd: 0
431
393
  });
432
394
  sessions.push({ id: sessionId, source: "cursor", date });
@@ -464,7 +426,7 @@ async function readConfiguredModel(path) {
464
426
  // src/data/sources/gemini.ts
465
427
  import { homedir as homedir4 } from "os";
466
428
  import { basename as basename2, join as join4 } from "path";
467
- async function loadGeminiUsage() {
429
+ async function loadGeminiUsage(pricing) {
468
430
  const root = process.env.GEMINI_HOME ?? join4(homedir4(), ".gemini");
469
431
  const files = [
470
432
  ...await globFiles(join4(root, "tmp"), "**/chats/*.json"),
@@ -510,8 +472,8 @@ async function loadGeminiUsage() {
510
472
  model,
511
473
  sessionId,
512
474
  ...tokens,
513
- costUsd: estimateCost(model, tokens),
514
- cacheSavingsUsd: estimateCacheSavings(model, cached)
475
+ costUsd: pricing.estimateCost("google", model, tokens),
476
+ cacheSavingsUsd: pricing.estimateCacheSavings("google", model, tokens)
515
477
  });
516
478
  sessions.set(sessionId, { id: sessionId, source: "gemini", date });
517
479
  }
@@ -566,7 +528,7 @@ function parseGeminiConversation(text, fallbackId = "session") {
566
528
  // src/data/sources/grok.ts
567
529
  import { homedir as homedir5 } from "os";
568
530
  import { basename as basename3, dirname as dirname2, join as join5 } from "path";
569
- async function loadGrokUsage() {
531
+ async function loadGrokUsage(pricing) {
570
532
  const root = process.env.GROK_HOME ?? join5(homedir5(), ".grok");
571
533
  const files = await globFiles(join5(root, "sessions"), "**/updates.jsonl");
572
534
  const records = [];
@@ -584,13 +546,7 @@ async function loadGrokUsage() {
584
546
  const date = localDate(timestamp);
585
547
  const models = asObject(usage.modelUsage);
586
548
  const entries = models == null ? [["grok-build", usage]] : Object.entries(models);
587
- const cost = numberValue(usage.costUsdTicks) / 1e9;
588
- const tokenWeights = entries.map(([, entry]) => {
589
- const tokens = asObject(entry);
590
- return numberValue(tokens?.inputTokens) + numberValue(tokens?.outputTokens);
591
- });
592
- const totalWeight = tokenWeights.reduce((sum, value2) => sum + value2, 0);
593
- entries.forEach(([model, entry], index) => {
549
+ entries.forEach(([model, entry]) => {
594
550
  const raw = asObject(entry);
595
551
  const cached = numberValue(raw?.cachedReadTokens);
596
552
  const input = Math.max(0, numberValue(raw?.inputTokens) - cached);
@@ -600,15 +556,14 @@ async function loadGrokUsage() {
600
556
  cacheCreationTokens: 0,
601
557
  cacheReadTokens: cached
602
558
  };
603
- const allocatedCost = totalWeight > 0 ? cost * (tokenWeights[index] / totalWeight) : 0;
604
559
  records.push({
605
560
  date,
606
561
  source: "grok",
607
562
  model,
608
563
  sessionId,
609
564
  ...tokens,
610
- costUsd: allocatedCost || estimateCost(model, tokens),
611
- cacheSavingsUsd: estimateCacheSavings(model, cached)
565
+ costUsd: pricing.estimateCost("xai", model, tokens),
566
+ cacheSavingsUsd: pricing.estimateCacheSavings("xai", model, tokens)
612
567
  });
613
568
  });
614
569
  sessions.set(sessionId, { id: sessionId, source: "grok", date });
@@ -620,7 +575,7 @@ async function loadGrokUsage() {
620
575
  // src/data/sources/opencode.ts
621
576
  import { homedir as homedir6 } from "os";
622
577
  import { join as join6 } from "path";
623
- async function loadOpenCodeUsage() {
578
+ async function loadOpenCodeUsage(pricing) {
624
579
  const root = process.env.OPENCODE_DATA_DIR ?? join6(homedir6(), ".local", "share", "opencode");
625
580
  const files = await globFiles(join6(root, "storage", "message"), "**/*.json");
626
581
  const seen = new Set;
@@ -631,6 +586,7 @@ async function loadOpenCodeUsage() {
631
586
  const message = asObject(await Bun.file(path).json());
632
587
  const id = stringValue(message?.id);
633
588
  const model = stringValue(message?.modelID);
589
+ const provider = stringValue(message?.providerID) ?? "opencode";
634
590
  const tokensValue = asObject(message?.tokens);
635
591
  if (id == null || model == null || tokensValue == null || seen.has(id))
636
592
  continue;
@@ -648,15 +604,14 @@ async function loadOpenCodeUsage() {
648
604
  const date = localDate(numberValue(time?.created) || Date.now());
649
605
  const rawSession = stringValue(message?.sessionID) ?? id;
650
606
  const sessionId = `opencode:${rawSession}`;
651
- const localCost = typeof message?.cost === "number" ? message.cost : null;
652
607
  records.push({
653
608
  date,
654
609
  source: "opencode",
655
610
  model,
656
611
  sessionId,
657
612
  ...tokens,
658
- costUsd: localCost ?? estimateCost(model, tokens),
659
- cacheSavingsUsd: estimateCacheSavings(model, tokens.cacheReadTokens)
613
+ costUsd: pricing.estimateCost(provider, model, tokens),
614
+ cacheSavingsUsd: pricing.estimateCacheSavings(provider, model, tokens)
660
615
  });
661
616
  sessions.set(sessionId, { id: sessionId, source: "opencode", date });
662
617
  } catch {
@@ -666,6 +621,155 @@ async function loadOpenCodeUsage() {
666
621
  return { records, sessions: [...sessions.values()], files: files.length };
667
622
  }
668
623
 
624
+ // src/data/pricing.ts
625
+ var MODELS_DEV_API_URL = process.env.MODELS_DEV_API_URL ?? "https://models.dev/api.json";
626
+ var TOKENS_PER_MILLION = 1e6;
627
+ async function loadPricingCatalog() {
628
+ const response = await fetch(MODELS_DEV_API_URL);
629
+ if (!response.ok)
630
+ throw new Error(`models.dev pricing request failed (${response.status})`);
631
+ return createPricingCatalog(await response.json());
632
+ }
633
+ function createPricingCatalog(value) {
634
+ const providers = parseProviders(value);
635
+ const resolve2 = (provider, model, tokens) => {
636
+ const direct = resolveFromProvider(providers.get(normalizeId(provider)), provider, model);
637
+ const resolved = direct ?? resolveUnambiguousModel(providers, model);
638
+ return resolved == null ? null : applyContextTier(resolved.cost, tokens);
639
+ };
640
+ return {
641
+ resolve: resolve2,
642
+ estimateCost(provider, model, tokens) {
643
+ const cost = resolve2(provider, model, tokens);
644
+ if (cost == null)
645
+ return 0;
646
+ const input = tokens.inputTokens * cost.input;
647
+ const output = tokens.outputTokens * cost.output;
648
+ const cacheRead = tokens.cacheReadTokens * (cost.cache_read ?? cost.input);
649
+ const cacheWrite = tokens.cacheCreationTokens * (cost.cache_write ?? cost.input);
650
+ return (input + output + cacheRead + cacheWrite) / TOKENS_PER_MILLION;
651
+ },
652
+ estimateCacheSavings(provider, model, tokens) {
653
+ const cost = resolve2(provider, model, tokens);
654
+ if (cost == null)
655
+ return 0;
656
+ return Math.max(0, tokens.cacheReadTokens * (cost.input - (cost.cache_read ?? cost.input)) / TOKENS_PER_MILLION);
657
+ }
658
+ };
659
+ }
660
+ function parseProviders(value) {
661
+ const providers = new Map;
662
+ if (!isObject(value))
663
+ throw new Error("models.dev returned an invalid catalog");
664
+ for (const [providerKey, providerValue] of Object.entries(value)) {
665
+ if (!isObject(providerValue) || !isObject(providerValue.models))
666
+ continue;
667
+ const models = new Map;
668
+ for (const [modelKey, modelValue] of Object.entries(providerValue.models)) {
669
+ const model = parseModel(modelKey, modelValue);
670
+ if (model == null)
671
+ continue;
672
+ models.set(normalizeId(modelKey), model);
673
+ models.set(normalizeId(model.id), model);
674
+ }
675
+ providers.set(normalizeId(providerKey), models);
676
+ if (typeof providerValue.id === "string")
677
+ providers.set(normalizeId(providerValue.id), models);
678
+ }
679
+ if (providers.size === 0)
680
+ throw new Error("models.dev returned an empty catalog");
681
+ return providers;
682
+ }
683
+ function parseModel(modelKey, value) {
684
+ if (!isObject(value) || !isObject(value.cost))
685
+ return null;
686
+ const input = finiteNumber2(value.cost.input);
687
+ const output = finiteNumber2(value.cost.output);
688
+ if (input == null || output == null)
689
+ return null;
690
+ return {
691
+ id: typeof value.id === "string" ? value.id : modelKey,
692
+ cost: {
693
+ input,
694
+ output,
695
+ cache_read: finiteNumber2(value.cost.cache_read) ?? undefined,
696
+ cache_write: finiteNumber2(value.cost.cache_write) ?? undefined,
697
+ tiers: parseTiers(value.cost.tiers)
698
+ }
699
+ };
700
+ }
701
+ function parseTiers(value) {
702
+ if (!Array.isArray(value))
703
+ return [];
704
+ return value.flatMap((entry) => {
705
+ if (!isObject(entry) || !isObject(entry.tier) || entry.tier.type !== "context")
706
+ return [];
707
+ const size = finiteNumber2(entry.tier.size);
708
+ if (size == null)
709
+ return [];
710
+ return [{
711
+ input: finiteNumber2(entry.input) ?? undefined,
712
+ output: finiteNumber2(entry.output) ?? undefined,
713
+ cache_read: finiteNumber2(entry.cache_read) ?? undefined,
714
+ cache_write: finiteNumber2(entry.cache_write) ?? undefined,
715
+ tier: { type: "context", size }
716
+ }];
717
+ }).sort((left, right) => left.tier.size - right.tier.size);
718
+ }
719
+ function resolveFromProvider(models, provider, model) {
720
+ if (models == null)
721
+ return null;
722
+ for (const candidate of modelCandidates(provider, model)) {
723
+ const match = models.get(candidate);
724
+ if (match != null)
725
+ return match;
726
+ }
727
+ return null;
728
+ }
729
+ function resolveUnambiguousModel(providers, model) {
730
+ const matches = new Map;
731
+ const candidate = normalizeId(model);
732
+ for (const models of new Set(providers.values())) {
733
+ const match = models.get(candidate);
734
+ if (match != null)
735
+ matches.set(costSignature(match.cost), match);
736
+ }
737
+ return matches.size === 1 ? [...matches.values()][0] : null;
738
+ }
739
+ function applyContextTier(cost, tokens) {
740
+ if (tokens == null)
741
+ return cost;
742
+ const contextTokens = tokens.inputTokens + tokens.cacheCreationTokens + tokens.cacheReadTokens;
743
+ const tier = cost.tiers.findLast((candidate) => contextTokens > candidate.tier.size);
744
+ if (tier == null)
745
+ return cost;
746
+ return {
747
+ ...cost,
748
+ input: tier.input ?? cost.input,
749
+ output: tier.output ?? cost.output,
750
+ cache_read: tier.cache_read ?? cost.cache_read,
751
+ cache_write: tier.cache_write ?? cost.cache_write
752
+ };
753
+ }
754
+ function modelCandidates(provider, model) {
755
+ const normalizedProvider = normalizeId(provider);
756
+ const normalizedModel = normalizeId(model);
757
+ const prefix = `${normalizedProvider}/`;
758
+ return normalizedModel.startsWith(prefix) ? [normalizedModel, normalizedModel.slice(prefix.length)] : [normalizedModel];
759
+ }
760
+ function costSignature(cost) {
761
+ return JSON.stringify(cost);
762
+ }
763
+ function normalizeId(value) {
764
+ return value.trim().toLowerCase();
765
+ }
766
+ function finiteNumber2(value) {
767
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
768
+ }
769
+ function isObject(value) {
770
+ return typeof value === "object" && value != null && !Array.isArray(value);
771
+ }
772
+
669
773
  // src/data/load.ts
670
774
  var SOURCES = [
671
775
  { id: "claude", load: loadClaudeUsage },
@@ -676,12 +780,13 @@ var SOURCES = [
676
780
  { id: "grok", load: loadGrokUsage }
677
781
  ];
678
782
  async function loadUsageDataset(onProgress) {
783
+ const pricing = await loadPricingCatalog();
679
784
  let completed = 0;
680
785
  const results = await Promise.all(SOURCES.map(async (source) => {
681
786
  const common = { source: source.id, label: SOURCE_META[source.id].label, total: SOURCES.length };
682
787
  onProgress?.({ ...common, status: "scanning", completed });
683
788
  try {
684
- const value = await source.load();
789
+ const value = await source.load(pricing);
685
790
  completed += 1;
686
791
  onProgress?.({
687
792
  ...common,
@@ -1032,7 +1137,7 @@ function Summary({
1032
1137
  }),
1033
1138
  /* @__PURE__ */ jsx5("text", {
1034
1139
  fg: theme.muted,
1035
- children: `${dashboard.sessions.toLocaleString("en-US")} sessions \xB7 API estimate`
1140
+ children: `${dashboard.sessions.toLocaleString("en-US")} sessions \xB7 models.dev estimate`
1036
1141
  }),
1037
1142
  /* @__PURE__ */ jsx5("box", {
1038
1143
  height: 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dropalltables/yacu",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "description": "Local coding-agent usage dashboard for the terminal",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,8 +51,7 @@
51
51
  "@opentui/core": "^0.5.9",
52
52
  "@opentui/react": "^0.5.9",
53
53
  "gpt-tokenizer": "4.0.0",
54
- "react": "^19.2.8",
55
- "tokentally": "^0.1.4"
54
+ "react": "^19.2.8"
56
55
  },
57
56
  "devDependencies": {
58
57
  "@types/bun": "^1.4.0",
package/src/data/load.ts CHANGED
@@ -5,11 +5,12 @@ import { loadCursorUsage } from "./sources/cursor"
5
5
  import { loadGeminiUsage } from "./sources/gemini"
6
6
  import { loadGrokUsage } from "./sources/grok"
7
7
  import { loadOpenCodeUsage } from "./sources/opencode"
8
+ import { loadPricingCatalog, type PricingCatalog } from "./pricing"
8
9
  import type { ScanProgressHandler, SourceLoadResult } from "./types"
9
10
 
10
11
  type SourceLoader = {
11
12
  id: SourceId
12
- load: () => Promise<SourceLoadResult>
13
+ load: (pricing: PricingCatalog) => Promise<SourceLoadResult>
13
14
  }
14
15
 
15
16
  const SOURCES: SourceLoader[] = [
@@ -22,12 +23,13 @@ const SOURCES: SourceLoader[] = [
22
23
  ]
23
24
 
24
25
  export async function loadUsageDataset(onProgress?: ScanProgressHandler): Promise<UsageDataset> {
26
+ const pricing = await loadPricingCatalog()
25
27
  let completed = 0
26
28
  const results = await Promise.all(SOURCES.map(async (source) => {
27
29
  const common = { source: source.id, label: SOURCE_META[source.id].label, total: SOURCES.length }
28
30
  onProgress?.({ ...common, status: "scanning", completed })
29
31
  try {
30
- const value = await source.load()
32
+ const value = await source.load(pricing)
31
33
  completed += 1
32
34
  onProgress?.({
33
35
  ...common,
@@ -1,22 +1,86 @@
1
1
  import { describe, expect, test } from "bun:test"
2
- import { estimateCacheSavings, estimateCost, resolvePricing } from "./pricing"
2
+ import { createPricingCatalog } from "./pricing"
3
3
 
4
- describe("pricing", () => {
5
- test("matches supported model families", () => {
6
- expect(resolvePricing("claude-opus-4-1")).not.toBeNull()
7
- expect(resolvePricing("gpt-5-codex")).not.toBeNull()
8
- expect(resolvePricing("unknown-local-model")).toBeNull()
4
+ const pricing = createPricingCatalog({
5
+ openai: {
6
+ id: "openai",
7
+ models: {
8
+ "gpt-example": {
9
+ id: "gpt-example",
10
+ cost: {
11
+ input: 2,
12
+ output: 8,
13
+ cache_read: 0.2,
14
+ cache_write: 2.5,
15
+ tiers: [{
16
+ input: 4,
17
+ output: 12,
18
+ cache_read: 0.4,
19
+ cache_write: 5,
20
+ tier: { type: "context", size: 200_000 },
21
+ }],
22
+ },
23
+ },
24
+ },
25
+ },
26
+ gateway: {
27
+ id: "gateway",
28
+ models: {
29
+ "gpt-example": {
30
+ id: "gpt-example",
31
+ cost: { input: 3, output: 9 },
32
+ },
33
+ "shared-model": {
34
+ id: "shared-model",
35
+ cost: { input: 1, output: 2 },
36
+ },
37
+ },
38
+ },
39
+ another: {
40
+ id: "another",
41
+ models: {
42
+ "shared-model": {
43
+ id: "shared-model",
44
+ cost: { input: 1, output: 2 },
45
+ },
46
+ },
47
+ },
48
+ })
49
+
50
+ describe("models.dev pricing", () => {
51
+ test("resolves exact provider and model identifiers", () => {
52
+ expect(pricing.resolve("openai", "gpt-example")?.input).toBe(2)
53
+ expect(pricing.resolve("openai", "openai/gpt-example")?.input).toBe(2)
54
+ expect(pricing.resolve("missing", "shared-model")?.input).toBe(1)
55
+ expect(pricing.resolve("missing", "gpt-example")).toBeNull()
56
+ expect(pricing.resolve("openai", "unknown-model")).toBeNull()
9
57
  })
10
58
 
11
- test("estimates cost and cache savings", () => {
12
- const cost = estimateCost("gpt-5-codex", {
13
- inputTokens: 1_000_000,
59
+ test("uses input, output, cache read, and cache write rates", () => {
60
+ const cost = pricing.estimateCost("openai", "gpt-example", {
61
+ inputTokens: 50_000,
62
+ outputTokens: 100_000,
63
+ cacheCreationTokens: 50_000,
64
+ cacheReadTokens: 50_000,
65
+ })
66
+
67
+ expect(cost).toBeCloseTo(1.035)
68
+ expect(pricing.estimateCacheSavings("openai", "gpt-example", {
69
+ inputTokens: 0,
14
70
  outputTokens: 0,
15
71
  cacheCreationTokens: 0,
72
+ cacheReadTokens: 1_000_000,
73
+ })).toBeCloseTo(3.6)
74
+ })
75
+
76
+ test("applies context pricing tiers", () => {
77
+ const cost = pricing.estimateCost("openai", "gpt-example", {
78
+ inputTokens: 200_001,
79
+ outputTokens: 1_000_000,
80
+ cacheCreationTokens: 0,
16
81
  cacheReadTokens: 0,
17
82
  })
18
83
 
19
- expect(cost).toBeCloseTo(1.75)
20
- expect(estimateCacheSavings("gpt-5-codex", 1_000_000)).toBeCloseTo(1.575)
84
+ expect(cost).toBeCloseTo(12.800004)
21
85
  })
22
86
  })
@@ -1,10 +1,3 @@
1
- import {
2
- estimateUsdCost,
3
- normalizeTokenUsage,
4
- pricingFromUsdPerMillion,
5
- type Pricing,
6
- } from "tokentally"
7
-
8
1
  export type TokenParts = {
9
2
  inputTokens: number
10
3
  outputTokens: number
@@ -12,38 +5,179 @@ export type TokenParts = {
12
5
  cacheReadTokens: number
13
6
  }
14
7
 
15
- const PRICING: Array<[RegExp, Pricing]> = [
16
- [/claude.*opus/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 5, outputUsdPerMillion: 25, cachedInputUsdPerMillion: 0.5, cacheCreationInputUsdPerMillion: 6.25 })],
17
- [/claude.*sonnet/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 3, outputUsdPerMillion: 15, cachedInputUsdPerMillion: 0.3, cacheCreationInputUsdPerMillion: 3.75 })],
18
- [/claude.*haiku/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 1, outputUsdPerMillion: 5, cachedInputUsdPerMillion: 0.1, cacheCreationInputUsdPerMillion: 1.25 })],
19
- [/(^|\/)gpt-5|codex/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 1.75, outputUsdPerMillion: 14, cachedInputUsdPerMillion: 0.175 })],
20
- [/grok|composer/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 2, outputUsdPerMillion: 10, cachedInputUsdPerMillion: 0.2 })],
21
- [/deepseek/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 0.27, outputUsdPerMillion: 1.1, cachedInputUsdPerMillion: 0.07 })],
22
- [/gemini-3\.1-pro/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 2, outputUsdPerMillion: 12, cachedInputUsdPerMillion: 0.2 })],
23
- [/gemini-2\.5-pro/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 1.25, outputUsdPerMillion: 10, cachedInputUsdPerMillion: 0.125 })],
24
- [/gemini-3\.[67]-flash/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 0.75, outputUsdPerMillion: 3.75, cachedInputUsdPerMillion: 0.075 })],
25
- [/gemini-3\.5-flash/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 1.5, outputUsdPerMillion: 9, cachedInputUsdPerMillion: 0.15 })],
26
- [/gemini-3\.1-flash-lite/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 0.25, outputUsdPerMillion: 1.5, cachedInputUsdPerMillion: 0.025 })],
27
- [/gemini.*flash/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 0.3, outputUsdPerMillion: 2.5, cachedInputUsdPerMillion: 0.03 })],
28
- ]
29
-
30
- export function resolvePricing(model: string): Pricing | null {
31
- return PRICING.find(([pattern]) => pattern.test(model))?.[1] ?? null
32
- }
33
-
34
- export function estimateCost(model: string, tokens: TokenParts): number {
35
- const usage = normalizeTokenUsage({
36
- inputTokens: tokens.inputTokens,
37
- outputTokens: tokens.outputTokens,
38
- cachedInputTokens: tokens.cacheReadTokens,
39
- cacheCreationInputTokens: tokens.cacheCreationTokens,
40
- })
41
- return estimateUsdCost({ usage, pricing: resolvePricing(model) })?.totalUsd ?? 0
42
- }
43
-
44
- export function estimateCacheSavings(model: string, cachedReadTokens: number): number {
45
- const pricing = resolvePricing(model)
46
- if (pricing == null) return 0
47
- const cachedRate = pricing.cachedInputUsdPerToken ?? pricing.inputUsdPerToken
48
- return Math.max(0, cachedReadTokens * (pricing.inputUsdPerToken - cachedRate))
8
+ type ModelsDevCost = {
9
+ input: number
10
+ output: number
11
+ cache_read?: number
12
+ cache_write?: number
13
+ tiers: Array<{
14
+ input?: number
15
+ output?: number
16
+ cache_read?: number
17
+ cache_write?: number
18
+ tier: { type: "context", size: number }
19
+ }>
20
+ }
21
+
22
+ type ModelsDevModel = {
23
+ id: string
24
+ cost: ModelsDevCost
25
+ }
26
+
27
+ type ProviderModels = Map<string, ModelsDevModel>
28
+
29
+ export type PricingCatalog = {
30
+ resolve: (provider: string, model: string, tokens?: TokenParts) => ModelsDevCost | null
31
+ estimateCost: (provider: string, model: string, tokens: TokenParts) => number
32
+ estimateCacheSavings: (provider: string, model: string, tokens: TokenParts) => number
33
+ }
34
+
35
+ const MODELS_DEV_API_URL = process.env.MODELS_DEV_API_URL ?? "https://models.dev/api.json"
36
+ const TOKENS_PER_MILLION = 1_000_000
37
+
38
+ export async function loadPricingCatalog(): Promise<PricingCatalog> {
39
+ const response = await fetch(MODELS_DEV_API_URL)
40
+ if (!response.ok) throw new Error(`models.dev pricing request failed (${response.status})`)
41
+ return createPricingCatalog(await response.json())
42
+ }
43
+
44
+ export function createPricingCatalog(value: unknown): PricingCatalog {
45
+ const providers = parseProviders(value)
46
+
47
+ const resolve = (provider: string, model: string, tokens?: TokenParts): ModelsDevCost | null => {
48
+ const direct = resolveFromProvider(providers.get(normalizeId(provider)), provider, model)
49
+ const resolved = direct ?? resolveUnambiguousModel(providers, model)
50
+ return resolved == null ? null : applyContextTier(resolved.cost, tokens)
51
+ }
52
+
53
+ return {
54
+ resolve,
55
+ estimateCost(provider, model, tokens) {
56
+ const cost = resolve(provider, model, tokens)
57
+ if (cost == null) return 0
58
+ const input = tokens.inputTokens * cost.input
59
+ const output = tokens.outputTokens * cost.output
60
+ const cacheRead = tokens.cacheReadTokens * (cost.cache_read ?? cost.input)
61
+ const cacheWrite = tokens.cacheCreationTokens * (cost.cache_write ?? cost.input)
62
+ return (input + output + cacheRead + cacheWrite) / TOKENS_PER_MILLION
63
+ },
64
+ estimateCacheSavings(provider, model, tokens) {
65
+ const cost = resolve(provider, model, tokens)
66
+ if (cost == null) return 0
67
+ return Math.max(0, tokens.cacheReadTokens * (cost.input - (cost.cache_read ?? cost.input)) / TOKENS_PER_MILLION)
68
+ },
69
+ }
70
+ }
71
+
72
+ function parseProviders(value: unknown): Map<string, ProviderModels> {
73
+ const providers = new Map<string, ProviderModels>()
74
+ if (!isObject(value)) throw new Error("models.dev returned an invalid catalog")
75
+
76
+ for (const [providerKey, providerValue] of Object.entries(value)) {
77
+ if (!isObject(providerValue) || !isObject(providerValue.models)) continue
78
+ const models: ProviderModels = new Map()
79
+ for (const [modelKey, modelValue] of Object.entries(providerValue.models)) {
80
+ const model = parseModel(modelKey, modelValue)
81
+ if (model == null) continue
82
+ models.set(normalizeId(modelKey), model)
83
+ models.set(normalizeId(model.id), model)
84
+ }
85
+ providers.set(normalizeId(providerKey), models)
86
+ if (typeof providerValue.id === "string") providers.set(normalizeId(providerValue.id), models)
87
+ }
88
+
89
+ if (providers.size === 0) throw new Error("models.dev returned an empty catalog")
90
+ return providers
91
+ }
92
+
93
+ function parseModel(modelKey: string, value: unknown): ModelsDevModel | null {
94
+ if (!isObject(value) || !isObject(value.cost)) return null
95
+ const input = finiteNumber(value.cost.input)
96
+ const output = finiteNumber(value.cost.output)
97
+ if (input == null || output == null) return null
98
+
99
+ return {
100
+ id: typeof value.id === "string" ? value.id : modelKey,
101
+ cost: {
102
+ input,
103
+ output,
104
+ cache_read: finiteNumber(value.cost.cache_read) ?? undefined,
105
+ cache_write: finiteNumber(value.cost.cache_write) ?? undefined,
106
+ tiers: parseTiers(value.cost.tiers),
107
+ },
108
+ }
109
+ }
110
+
111
+ function parseTiers(value: unknown): ModelsDevCost["tiers"] {
112
+ if (!Array.isArray(value)) return []
113
+ return value.flatMap((entry) => {
114
+ if (!isObject(entry) || !isObject(entry.tier) || entry.tier.type !== "context") return []
115
+ const size = finiteNumber(entry.tier.size)
116
+ if (size == null) return []
117
+ return [{
118
+ input: finiteNumber(entry.input) ?? undefined,
119
+ output: finiteNumber(entry.output) ?? undefined,
120
+ cache_read: finiteNumber(entry.cache_read) ?? undefined,
121
+ cache_write: finiteNumber(entry.cache_write) ?? undefined,
122
+ tier: { type: "context" as const, size },
123
+ }]
124
+ }).sort((left, right) => left.tier.size - right.tier.size)
125
+ }
126
+
127
+ function resolveFromProvider(models: ProviderModels | undefined, provider: string, model: string): ModelsDevModel | null {
128
+ if (models == null) return null
129
+ for (const candidate of modelCandidates(provider, model)) {
130
+ const match = models.get(candidate)
131
+ if (match != null) return match
132
+ }
133
+ return null
134
+ }
135
+
136
+ function resolveUnambiguousModel(providers: Map<string, ProviderModels>, model: string): ModelsDevModel | null {
137
+ const matches = new Map<string, ModelsDevModel>()
138
+ const candidate = normalizeId(model)
139
+ for (const models of new Set(providers.values())) {
140
+ const match = models.get(candidate)
141
+ if (match != null) matches.set(costSignature(match.cost), match)
142
+ }
143
+ return matches.size === 1 ? [...matches.values()][0]! : null
144
+ }
145
+
146
+ function applyContextTier(cost: ModelsDevCost, tokens?: TokenParts): ModelsDevCost {
147
+ if (tokens == null) return cost
148
+ const contextTokens = tokens.inputTokens + tokens.cacheCreationTokens + tokens.cacheReadTokens
149
+ const tier = cost.tiers.findLast((candidate) => contextTokens > candidate.tier.size)
150
+ if (tier == null) return cost
151
+ return {
152
+ ...cost,
153
+ input: tier.input ?? cost.input,
154
+ output: tier.output ?? cost.output,
155
+ cache_read: tier.cache_read ?? cost.cache_read,
156
+ cache_write: tier.cache_write ?? cost.cache_write,
157
+ }
158
+ }
159
+
160
+ function modelCandidates(provider: string, model: string): string[] {
161
+ const normalizedProvider = normalizeId(provider)
162
+ const normalizedModel = normalizeId(model)
163
+ const prefix = `${normalizedProvider}/`
164
+ return normalizedModel.startsWith(prefix)
165
+ ? [normalizedModel, normalizedModel.slice(prefix.length)]
166
+ : [normalizedModel]
167
+ }
168
+
169
+ function costSignature(cost: ModelsDevCost): string {
170
+ return JSON.stringify(cost)
171
+ }
172
+
173
+ function normalizeId(value: string): string {
174
+ return value.trim().toLowerCase()
175
+ }
176
+
177
+ function finiteNumber(value: unknown): number | null {
178
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null
179
+ }
180
+
181
+ function isObject(value: unknown): value is Record<string, unknown> {
182
+ return typeof value === "object" && value != null && !Array.isArray(value)
49
183
  }
@@ -2,7 +2,7 @@ import { homedir } from "node:os"
2
2
  import { join, resolve } from "node:path"
3
3
  import { localDate } from "../../domain/dates"
4
4
  import type { UsageRecord, UsageSession } from "../../domain/types"
5
- import { estimateCacheSavings, estimateCost } from "../pricing"
5
+ import type { PricingCatalog } from "../pricing"
6
6
  import { asObject, forEachJsonLine, globFiles, numberValue, stringValue } from "../jsonl"
7
7
  import type { SourceLoadResult } from "../types"
8
8
 
@@ -18,7 +18,7 @@ type ClaudeEntry = {
18
18
  cacheReadTokens: number
19
19
  }
20
20
 
21
- export async function loadClaudeUsage(): Promise<SourceLoadResult> {
21
+ export async function loadClaudeUsage(pricing: PricingCatalog): Promise<SourceLoadResult> {
22
22
  const files = [...new Set((await Promise.all(
23
23
  claudeRoots().map((root) => globFiles(
24
24
  join(root, "projects"),
@@ -57,8 +57,8 @@ export async function loadClaudeUsage(): Promise<SourceLoadResult> {
57
57
  model: entry.model,
58
58
  sessionId,
59
59
  ...tokens,
60
- costUsd: estimateCost(entry.model, tokens),
61
- cacheSavingsUsd: estimateCacheSavings(entry.model, entry.cacheReadTokens),
60
+ costUsd: pricing.estimateCost("anthropic", entry.model, tokens),
61
+ cacheSavingsUsd: pricing.estimateCacheSavings("anthropic", entry.model, tokens),
62
62
  })
63
63
 
64
64
  if (sessionId != null) {
@@ -2,11 +2,11 @@ import { homedir } from "node:os"
2
2
  import { join } from "node:path"
3
3
  import { localDate } from "../../domain/dates"
4
4
  import type { UsageRecord, UsageSession } from "../../domain/types"
5
- import { estimateCacheSavings, estimateCost } from "../pricing"
5
+ import type { PricingCatalog } from "../pricing"
6
6
  import { asObject, forEachJsonLine, globFiles, numberValue, stringValue } from "../jsonl"
7
7
  import type { SourceLoadResult } from "../types"
8
8
 
9
- export async function loadCodexUsage(): Promise<SourceLoadResult> {
9
+ export async function loadCodexUsage(pricing: PricingCatalog): Promise<SourceLoadResult> {
10
10
  const root = join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "sessions")
11
11
  const files = await globFiles(root, "**/*.jsonl")
12
12
  const records: UsageRecord[] = []
@@ -15,6 +15,7 @@ export async function loadCodexUsage(): Promise<SourceLoadResult> {
15
15
  for (const path of files) {
16
16
  let sessionId = path
17
17
  let model = "codex"
18
+ let provider = "openai"
18
19
  let skipSession = false
19
20
  let previousTotal = ""
20
21
 
@@ -25,9 +26,10 @@ export async function loadCodexUsage(): Promise<SourceLoadResult> {
25
26
 
26
27
  if (type === "session_meta") {
27
28
  sessionId = stringValue(payload?.id) ?? sessionId
29
+ provider = stringValue(payload?.model_provider) ?? provider
28
30
  const timestamp = stringValue(payload?.timestamp) ?? stringValue(value?.timestamp)
29
- const threadSource = payload?.thread_source
30
- skipSession = typeof threadSource !== "string" && JSON.stringify(threadSource).toLowerCase().includes("sub")
31
+ const sessionSource = JSON.stringify([payload?.thread_source, payload?.source]).toLowerCase()
32
+ skipSession = sessionSource.includes("subagent")
31
33
  previousTotal = ""
32
34
  if (timestamp != null && !skipSession) {
33
35
  sessions.set(sessionId, { id: `codex:${sessionId}`, source: "codex", date: localDate(timestamp) })
@@ -63,8 +65,8 @@ export async function loadCodexUsage(): Promise<SourceLoadResult> {
63
65
  model,
64
66
  sessionId: `codex:${sessionId}`,
65
67
  ...tokens,
66
- costUsd: estimateCost(model, tokens),
67
- cacheSavingsUsd: estimateCacheSavings(model, cached),
68
+ costUsd: pricing.estimateCost(provider, model, tokens),
69
+ cacheSavingsUsd: pricing.estimateCacheSavings(provider, model, tokens),
68
70
  })
69
71
  })
70
72
  }
@@ -4,7 +4,7 @@ import { basename, dirname, join } from "node:path"
4
4
  import { countTokens } from "gpt-tokenizer/encoding/o200k_base"
5
5
  import { localDate } from "../../domain/dates"
6
6
  import type { UsageRecord, UsageSession } from "../../domain/types"
7
- import { estimateCost } from "../pricing"
7
+ import type { PricingCatalog } from "../pricing"
8
8
  import { asObject, forEachJsonLine, globFiles, stringValue } from "../jsonl"
9
9
  import type { SourceLoadResult } from "../types"
10
10
 
@@ -13,7 +13,7 @@ type TranscriptCount = {
13
13
  outputTokens: number
14
14
  }
15
15
 
16
- export async function loadCursorUsage(): Promise<SourceLoadResult> {
16
+ export async function loadCursorUsage(pricing: PricingCatalog): Promise<SourceLoadResult> {
17
17
  const root = process.env.CURSOR_CONFIG_DIR ?? join(homedir(), ".cursor")
18
18
  const files = await globFiles(join(root, "projects"), "**/agent-transcripts/**/*.jsonl")
19
19
  const fallbackModel = await readConfiguredModel(join(root, "cli-config.json"))
@@ -45,7 +45,7 @@ export async function loadCursorUsage(): Promise<SourceLoadResult> {
45
45
  model,
46
46
  sessionId,
47
47
  ...tokens,
48
- costUsd: estimateCost(model, tokens),
48
+ costUsd: pricing.estimateCost("cursor", model, tokens),
49
49
  cacheSavingsUsd: 0,
50
50
  })
51
51
  sessions.push({ id: sessionId, source: "cursor", date })
@@ -2,7 +2,7 @@ import { homedir } from "node:os"
2
2
  import { basename, join } from "node:path"
3
3
  import { localDate } from "../../domain/dates"
4
4
  import type { UsageRecord, UsageSession } from "../../domain/types"
5
- import { estimateCacheSavings, estimateCost } from "../pricing"
5
+ import type { PricingCatalog } from "../pricing"
6
6
  import { asObject, globFiles, numberValue, stringValue } from "../jsonl"
7
7
  import type { SourceLoadResult } from "../types"
8
8
 
@@ -12,7 +12,7 @@ type ParsedConversation = {
12
12
  messages: Array<Record<string, unknown>>
13
13
  }
14
14
 
15
- export async function loadGeminiUsage(): Promise<SourceLoadResult> {
15
+ export async function loadGeminiUsage(pricing: PricingCatalog): Promise<SourceLoadResult> {
16
16
  const root = process.env.GEMINI_HOME ?? join(homedir(), ".gemini")
17
17
  const files = [
18
18
  ...await globFiles(join(root, "tmp"), "**/chats/*.json"),
@@ -56,8 +56,8 @@ export async function loadGeminiUsage(): Promise<SourceLoadResult> {
56
56
  model,
57
57
  sessionId,
58
58
  ...tokens,
59
- costUsd: estimateCost(model, tokens),
60
- cacheSavingsUsd: estimateCacheSavings(model, cached),
59
+ costUsd: pricing.estimateCost("google", model, tokens),
60
+ cacheSavingsUsd: pricing.estimateCacheSavings("google", model, tokens),
61
61
  })
62
62
  sessions.set(sessionId, { id: sessionId, source: "gemini", date })
63
63
  }
@@ -2,11 +2,11 @@ import { homedir } from "node:os"
2
2
  import { basename, dirname, join } from "node:path"
3
3
  import { localDate } from "../../domain/dates"
4
4
  import type { UsageRecord, UsageSession } from "../../domain/types"
5
- import { estimateCacheSavings, estimateCost } from "../pricing"
5
+ import type { PricingCatalog } from "../pricing"
6
6
  import { asObject, forEachJsonLine, globFiles, numberValue, stringValue } from "../jsonl"
7
7
  import type { SourceLoadResult } from "../types"
8
8
 
9
- export async function loadGrokUsage(): Promise<SourceLoadResult> {
9
+ export async function loadGrokUsage(pricing: PricingCatalog): Promise<SourceLoadResult> {
10
10
  const root = process.env.GROK_HOME ?? join(homedir(), ".grok")
11
11
  const files = await globFiles(join(root, "sessions"), "**/updates.jsonl")
12
12
  const records: UsageRecord[] = []
@@ -24,14 +24,7 @@ export async function loadGrokUsage(): Promise<SourceLoadResult> {
24
24
  const date = localDate(timestamp)
25
25
  const models = asObject(usage.modelUsage)
26
26
  const entries = models == null ? [["grok-build", usage] as const] : Object.entries(models)
27
- const cost = numberValue(usage.costUsdTicks) / 1_000_000_000
28
- const tokenWeights = entries.map(([, entry]) => {
29
- const tokens = asObject(entry)
30
- return numberValue(tokens?.inputTokens) + numberValue(tokens?.outputTokens)
31
- })
32
- const totalWeight = tokenWeights.reduce((sum, value) => sum + value, 0)
33
-
34
- entries.forEach(([model, entry], index) => {
27
+ entries.forEach(([model, entry]) => {
35
28
  const raw = asObject(entry)
36
29
  const cached = numberValue(raw?.cachedReadTokens)
37
30
  const input = Math.max(0, numberValue(raw?.inputTokens) - cached)
@@ -41,15 +34,14 @@ export async function loadGrokUsage(): Promise<SourceLoadResult> {
41
34
  cacheCreationTokens: 0,
42
35
  cacheReadTokens: cached,
43
36
  }
44
- const allocatedCost = totalWeight > 0 ? cost * (tokenWeights[index]! / totalWeight) : 0
45
37
  records.push({
46
38
  date,
47
39
  source: "grok",
48
40
  model,
49
41
  sessionId,
50
42
  ...tokens,
51
- costUsd: allocatedCost || estimateCost(model, tokens),
52
- cacheSavingsUsd: estimateCacheSavings(model, cached),
43
+ costUsd: pricing.estimateCost("xai", model, tokens),
44
+ cacheSavingsUsd: pricing.estimateCacheSavings("xai", model, tokens),
53
45
  })
54
46
  })
55
47
  sessions.set(sessionId, { id: sessionId, source: "grok", date })
@@ -2,11 +2,11 @@ import { homedir } from "node:os"
2
2
  import { join } from "node:path"
3
3
  import { localDate } from "../../domain/dates"
4
4
  import type { UsageRecord, UsageSession } from "../../domain/types"
5
- import { estimateCacheSavings, estimateCost } from "../pricing"
5
+ import type { PricingCatalog } from "../pricing"
6
6
  import { asObject, globFiles, numberValue, stringValue } from "../jsonl"
7
7
  import type { SourceLoadResult } from "../types"
8
8
 
9
- export async function loadOpenCodeUsage(): Promise<SourceLoadResult> {
9
+ export async function loadOpenCodeUsage(pricing: PricingCatalog): Promise<SourceLoadResult> {
10
10
  const root = process.env.OPENCODE_DATA_DIR ?? join(homedir(), ".local", "share", "opencode")
11
11
  const files = await globFiles(join(root, "storage", "message"), "**/*.json")
12
12
  const seen = new Set<string>()
@@ -18,6 +18,7 @@ export async function loadOpenCodeUsage(): Promise<SourceLoadResult> {
18
18
  const message = asObject(await Bun.file(path).json())
19
19
  const id = stringValue(message?.id)
20
20
  const model = stringValue(message?.modelID)
21
+ const provider = stringValue(message?.providerID) ?? "opencode"
21
22
  const tokensValue = asObject(message?.tokens)
22
23
  if (id == null || model == null || tokensValue == null || seen.has(id)) continue
23
24
  seen.add(id)
@@ -33,15 +34,14 @@ export async function loadOpenCodeUsage(): Promise<SourceLoadResult> {
33
34
  const date = localDate(numberValue(time?.created) || Date.now())
34
35
  const rawSession = stringValue(message?.sessionID) ?? id
35
36
  const sessionId = `opencode:${rawSession}`
36
- const localCost = typeof message?.cost === "number" ? message.cost : null
37
37
  records.push({
38
38
  date,
39
39
  source: "opencode",
40
40
  model,
41
41
  sessionId,
42
42
  ...tokens,
43
- costUsd: localCost ?? estimateCost(model, tokens),
44
- cacheSavingsUsd: estimateCacheSavings(model, tokens.cacheReadTokens),
43
+ costUsd: pricing.estimateCost(provider, model, tokens),
44
+ cacheSavingsUsd: pricing.estimateCacheSavings(provider, model, tokens),
45
45
  })
46
46
  sessions.set(sessionId, { id: sessionId, source: "opencode", date })
47
47
  } catch {
@@ -25,7 +25,7 @@ export function Summary({
25
25
  return (
26
26
  <box flexDirection="column" width="100%" gap={1}>
27
27
  <text fg={theme.text}><strong>{total}</strong></text>
28
- <text fg={theme.muted}>{`${dashboard.sessions.toLocaleString("en-US")} sessions · API estimate`}</text>
28
+ <text fg={theme.muted}>{`${dashboard.sessions.toLocaleString("en-US")} sessions · models.dev estimate`}</text>
29
29
  <box height={1} />
30
30
  {dashboard.providers.map((provider) => {
31
31
  const meta = SOURCE_META[provider.source]