@vibe-cafe/vibe-usage 0.14.1 → 0.14.2
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/package.json
CHANGED
|
@@ -14,7 +14,7 @@ import { join } from 'node:path';
|
|
|
14
14
|
// separate from ~/.vibe-usage/state.json, whose hashes are the authoritative
|
|
15
15
|
// record of successful uploads and must remain backward-compatible.
|
|
16
16
|
export const CODEX_CACHE_SCHEMA_VERSION = 1;
|
|
17
|
-
export const CODEX_PARSER_ALGORITHM_VERSION =
|
|
17
|
+
export const CODEX_PARSER_ALGORITHM_VERSION = 5;
|
|
18
18
|
|
|
19
19
|
function hash(value, length = 24) {
|
|
20
20
|
return createHash('sha256').update(value).digest('hex').slice(0, length);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { normalizeUsageRecord } from './codex-usage-record.js';
|
|
2
3
|
|
|
3
4
|
const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('base64url');
|
|
4
5
|
function pick(value, keys) {
|
|
@@ -10,6 +11,7 @@ const usageKeys = ['input_tokens', 'output_tokens', 'cached_input_tokens', 'cach
|
|
|
10
11
|
// Retain only fields consumed by the existing token/timing parser. Full JSON is
|
|
11
12
|
// hashed transiently to identify exact copies; chat/tool text is never retained.
|
|
12
13
|
function accountingRecord(obj, context) {
|
|
14
|
+
obj = normalizeUsageRecord(obj);
|
|
13
15
|
const next = { type: obj.type, timestamp: obj.timestamp };
|
|
14
16
|
const p = obj.payload;
|
|
15
17
|
if (obj.type === 'session_meta' && p) {
|
|
@@ -22,7 +24,7 @@ function accountingRecord(obj, context) {
|
|
|
22
24
|
} else if (obj.type === 'turn_context') {
|
|
23
25
|
next.payload = pick(p, ['model', 'service_tier']);
|
|
24
26
|
} else if (obj.type === 'event_msg' && p) {
|
|
25
|
-
next.payload = pick(p, ['type', 'started_at', 'model']);
|
|
27
|
+
next.payload = pick(p, ['type', 'started_at', 'model', 'usage_record']);
|
|
26
28
|
if (p.type === 'token_count') {
|
|
27
29
|
next._tokenFingerprint = hash(p).slice(0, 16);
|
|
28
30
|
next._segmentContext = { ...context };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Codex writes usage twice for the same request: once as the UI-facing
|
|
2
|
+
// `event_msg` / `token_count`, and once as a durable `token_usage_record`.
|
|
3
|
+
// The UI event can be missing entirely — an interrupted or crashed call never
|
|
4
|
+
// reaches the point where it is emitted — while the durable record still lands,
|
|
5
|
+
// so counting only the UI form under-reports real usage.
|
|
6
|
+
//
|
|
7
|
+
// Rewrite the durable form into the shape the parser already understands, and
|
|
8
|
+
// keep a `usage_record` marker so the UI event that mirrors the same request is
|
|
9
|
+
// not counted a second time when its cumulative total lags.
|
|
10
|
+
export function normalizeUsageRecord(obj) {
|
|
11
|
+
if (obj?.type !== 'token_usage_record') return obj;
|
|
12
|
+
const p = obj.payload;
|
|
13
|
+
const usage = p?.usage;
|
|
14
|
+
const total = p?.thread_token_usage;
|
|
15
|
+
if (!usage || !total || !Number.isFinite(total.total_tokens) || total.total_tokens <= 0) return obj;
|
|
16
|
+
for (const value of [usage.input_tokens, usage.output_tokens, usage.cached_input_tokens ?? 0, usage.reasoning_output_tokens ?? 0]) {
|
|
17
|
+
if (!Number.isFinite(value) || value < 0) return obj;
|
|
18
|
+
}
|
|
19
|
+
return { ...obj, type: 'event_msg', payload: {
|
|
20
|
+
type: 'token_count', usage_record: true,
|
|
21
|
+
info: { last_token_usage: pickUsage(usage), total_token_usage: pickUsage(total) },
|
|
22
|
+
}};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pickUsage(value) {
|
|
26
|
+
const keys = ['input_tokens', 'output_tokens', 'cached_input_tokens', 'cache_read_input_tokens',
|
|
27
|
+
'cache_write_input_tokens', 'reasoning_output_tokens', 'total_tokens'];
|
|
28
|
+
return Object.fromEntries(keys.filter(key => Number.isFinite(value[key])).map(key => [key, value[key]]));
|
|
29
|
+
}
|
package/src/parsers/codex.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeUsageRecord } from './codex-usage-record.js';
|
|
1
2
|
import {
|
|
2
3
|
closeSync,
|
|
3
4
|
createReadStream,
|
|
@@ -139,7 +140,7 @@ async function readSessionHeader(filePath, snapshotSize) {
|
|
|
139
140
|
for await (const line of readLines(filePath, snapshotSize)) {
|
|
140
141
|
if (!line.trim()) continue;
|
|
141
142
|
try {
|
|
142
|
-
const obj = JSON.parse(line);
|
|
143
|
+
const obj = normalizeUsageRecord(JSON.parse(line));
|
|
143
144
|
if (obj.type !== 'session_meta' || !obj.payload) continue;
|
|
144
145
|
const meta = obj.payload;
|
|
145
146
|
return {
|
|
@@ -338,7 +339,7 @@ async function indexSessionFile(filePath, snapshotSize, lines = null) {
|
|
|
338
339
|
for await (const line of (lines ?? readLines(filePath, snapshotSize))) {
|
|
339
340
|
if (!line.trim()) continue;
|
|
340
341
|
try {
|
|
341
|
-
const obj = JSON.parse(line);
|
|
342
|
+
const obj = normalizeUsageRecord(JSON.parse(line));
|
|
342
343
|
parsedRecordCount++;
|
|
343
344
|
|
|
344
345
|
const recordTimestamp = timestampMs(obj.timestamp);
|
|
@@ -635,12 +636,20 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
|
635
636
|
let serviceTier = previousTail?.serviceTier || null;
|
|
636
637
|
let prevTotal = previousTail?.prevTotal || null;
|
|
637
638
|
let prevCumulativeTotal = previousTail?.prevCumulativeTotal ?? null;
|
|
639
|
+
// Durable records and UI counters are separate series whose cumulative totals
|
|
640
|
+
// can diverge after an interrupted call, so each keeps its own baseline.
|
|
641
|
+
let prevRecordTotal = previousTail?.prevRecordTotal ?? null;
|
|
642
|
+
let pendingUsageMirror = previousTail?.pendingUsageMirror ?? null;
|
|
638
643
|
const start = previousTail?.parsedBytes || 0;
|
|
639
644
|
for await (const line of (lines ?? readLines(filePath, snapshotSize, start))) {
|
|
640
645
|
if (!line.trim()) continue;
|
|
641
646
|
try {
|
|
642
|
-
const obj = JSON.parse(line);
|
|
647
|
+
const obj = normalizeUsageRecord(JSON.parse(line));
|
|
643
648
|
parsedRecordIndex++;
|
|
649
|
+
// A resumed or new turn cannot be the delayed mirror of the preceding
|
|
650
|
+
// request, so the marker must not survive a turn boundary.
|
|
651
|
+
if (obj.type === 'session_meta' || obj.type === 'turn_context' || obj.type === 'token_usage_record'
|
|
652
|
+
|| (obj.type === 'event_msg' && isTaskStarted(obj.payload))) pendingUsageMirror = null;
|
|
644
653
|
|
|
645
654
|
// A direct child task boundary covers every copied record, including
|
|
646
655
|
// timing/meta events. The raw-token ordinal covers full-history and
|
|
@@ -717,11 +726,21 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
|
717
726
|
// compaction — and must count as zero, not a second copy of
|
|
718
727
|
// last_token_usage. Guarded to positive totals so builds that leave
|
|
719
728
|
// total_token_usage all-zero can't suppress real usage.
|
|
729
|
+
const isUsageRecord = payload.usage_record === true;
|
|
730
|
+
// The durable record and the UI event describe the same request, but their
|
|
731
|
+
// cumulative counters can diverge after an interrupted call, so the match
|
|
732
|
+
// is on per-request usage. The marker survives appends because the mirror
|
|
733
|
+
// can arrive in a later read.
|
|
734
|
+
const isMirror = !isUsageRecord && pendingUsageMirror && sameRequestUsage(pendingUsageMirror, info.last_token_usage);
|
|
735
|
+
pendingUsageMirror = isUsageRecord ? info.last_token_usage : null;
|
|
720
736
|
const cumulativeTotal = info.total_token_usage?.total_tokens;
|
|
721
737
|
const isDuplicateEmission = typeof cumulativeTotal === 'number'
|
|
722
738
|
&& cumulativeTotal > 0
|
|
723
|
-
&& cumulativeTotal === prevCumulativeTotal;
|
|
724
|
-
if (typeof cumulativeTotal === 'number')
|
|
739
|
+
&& cumulativeTotal === (isUsageRecord ? prevRecordTotal : prevCumulativeTotal);
|
|
740
|
+
if (typeof cumulativeTotal === 'number') {
|
|
741
|
+
if (isUsageRecord) prevRecordTotal = cumulativeTotal;
|
|
742
|
+
else prevCumulativeTotal = cumulativeTotal;
|
|
743
|
+
}
|
|
725
744
|
|
|
726
745
|
// Prefer incremental per-request usage; compute delta from cumulative
|
|
727
746
|
// totals as fallback. Always advance the cumulative baseline, even
|
|
@@ -749,7 +768,7 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
|
749
768
|
// avoids counting the full cumulative total again after a model switch.
|
|
750
769
|
if (curr) prevTotal = { ...curr };
|
|
751
770
|
if (!usage) continue;
|
|
752
|
-
if (isReplayedHistory || isDuplicateEmission) continue;
|
|
771
|
+
if (isReplayedHistory || isDuplicateEmission || isMirror) continue;
|
|
753
772
|
|
|
754
773
|
const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
|
|
755
774
|
if (!timestamp || isNaN(timestamp.getTime())) continue;
|
|
@@ -816,6 +835,8 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
|
816
835
|
serviceTier,
|
|
817
836
|
prevTotal,
|
|
818
837
|
prevCumulativeTotal,
|
|
838
|
+
prevRecordTotal,
|
|
839
|
+
pendingUsageMirror,
|
|
819
840
|
buckets,
|
|
820
841
|
sessionAccumulator,
|
|
821
842
|
guardHash: guard.hash,
|
|
@@ -1171,6 +1192,12 @@ async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
|
|
|
1171
1192
|
return { ...mergeFileResults(results), cache: cacheStats };
|
|
1172
1193
|
}
|
|
1173
1194
|
|
|
1195
|
+
function sameRequestUsage(left, right) {
|
|
1196
|
+
if (!left || !right) return false;
|
|
1197
|
+
return ['input_tokens', 'cached_input_tokens', 'output_tokens', 'reasoning_output_tokens']
|
|
1198
|
+
.every(key => (left[key] || 0) === (right[key] || 0));
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1174
1201
|
export async function parse(options = {}) {
|
|
1175
1202
|
const nativeResult = await parseNativeCodex(options);
|
|
1176
1203
|
return mergeCindyHarnessUsage(nativeResult, readCindyHarnessUsage('codex'));
|