@praeviso/code-env-switch 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,23 +4,36 @@
4
4
  import * as fs from "fs";
5
5
  import * as path from "path";
6
6
  import * as os from "os";
7
- import type { Config, ProfileType } from "../types";
7
+ import type { Config, Profile, ProfileType } from "../types";
8
8
  import { resolvePath } from "../shell/utils";
9
9
  import { normalizeType, inferProfileType, getProfileDisplayName } from "../profile/type";
10
+ import { calculateUsageCost, resolvePricingForProfile } from "./pricing";
10
11
 
11
12
  interface UsageRecord {
12
13
  ts: string;
13
14
  type: string;
14
15
  profileKey: string | null;
15
16
  profileName: string | null;
17
+ model: string | null;
18
+ sessionId?: string | null;
16
19
  inputTokens: number;
17
20
  outputTokens: number;
21
+ cacheReadTokens: number;
22
+ cacheWriteTokens: number;
18
23
  totalTokens: number;
19
24
  }
20
25
 
21
26
  interface UsageTotals {
22
27
  today: number;
23
28
  total: number;
29
+ todayInput: number;
30
+ totalInput: number;
31
+ todayOutput: number;
32
+ totalOutput: number;
33
+ todayCacheRead: number;
34
+ totalCacheRead: number;
35
+ todayCacheWrite: number;
36
+ totalCacheWrite: number;
24
37
  }
25
38
 
