Skip to content

Commit 54abc64

Browse files
committed
fix(ccusage): dedupe copied Codex token events
Deduplicate Codex token usage events with a session-independent fingerprint so branched or repeated session files do not count copied history more than once. Add regression coverage for copied branch history and validate against local Codex logs, where the current parser produced thousands of duplicate token events.
1 parent 6e96ff7 commit 54abc64

1 file changed

Lines changed: 101 additions & 1 deletion

File tree

apps/ccusage/src/adapter/codex/parser.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,32 @@ function convertToEventUsage(
122122
};
123123
}
124124

125+
function createTokenUsageEventKey(event: TokenUsageEvent): string {
126+
return JSON.stringify([
127+
event.timestamp,
128+
event.model ?? '',
129+
event.inputTokens,
130+
event.cachedInputTokens,
131+
event.outputTokens,
132+
event.reasoningOutputTokens,
133+
event.totalTokens,
134+
]);
135+
}
136+
137+
function deduplicateTokenUsageEvents(events: TokenUsageEvent[]): TokenUsageEvent[] {
138+
const seen = new Set<string>();
139+
const deduplicated: TokenUsageEvent[] = [];
140+
for (const event of events) {
141+
const key = createTokenUsageEventKey(event);
142+
if (seen.has(key)) {
143+
continue;
144+
}
145+
seen.add(key);
146+
deduplicated.push(event);
147+
}
148+
return deduplicated;
149+
}
150+
125151
function asRecord(value: unknown): Record<string, unknown> | null {
126152
return value != null && typeof value === 'object' && !Array.isArray(value)
127153
? (value as Record<string, unknown>)
@@ -411,7 +437,9 @@ export async function loadTokenUsageEvents(): Promise<TokenUsageEvent[]> {
411437
const directoryEvents = await Promise.all(
412438
getCodexSessionsPaths().map(loadTokenUsageEventsFromDirectory),
413439
);
414-
return directoryEvents.flat().sort((a, b) => compareStrings(a.timestamp, b.timestamp));
440+
return deduplicateTokenUsageEvents(directoryEvents.flat()).sort((a, b) =>
441+
compareStrings(a.timestamp, b.timestamp),
442+
);
415443
}
416444

417445
async function runCodexWorker(data: CodexWorkerData): Promise<void> {
@@ -734,6 +762,78 @@ if (import.meta.vitest != null) {
734762
{ sessionId: 'b', model: 'gpt-5.2', inputTokens: 20 },
735763
]);
736764
});
765+
766+
it('deduplicates copied branch history across session files', async () => {
767+
const copiedHistory = [
768+
JSON.stringify({
769+
timestamp: '2026-05-12T08:00:00.000Z',
770+
type: 'turn_context',
771+
payload: {
772+
model: 'gpt-5.2',
773+
},
774+
}),
775+
JSON.stringify({
776+
timestamp: '2026-05-12T08:01:00.000Z',
777+
type: 'event_msg',
778+
payload: {
779+
type: 'token_count',
780+
info: {
781+
total_token_usage: {
782+
input_tokens: 1_000,
783+
cached_input_tokens: 100,
784+
output_tokens: 200,
785+
reasoning_output_tokens: 20,
786+
total_tokens: 1_200,
787+
},
788+
},
789+
},
790+
}),
791+
].join('\n');
792+
793+
await using fixture = await createFixture({
794+
sessions: {
795+
'project-parent.jsonl': copiedHistory,
796+
'project-branch.jsonl': [
797+
copiedHistory,
798+
JSON.stringify({
799+
timestamp: '2026-05-12T08:02:00.000Z',
800+
type: 'event_msg',
801+
payload: {
802+
type: 'token_count',
803+
info: {
804+
total_token_usage: {
805+
input_tokens: 1_600,
806+
cached_input_tokens: 300,
807+
output_tokens: 450,
808+
reasoning_output_tokens: 40,
809+
total_tokens: 2_050,
810+
},
811+
},
812+
},
813+
}),
814+
].join('\n'),
815+
},
816+
});
817+
vi.stubEnv('CODEX_HOME', fixture.path);
818+
819+
const events = await loadTokenUsageEvents();
820+
expect(events).toMatchObject([
821+
{
822+
inputTokens: 1_000,
823+
cachedInputTokens: 100,
824+
outputTokens: 200,
825+
totalTokens: 1_200,
826+
},
827+
{
828+
sessionId: 'project-branch',
829+
inputTokens: 600,
830+
cachedInputTokens: 200,
831+
outputTokens: 250,
832+
totalTokens: 850,
833+
},
834+
]);
835+
expect(events).toHaveLength(2);
836+
});
737837
});
738838

739839
describe('getCodexWorkerThreadCount', () => {

0 commit comments

Comments
 (0)