@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 +1 -1
- package/dist/yacu.js +175 -70
- package/package.json +2 -3
- package/src/data/load.ts +4 -2
- package/src/data/pricing.test.ts +75 -11
- package/src/data/pricing.ts +175 -41
- package/src/data/sources/claude.ts +4 -4
- package/src/data/sources/codex.ts +8 -6
- package/src/data/sources/cursor.ts +3 -3
- package/src/data/sources/gemini.ts +4 -4
- package/src/data/sources/grok.ts +5 -13
- package/src/data/sources/opencode.ts +5 -5
- package/src/tui/components/Summary.tsx +1 -1
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
|
|
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,
|
|
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
|
|
347
|
-
skipSession =
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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:
|
|
611
|
-
cacheSavingsUsd: estimateCacheSavings(model,
|
|
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:
|
|
659
|
-
cacheSavingsUsd: estimateCacheSavings(model, tokens
|
|
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
|
|
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": "
|
|
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,
|
package/src/data/pricing.test.ts
CHANGED
|
@@ -1,22 +1,86 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import {
|
|
2
|
+
import { createPricingCatalog } from "./pricing"
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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("
|
|
12
|
-
const cost = estimateCost("gpt-
|
|
13
|
-
inputTokens:
|
|
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(
|
|
20
|
-
expect(estimateCacheSavings("gpt-5-codex", 1_000_000)).toBeCloseTo(1.575)
|
|
84
|
+
expect(cost).toBeCloseTo(12.800004)
|
|
21
85
|
})
|
|
22
86
|
})
|
package/src/data/pricing.ts
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return
|
|
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 {
|
|
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,
|
|
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 {
|
|
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
|
|
30
|
-
skipSession =
|
|
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,
|
|
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 {
|
|
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 {
|
|
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,
|
|
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
|
}
|
package/src/data/sources/grok.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
52
|
-
cacheSavingsUsd: estimateCacheSavings(model,
|
|
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 {
|
|
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:
|
|
44
|
-
cacheSavingsUsd: estimateCacheSavings(model, tokens
|
|
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 ·
|
|
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]
|