26
39
  interface UsageTotalsIndex {
@@ -28,26 +41,44 @@ interface UsageTotalsIndex {
28
41
  byName: Map<string, UsageTotals>;
29
42
  }
30
43
 
44
+ interface UsageCostTotals {
45
+ today: number;
46
+ total: number;
47
+ todayTokens: number;
48
+ totalTokens: number;
49
+ }
50
+
51
+ interface UsageCostIndex {
52
+ byKey: Map<string, UsageCostTotals>;
53
+ byName: Map<string, UsageCostTotals>;
54
+ }
55
+
31
56
  interface UsageStateEntry {
32
57
  mtimeMs: number;
33
58
  size: number;
34
59
  type: ProfileType;
35
60
  inputTokens: number;
36
61
  outputTokens: number;
62
+ cacheReadTokens: number;
63
+ cacheWriteTokens: number;
37
64
  totalTokens: number;
38
65
  startTs: string | null;
39
66
  endTs: string | null;
40
67
  cwd: string | null;
68
+ model?: string | null;
41
69
  }
42
70
 
43
71
  interface UsageSessionEntry {
44
72
  type: ProfileType;
45
73
  inputTokens: number;
46
74
  outputTokens: number;
75
+ cacheReadTokens: number;
76
+ cacheWriteTokens: number;
47
77
  totalTokens: number;
48
78
  startTs: string | null;
49
79
  endTs: string | null;
50
80
  cwd: string | null;
81
+ model?: string | null;
51
82
  }
52
83
 
53
84
  interface UsageStateFile {
@@ -82,19 +113,46 @@ interface ProfileResolveResult {
82
113
  interface SessionStats {
83
114
  inputTokens: number;
84
115
  outputTokens: number;
116
+ cacheReadTokens: number;
117
+ cacheWriteTokens: number;
85
118
  totalTokens: number;
86
119
  startTs: string | null;
87
120
  endTs: string | null;
88
121
  cwd: string | null;
89
122
  sessionId: string | null;
123
+ model: string | null;
90
124
  }
91
125
 
92
126
  interface UsageTotalsInput {
93
127
  inputTokens: number | null;
94
128
  outputTokens: number | null;
129
+ cacheReadTokens: number | null;
130
+ cacheWriteTokens: number | null;
95
131
  totalTokens: number | null;
96
132
  }
97
133
 
134
+ function resolveProfileForRecord(
135
+ config: Config,
136
+ type: string | null,
137
+ record: UsageRecord
138
+ ): Profile | null {
139
+ if (record.profileKey && config.profiles && config.profiles[record.profileKey]) {
140
+ return config.profiles[record.profileKey];
141
+ }
142
+ if (record.profileName && config.profiles) {
143
+ const matches = Object.entries(config.profiles).find(([key, entry]) => {
144
+ const displayName = getProfileDisplayName(key, entry, type || undefined);
145
+ return (
146
+ displayName === record.profileName ||
147
+ entry.name === record.profileName ||
148
+ key === record.profileName
149
+ );
150
+ });
151
+ if (matches) return matches[1];
152
+ }
153
+ return null;
154
+ }
155
+
98
156
  function resolveDefaultConfigDir(configPath: string | null): string {
99
157
  if (configPath) return path.dirname(configPath);
100
158
  return path.join(os.homedir(), ".config", "code-env");
@@ -141,40 +199,175 @@ export function formatTokenCount(value: number | null | undefined): string {
141
199
  return `${(value / 1_000_000_000).toFixed(2)}B`;
142
200
  }
143
201
 
144
- export function buildUsageTotals(records: UsageRecord[]): UsageTotalsIndex {
145
- const byKey = new Map<string, UsageTotals>();
146
- const byName = new Map<string, UsageTotals>();
202
+ function createUsageTotals(): UsageTotals {
203
+ return {
204
+ today: 0,
205
+ total: 0,
206
+ todayInput: 0,
207
+ totalInput: 0,
208
+ todayOutput: 0,
209
+ totalOutput: 0,
210
+ todayCacheRead: 0,
211
+ totalCacheRead: 0,
212
+ todayCacheWrite: 0,
213
+ totalCacheWrite: 0,
214
+ };
215
+ }
216
+
217
+ function createUsageCostTotals(): UsageCostTotals {
218
+ return { today: 0, total: 0, todayTokens: 0, totalTokens: 0 };
219
+ }
220
+
221
+ function toUsageNumber(value: number | null | undefined): number {
222
+ const num = Number(value ?? 0);
223
+ return Number.isFinite(num) ? num : 0;
224
+ }
225
+
226
+ function getTodayWindow(): { startMs: number; endMs: number } {
147
227
  const todayStart = new Date();
148
228
  todayStart.setHours(0, 0, 0, 0);
149
- const todayStartMs = todayStart.getTime();
229
+ const startMs = todayStart.getTime();
150
230
  const tomorrowStart = new Date(todayStart);
151
231
  tomorrowStart.setDate(todayStart.getDate() + 1);
152
- const tomorrowStartMs = tomorrowStart.getTime();
232
+ return { startMs, endMs: tomorrowStart.getTime() };
233
+ }
234
+
235
+ function isTimestampInWindow(
236
+ ts: string,
237
+ startMs: number,
238
+ endMs: number
239
+ ): boolean {
240
+ if (!ts) return false;
241
+ const time = new Date(ts).getTime();
242
+ if (Number.isNaN(time)) return false;
243
+ return time >= startMs && time < endMs;
244
+ }
245
+
246
+ export function buildUsageTotals(records: UsageRecord[]): UsageTotalsIndex {
247
+ const byKey = new Map<string, UsageTotals>();
248
+ const byName = new Map<string, UsageTotals>();
249
+ const { startMs, endMs } = getTodayWindow();
153
250
 
154
251
  const isToday = (ts: string) => {
155
- if (!ts) return false;
156
- const time = new Date(ts).getTime();
157
- if (Number.isNaN(time)) return false;
158
- return time >= todayStartMs && time < tomorrowStartMs;
252
+ return isTimestampInWindow(ts, startMs, endMs);
159
253
  };
160
254
 
161
- const addTotals = (map: Map<string, UsageTotals>, key: string, amount: number, ts: string) => {
255
+ const addTotals = (
256
+ map: Map<string, UsageTotals>,
257
+ key: string,
258
+ amounts: {
259
+ total: number;
260
+ input: number;
261
+ output: number;
262
+ cacheRead: number;
263
+ cacheWrite: number;
264
+ },
265
+ ts: string
266
+ ) => {
162
267
  if (!key) return;
163
- const current = map.get(key) || { today: 0, total: 0 };
164
- current.total += amount;
165
- if (isToday(ts)) current.today += amount;
268
+ const current = map.get(key) || createUsageTotals();
269
+ current.total += amounts.total;
270
+ current.totalInput += amounts.input;
271
+ current.totalOutput += amounts.output;
272
+ current.totalCacheRead += amounts.cacheRead;
273
+ current.totalCacheWrite += amounts.cacheWrite;
274
+ if (isToday(ts)) {
275
+ current.today += amounts.total;
276
+ current.todayInput += amounts.input;
277
+ current.todayOutput += amounts.output;
278
+ current.todayCacheRead += amounts.cacheRead;
279
+ current.todayCacheWrite += amounts.cacheWrite;
280
+ }
166
281
  map.set(key, current);
167
282
  };
168
283
 
169
284
  for (const record of records) {
170
285
  const type = normalizeUsageType(record.type) || "";
171
- const total = Number(record.totalTokens || 0);
286
+ const input = toUsageNumber(record.inputTokens);
287
+ const output = toUsageNumber(record.outputTokens);
288
+ const cacheRead = toUsageNumber(record.cacheReadTokens);
289
+ const cacheWrite = toUsageNumber(record.cacheWriteTokens);
290
+ const computedTotal = input + output + cacheRead + cacheWrite;
291
+ const rawTotal = Number(record.totalTokens ?? computedTotal);
292
+ const total = Number.isFinite(rawTotal)
293
+ ? Math.max(rawTotal, computedTotal)
294
+ : computedTotal;
172
295
  if (!Number.isFinite(total)) continue;
173
296
  if (record.profileKey) {
174
- addTotals(byKey, `${type}||${record.profileKey}`, total, record.ts);
297
+ addTotals(
298
+ byKey,
299
+ `${type}||${record.profileKey}`,
300
+ { total, input, output, cacheRead, cacheWrite },
301
+ record.ts
302
+ );
175
303
  }
176
304
  if (record.profileName) {
177
- addTotals(byName, `${type}||${record.profileName}`, total, record.ts);
305
+ addTotals(
306
+ byName,
307
+ `${type}||${record.profileName}`,
308
+ { total, input, output, cacheRead, cacheWrite },
309
+ record.ts
310
+ );
311
+ }
312
+ }
313
+
314
+ return { byKey, byName };
315
+ }
316
+
317
+ function buildUsageCostIndex(records: UsageRecord[], config: Config): UsageCostIndex {
318
+ const byKey = new Map<string, UsageCostTotals>();
319
+ const byName = new Map<string, UsageCostTotals>();
320
+ const { startMs, endMs } = getTodayWindow();
321
+
322
+ const addCost = (
323
+ map: Map<string, UsageCostTotals>,
324
+ key: string,
325
+ cost: number,
326
+ tokens: number,
327
+ ts: string
328
+ ) => {
329
+ if (!key) return;
330
+ const current = map.get(key) || createUsageCostTotals();
331
+ current.total += cost;
332
+ current.totalTokens += tokens;
333
+ if (isTimestampInWindow(ts, startMs, endMs)) {
334
+ current.today += cost;
335
+ current.todayTokens += tokens;
336
+ }
337
+ map.set(key, current);
338
+ };
339
+
340
+ for (const record of records) {
341
+ const model = normalizeModelValue(record.model);
342
+ if (!model) continue;
343
+ const type = normalizeUsageType(record.type) || "";
344
+ const profile =
345
+ record.profileKey && config.profiles ? config.profiles[record.profileKey] : null;
346
+ const pricing = resolvePricingForProfile(config, profile || null, model);
347
+ if (!pricing) continue;
348
+ const cost = calculateUsageCost(
349
+ {
350
+ totalTokens: record.totalTokens,
351
+ inputTokens: record.inputTokens,
352
+ outputTokens: record.outputTokens,
353
+ cacheReadTokens: record.cacheReadTokens,
354
+ cacheWriteTokens: record.cacheWriteTokens,
355
+ },
356
+ pricing
357
+ );
358
+ if (cost === null || !Number.isFinite(cost)) continue;
359
+ const billedTokens =
360
+ toUsageNumber(record.inputTokens) +
361
+ toUsageNumber(record.outputTokens) +
362
+ toUsageNumber(record.cacheReadTokens) +
363
+ toUsageNumber(record.cacheWriteTokens);
364
+ const billedTotal =
365
+ billedTokens > 0 ? billedTokens : toUsageNumber(record.totalTokens);
366
+ if (record.profileKey) {
367
+ addCost(byKey, `${type}||${record.profileKey}`, cost, billedTotal, record.ts);
368
+ }
369
+ if (record.profileName) {
370
+ addCost(byName, `${type}||${record.profileName}`, cost, billedTotal, record.ts);
178
371
  }
179
372
  }
180
373
 
@@ -189,6 +382,12 @@ function normalizeUsageType(type: string | null | undefined): string | null {
189
382
  return trimmed ? trimmed : null;
190
383
  }
191
384
 
385
+ function normalizeModelValue(value: unknown): string | null {
386
+ if (typeof value !== "string") return null;
387
+ const trimmed = value.trim();
388
+ return trimmed ? trimmed : null;
389
+ }
390
+
192
391
  function buildSessionKey(type: ProfileType | null, sessionId: string): string {
193
392
  const normalized = normalizeUsageType(type || "");
194
393
  return normalized ? `${normalized}::${sessionId}` : sessionId;
@@ -226,6 +425,72 @@ export function readUsageTotalsIndex(
226
425
  return buildUsageTotals(records);
227
426
  }
228
427
 
428
+ export function readUsageCostIndex(
429
+ config: Config,
430
+ configPath: string | null,
431
+ syncUsage: boolean
432
+ ): UsageCostIndex | null {
433
+ const usagePath = getUsagePath(config, configPath);
434
+ if (!usagePath) return null;
435
+ if (syncUsage) {
436
+ syncUsageFromSessions(config, configPath, usagePath);
437
+ }
438
+ const records = readUsageRecords(usagePath);
439
+ if (records.length === 0) return null;
440
+ const costs = buildUsageCostIndex(records, config);
441
+ if (costs.byKey.size === 0 && costs.byName.size === 0) return null;
442
+ return costs;
443
+ }
444
+
445
+ export function readUsageSessionCost(
446
+ config: Config,
447
+ configPath: string | null,
448
+ type: string | null,
449
+ sessionId: string | null,
450
+ syncUsage: boolean
451
+ ): number | null {
452
+ if (!sessionId) return null;
453
+ const usagePath = getUsagePath(config, configPath);
454
+ if (!usagePath) return null;
455
+ if (syncUsage) {
456
+ syncUsageFromSessions(config, configPath, usagePath);
457
+ }
458
+ const records = readUsageRecords(usagePath);
459
+ if (records.length === 0) return null;
460
+ const normalizedType = normalizeUsageType(type);
461
+ let total = 0;
462
+ let hasCost = false;
463
+ for (const record of records) {
464
+ if (!record.sessionId) continue;
465
+ if (record.sessionId !== sessionId) continue;
466
+ if (
467
+ normalizedType &&
468
+ normalizeUsageType(record.type) !== normalizedType
469
+ ) {
470
+ continue;
471
+ }
472
+ const model = normalizeModelValue(record.model);
473
+ if (!model) continue;
474
+ const profile = resolveProfileForRecord(config, normalizedType, record);
475
+ const pricing = resolvePricingForProfile(config, profile, model);
476
+ if (!pricing) continue;
477
+ const cost = calculateUsageCost(
478
+ {
479
+ totalTokens: record.totalTokens,
480
+ inputTokens: record.inputTokens,
481
+ outputTokens: record.outputTokens,
482
+ cacheReadTokens: record.cacheReadTokens,
483
+ cacheWriteTokens: record.cacheWriteTokens,
484
+ },
485
+ pricing
486
+ );
487
+ if (cost === null || !Number.isFinite(cost)) continue;
488
+ total += cost;
489
+ hasCost = true;
490
+ }
491
+ return hasCost ? total : null;
492
+ }
493
+
229
494
  export function resolveUsageTotalsForProfile(
230
495
  totals: UsageTotalsIndex,
231
496
  type: string | null,
@@ -241,6 +506,21 @@ export function resolveUsageTotalsForProfile(
241
506
  );
242
507
  }
243
508
 
509
+ export function resolveUsageCostForProfile(
510
+ costs: UsageCostIndex,
511
+ type: string | null,
512
+ profileKey: string | null,
513
+ profileName: string | null
514
+ ): UsageCostTotals | null {
515
+ const keyLookup = buildUsageLookupKey(type, profileKey);
516
+ const nameLookup = buildUsageLookupKey(type, profileName);
517
+ return (
518
+ (keyLookup && costs.byKey.get(keyLookup)) ||
519
+ (nameLookup && costs.byName.get(nameLookup)) ||
520
+ null
521
+ );
522
+ }
523
+
244
524
  export function syncUsageFromStatuslineInput(
245
525
  config: Config,
246
526
  configPath: string | null,
@@ -249,19 +529,28 @@ export function syncUsageFromStatuslineInput(
249
529
  profileName: string | null,
250
530
  sessionId: string | null,
251
531
  totals: UsageTotalsInput | null,
252
- cwd: string | null
532
+ cwd: string | null,
533
+ model: string | null
253
534
  ): void {
254
535
  if (!sessionId) return;
255
536
  if (!totals) return;
256
537
  if (!profileKey && !profileName) return;
538
+ const resolvedModel = normalizeModelValue(model);
539
+ if (!resolvedModel) return;
257
540
  const normalizedType = normalizeType(type || "");
258
541
  if (!normalizedType) return;
259
542
  const usagePath = getUsagePath(config, configPath);
260
543
  if (!usagePath) return;
261
544
  const inputTokens = toFiniteNumber(totals.inputTokens) ?? 0;
262
545
  const outputTokens = toFiniteNumber(totals.outputTokens) ?? 0;
546
+ const cacheReadTokens = toFiniteNumber(totals.cacheReadTokens) ?? 0;
547
+ const cacheWriteTokens = toFiniteNumber(totals.cacheWriteTokens) ?? 0;
263
548
  const totalTokens =
264
- toFiniteNumber(totals.totalTokens) ?? inputTokens + outputTokens;
549
+ toFiniteNumber(totals.totalTokens) ??
550
+ inputTokens +
551
+ outputTokens +
552
+ cacheReadTokens +
553
+ cacheWriteTokens;
265
554
  if (!Number.isFinite(totalTokens)) return;
266
555
 
267
556
  const statePath = getUsageStatePath(usagePath, config);
@@ -273,16 +562,28 @@ export function syncUsageFromStatuslineInput(
273
562
  const sessions = state.sessions || {};
274
563
  const key = buildSessionKey(normalizedType, sessionId);
275
564
  const prev = sessions[key];
276
- const prevInput = prev ? prev.inputTokens : 0;
277
- const prevOutput = prev ? prev.outputTokens : 0;
278
- const prevTotal = prev ? prev.totalTokens : 0;
565
+ const prevInput = prev ? toUsageNumber(prev.inputTokens) : 0;
566
+ const prevOutput = prev ? toUsageNumber(prev.outputTokens) : 0;
567
+ const prevCacheRead = prev ? toUsageNumber(prev.cacheReadTokens) : 0;
568
+ const prevCacheWrite = prev ? toUsageNumber(prev.cacheWriteTokens) : 0;
569
+ const prevTotal = prev ? toUsageNumber(prev.totalTokens) : 0;
279
570
 
280
571
  let deltaInput = inputTokens - prevInput;
281
572
  let deltaOutput = outputTokens - prevOutput;
573
+ let deltaCacheRead = cacheReadTokens - prevCacheRead;
574
+ let deltaCacheWrite = cacheWriteTokens - prevCacheWrite;
282
575
  let deltaTotal = totalTokens - prevTotal;
283
- if (deltaTotal < 0 || deltaInput < 0 || deltaOutput < 0) {
576
+ if (
577
+ deltaTotal < 0 ||
578
+ deltaInput < 0 ||
579
+ deltaOutput < 0 ||
580
+ deltaCacheRead < 0 ||
581
+ deltaCacheWrite < 0
582
+ ) {
284
583
  deltaInput = inputTokens;
285
584
  deltaOutput = outputTokens;
585
+ deltaCacheRead = cacheReadTokens;
586
+ deltaCacheWrite = cacheWriteTokens;
286
587
  deltaTotal = totalTokens;
287
588
  }
288
589
 
@@ -292,8 +593,12 @@ export function syncUsageFromStatuslineInput(
292
593
  type: normalizedType,
293
594
  profileKey: profileKey || null,
294
595
  profileName: profileName || null,
596
+ model: resolvedModel,
597
+ sessionId,
295
598
  inputTokens: deltaInput,
296
599
  outputTokens: deltaOutput,
600
+ cacheReadTokens: deltaCacheRead,
601
+ cacheWriteTokens: deltaCacheWrite,
297
602
  totalTokens: deltaTotal,
298
603
  };
299
604
  appendUsageRecord(usagePath, record);
@@ -304,10 +609,13 @@ export function syncUsageFromStatuslineInput(
304
609
  type: normalizedType,
305
610
  inputTokens,
306
611
  outputTokens,
612
+ cacheReadTokens,
613
+ cacheWriteTokens,
307
614
  totalTokens,
308
615
  startTs: prev ? prev.startTs : now,
309
616
  endTs: now,
310
617
  cwd: cwd || (prev ? prev.cwd : null),
618
+ model: resolvedModel,
311
619
  };
312
620
  state.sessions = sessions;
313
621
  writeUsageState(statePath, state);
@@ -595,6 +903,66 @@ function updateMinMaxTs(
595
903
  }
596
904
  }
597
905
 
906
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
907
+ return typeof value === "object" && value !== null && !Array.isArray(value);
908
+ }
909
+
910
+ function coerceModelFromValue(value: unknown): string | null {
911
+ if (typeof value === "string") return normalizeModelValue(value);
912
+ if (!isPlainObject(value)) return null;
913
+ return (
914
+ normalizeModelValue(value.displayName) ||
915
+ normalizeModelValue(value.display_name) ||
916
+ normalizeModelValue(value.name) ||
917
+ normalizeModelValue(value.id) ||
918
+ normalizeModelValue(value.model) ||
919
+ normalizeModelValue(value.model_name) ||
920
+ normalizeModelValue(value.model_id) ||
921
+ normalizeModelValue(value.modelId) ||
922
+ null
923
+ );
924
+ }
925
+
926
+ function pickModelFromObject(record: Record<string, unknown>): string | null {
927
+ return (
928
+ coerceModelFromValue(record.model) ||
929
+ normalizeModelValue(record.model_name) ||
930
+ normalizeModelValue(record.modelName) ||
931
+ normalizeModelValue(record.model_id) ||
932
+ normalizeModelValue(record.modelId) ||
933
+ normalizeModelValue(record.model_display_name) ||
934
+ normalizeModelValue(record.modelDisplayName) ||
935
+ null
936
+ );
937
+ }
938
+
939
+ function extractModelFromRecord(record: Record<string, unknown>): string | null {
940
+ const direct = pickModelFromObject(record);
941
+ if (direct) return direct;
942
+ const message = isPlainObject(record.message)
943
+ ? (record.message as Record<string, unknown>)
944
+ : null;
945
+ if (message) {
946
+ const fromMessage = pickModelFromObject(message);
947
+ if (fromMessage) return fromMessage;
948
+ }
949
+ const payload = isPlainObject(record.payload)
950
+ ? (record.payload as Record<string, unknown>)
951
+ : null;
952
+ if (payload) {
953
+ const fromPayload = pickModelFromObject(payload);
954
+ if (fromPayload) return fromPayload;
955
+ const info = isPlainObject(payload.info)
956
+ ? (payload.info as Record<string, unknown>)
957
+ : null;
958
+ if (info) {
959
+ const fromInfo = pickModelFromObject(info);
960
+ if (fromInfo) return fromInfo;
961
+ }
962
+ }
963
+ return null;
964
+ }
965
+
598
966
  function parseCodexSessionFile(filePath: string): SessionStats {
599
967
  const raw = fs.readFileSync(filePath, "utf8");
600
968
  const lines = raw.split(/\r?\n/);
@@ -608,6 +976,7 @@ function parseCodexSessionFile(filePath: string): SessionStats {
608
976
  const tsRange = { start: null as string | null, end: null as string | null };
609
977
  let cwd: string | null = null;
610
978
  let sessionId: string | null = null;
979
+ let model: string | null = null;
611
980
 
612
981
  for (const line of lines) {
613
982
  const trimmed = line.trim();
@@ -615,6 +984,10 @@ function parseCodexSessionFile(filePath: string): SessionStats {
615
984
  try {
616
985
  const parsed = JSON.parse(trimmed);
617
986
  if (!parsed || typeof parsed !== "object") continue;
987
+ if (!model) {
988
+ const candidate = extractModelFromRecord(parsed as Record<string, unknown>);
989
+ if (candidate) model = candidate;
990
+ }
618
991
  if (parsed.timestamp) updateMinMaxTs(tsRange, String(parsed.timestamp));
619
992
  if (!cwd && parsed.type === "session_meta") {
620
993
  const payload = parsed.payload || {};
@@ -667,11 +1040,14 @@ function parseCodexSessionFile(filePath: string): SessionStats {
667
1040
  return {
668
1041
  inputTokens: maxInput,
669
1042
  outputTokens: maxOutput,
1043
+ cacheReadTokens: 0,
1044
+ cacheWriteTokens: 0,
670
1045
  totalTokens: maxTotal,
671
1046
  startTs: tsRange.start,
672
1047
  endTs: tsRange.end,
673
1048
  cwd,
674
1049
  sessionId,
1050
+ model,
675
1051
  };
676
1052
  }
677
1053
 
@@ -681,9 +1057,12 @@ function parseClaudeSessionFile(filePath: string): SessionStats {
681
1057
  let totalTokens = 0;
682
1058
  let inputTokens = 0;
683
1059
  let outputTokens = 0;
1060
+ let cacheReadTokens = 0;
1061
+ let cacheWriteTokens = 0;
684
1062
  const tsRange = { start: null as string | null, end: null as string | null };
685
1063
  let cwd: string | null = null;
686
1064
  let sessionId: string | null = null;
1065
+ let model: string | null = null;
687
1066
 
688
1067
  for (const line of lines) {
689
1068
  const trimmed = line.trim();
@@ -691,6 +1070,10 @@ function parseClaudeSessionFile(filePath: string): SessionStats {
691
1070
  try {
692
1071
  const parsed = JSON.parse(trimmed);
693
1072
  if (!parsed || typeof parsed !== "object") continue;
1073
+ if (!model) {
1074
+ const candidate = extractModelFromRecord(parsed as Record<string, unknown>);
1075
+ if (candidate) model = candidate;
1076
+ }
694
1077
  if (parsed.timestamp) updateMinMaxTs(tsRange, String(parsed.timestamp));
695
1078
  if (!cwd && parsed.cwd) cwd = String(parsed.cwd);
696
1079
  if (!sessionId && parsed.sessionId) {
@@ -705,6 +1088,8 @@ function parseClaudeSessionFile(filePath: string): SessionStats {
705
1088
  const cacheRead = Number(usage.cache_read_input_tokens ?? 0);
706
1089
  if (Number.isFinite(input)) inputTokens += input;
707
1090
  if (Number.isFinite(output)) outputTokens += output;
1091
+ if (Number.isFinite(cacheCreate)) cacheWriteTokens += cacheCreate;
1092
+ if (Number.isFinite(cacheRead)) cacheReadTokens += cacheRead;
708
1093
  totalTokens +=
709
1094
  (Number.isFinite(input) ? input : 0) +
710
1095
  (Number.isFinite(output) ? output : 0) +
@@ -718,11 +1103,14 @@ function parseClaudeSessionFile(filePath: string): SessionStats {
718
1103
  return {
719
1104
  inputTokens,
720
1105
  outputTokens,
1106
+ cacheReadTokens,
1107
+ cacheWriteTokens,
721
1108
  totalTokens,
722
1109
  startTs: tsRange.start,
723
1110
  endTs: tsRange.end,
724
1111
  cwd,
725
1112
  sessionId,
1113
+ model,
726
1114
  };
727
1115
  }
728
1116
 
@@ -827,16 +1215,56 @@ export function readUsageRecords(usagePath: string): UsageRecord[] {
827
1215
  if (!parsed || typeof parsed !== "object") continue;
828
1216
  const input = Number(parsed.inputTokens ?? 0);
829
1217
  const output = Number(parsed.outputTokens ?? 0);
830
- const total = Number(parsed.totalTokens ?? input + output);
1218
+ const cacheRead = Number(
1219
+ parsed.cacheReadTokens ??
1220
+ parsed.cache_read_input_tokens ??
1221
+ parsed.cacheReadInputTokens ??
1222
+ 0
1223
+ );
1224
+ const cacheWrite = Number(
1225
+ parsed.cacheWriteTokens ??
1226
+ parsed.cache_creation_input_tokens ??
1227
+ parsed.cache_write_input_tokens ??
1228
+ parsed.cacheWriteInputTokens ??
1229
+ 0
1230
+ );
1231
+ const computedTotal =
1232
+ (Number.isFinite(input) ? input : 0) +
1233
+ (Number.isFinite(output) ? output : 0) +
1234
+ (Number.isFinite(cacheRead) ? cacheRead : 0) +
1235
+ (Number.isFinite(cacheWrite) ? cacheWrite : 0);
1236
+ const total = Number(
1237
+ parsed.totalTokens ?? computedTotal
1238
+ );
1239
+ const finalTotal = Number.isFinite(total)
1240
+ ? Math.max(total, computedTotal)
1241
+ : computedTotal;
831
1242
  const type = normalizeType(parsed.type) || String(parsed.type ?? "unknown");
1243
+ const model =
1244
+ normalizeModelValue(parsed.model) ||
1245
+ normalizeModelValue(parsed.model_name) ||
1246
+ normalizeModelValue(parsed.modelName) ||
1247
+ normalizeModelValue(parsed.model_id) ||
1248
+ normalizeModelValue(parsed.modelId) ||
1249
+ null;
1250
+ const sessionId =
1251
+ parsed.sessionId ||
1252
+ parsed.session_id ||
1253
+ parsed.sessionID ||
1254
+ parsed.session ||
1255
+ null;
832
1256
  records.push({
833
1257
  ts: String(parsed.ts ?? ""),
834
1258
  type,
835
1259
  profileKey: parsed.profileKey ? String(parsed.profileKey) : null,
836
1260
  profileName: parsed.profileName ? String(parsed.profileName) : null,
1261
+ model,
1262
+ sessionId: sessionId ? String(sessionId) : null,
837
1263
  inputTokens: Number.isFinite(input) ? input : 0,
838
1264
  outputTokens: Number.isFinite(output) ? output : 0,
839
- totalTokens: Number.isFinite(total) ? total : 0,
1265
+ cacheReadTokens: Number.isFinite(cacheRead) ? cacheRead : 0,
1266
+ cacheWriteTokens: Number.isFinite(cacheWrite) ? cacheWrite : 0,
1267
+ totalTokens: Number.isFinite(finalTotal) ? finalTotal : 0,
840
1268
  });
841
1269
  } catch {
842
1270
  // ignore invalid lines
@@ -896,29 +1324,54 @@ export function syncUsageFromSessions(
896
1324
  const sessionKey =
897
1325
  stats.sessionId ? buildSessionKey(type, stats.sessionId) : null;
898
1326
  const sessionPrev = sessionKey ? sessions[sessionKey] : null;
899
- const prevInput = prev ? prev.inputTokens : 0;
900
- const prevOutput = prev ? prev.outputTokens : 0;
901
- const prevTotal = prev ? prev.totalTokens : 0;
1327
+ const resolvedModel =
1328
+ (sessionPrev && sessionPrev.model) ||
1329
+ stats.model ||
1330
+ (prev && prev.model) ||
1331
+ null;
1332
+ const prevInput = prev ? toUsageNumber(prev.inputTokens) : 0;
1333
+ const prevOutput = prev ? toUsageNumber(prev.outputTokens) : 0;
1334
+ const prevCacheRead = prev ? toUsageNumber(prev.cacheReadTokens) : 0;
1335
+ const prevCacheWrite = prev ? toUsageNumber(prev.cacheWriteTokens) : 0;
1336
+ const prevTotal = prev ? toUsageNumber(prev.totalTokens) : 0;
902
1337
  const prevInputMax = sessionPrev
903
- ? Math.max(prevInput, sessionPrev.inputTokens)
1338
+ ? Math.max(prevInput, toUsageNumber(sessionPrev.inputTokens))
904
1339
  : prevInput;
905
1340
  const prevOutputMax = sessionPrev
906
- ? Math.max(prevOutput, sessionPrev.outputTokens)
1341
+ ? Math.max(prevOutput, toUsageNumber(sessionPrev.outputTokens))
907
1342
  : prevOutput;
1343
+ const prevCacheReadMax = sessionPrev
1344
+ ? Math.max(prevCacheRead, toUsageNumber(sessionPrev.cacheReadTokens))
1345
+ : prevCacheRead;
1346
+ const prevCacheWriteMax = sessionPrev
1347
+ ? Math.max(prevCacheWrite, toUsageNumber(sessionPrev.cacheWriteTokens))
1348
+ : prevCacheWrite;
908
1349
  const prevTotalMax = sessionPrev
909
- ? Math.max(prevTotal, sessionPrev.totalTokens)
1350
+ ? Math.max(prevTotal, toUsageNumber(sessionPrev.totalTokens))
910
1351
  : prevTotal;
911
1352
  let deltaInput = stats.inputTokens - prevInputMax;
912
1353
  let deltaOutput = stats.outputTokens - prevOutputMax;
1354
+ let deltaCacheRead = stats.cacheReadTokens - prevCacheReadMax;
1355
+ let deltaCacheWrite = stats.cacheWriteTokens - prevCacheWriteMax;
913
1356
  let deltaTotal = stats.totalTokens - prevTotalMax;
914
- if (deltaTotal < 0 || deltaInput < 0 || deltaOutput < 0) {
1357
+ if (
1358
+ deltaTotal < 0 ||
1359
+ deltaInput < 0 ||
1360
+ deltaOutput < 0 ||
1361
+ deltaCacheRead < 0 ||
1362
+ deltaCacheWrite < 0
1363
+ ) {
915
1364
  if (sessionPrev) {
916
1365
  deltaInput = 0;
917
1366
  deltaOutput = 0;
1367
+ deltaCacheRead = 0;
1368
+ deltaCacheWrite = 0;
918
1369
  deltaTotal = 0;
919
1370
  } else {
920
1371
  deltaInput = stats.inputTokens;
921
1372
  deltaOutput = stats.outputTokens;
1373
+ deltaCacheRead = stats.cacheReadTokens;
1374
+ deltaCacheWrite = stats.cacheWriteTokens;
922
1375
  deltaTotal = stats.totalTokens;
923
1376
  }
924
1377
  }
@@ -928,30 +1381,58 @@ export function syncUsageFromSessions(
928
1381
  type,
929
1382
  profileKey: resolved.match.profileKey,
930
1383
  profileName: resolved.match.profileName,
1384
+ model: resolvedModel,
1385
+ sessionId: stats.sessionId,
931
1386
  inputTokens: deltaInput,
932
1387
  outputTokens: deltaOutput,
1388
+ cacheReadTokens: deltaCacheRead,
1389
+ cacheWriteTokens: deltaCacheWrite,
933
1390
  totalTokens: deltaTotal,
934
1391
  };
935
1392
  appendUsageRecord(usagePath, record);
936
1393
  }
937
1394
  if (sessionKey) {
938
1395
  const nextInput = sessionPrev
939
- ? Math.max(sessionPrev.inputTokens, stats.inputTokens)
1396
+ ? Math.max(
1397
+ toUsageNumber(sessionPrev.inputTokens),
1398
+ stats.inputTokens
1399
+ )
940
1400
  : stats.inputTokens;
941
1401
  const nextOutput = sessionPrev
942
- ? Math.max(sessionPrev.outputTokens, stats.outputTokens)
1402
+ ? Math.max(
1403
+ toUsageNumber(sessionPrev.outputTokens),
1404
+ stats.outputTokens
1405
+ )
943
1406
  : stats.outputTokens;
1407
+ const nextCacheRead = sessionPrev
1408
+ ? Math.max(
1409
+ toUsageNumber(sessionPrev.cacheReadTokens),
1410
+ stats.cacheReadTokens
1411
+ )
1412
+ : stats.cacheReadTokens;
1413
+ const nextCacheWrite = sessionPrev
1414
+ ? Math.max(
1415
+ toUsageNumber(sessionPrev.cacheWriteTokens),
1416
+ stats.cacheWriteTokens
1417
+ )
1418
+ : stats.cacheWriteTokens;
944
1419
  const nextTotal = sessionPrev
945
- ? Math.max(sessionPrev.totalTokens, stats.totalTokens)
1420
+ ? Math.max(
1421
+ toUsageNumber(sessionPrev.totalTokens),
1422
+ stats.totalTokens
1423
+ )
946
1424
  : stats.totalTokens;
947
1425
  sessions[sessionKey] = {
948
1426
  type,
949
1427
  inputTokens: nextInput,
950
1428
  outputTokens: nextOutput,
1429
+ cacheReadTokens: nextCacheRead,
1430
+ cacheWriteTokens: nextCacheWrite,
951
1431
  totalTokens: nextTotal,
952
1432
  startTs: sessionPrev ? sessionPrev.startTs : stats.startTs,
953
1433
  endTs: stats.endTs || (sessionPrev ? sessionPrev.endTs : null),
954
1434
  cwd: stats.cwd || (sessionPrev ? sessionPrev.cwd : null),
1435
+ model: resolvedModel,
955
1436
  };
956
1437
  }
957
1438
  files[filePath] = {
@@ -960,10 +1441,13 @@ export function syncUsageFromSessions(
960
1441
  type,
961
1442
  inputTokens: stats.inputTokens,
962
1443
  outputTokens: stats.outputTokens,
1444
+ cacheReadTokens: stats.cacheReadTokens,
1445
+ cacheWriteTokens: stats.cacheWriteTokens,
963
1446
  totalTokens: stats.totalTokens,
964
1447
  startTs: stats.startTs,
965
1448
  endTs: stats.endTs,
966
1449
  cwd: stats.cwd,
1450
+ model: resolvedModel,
967
1451
  };
968
1452
  };
969
1453