Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 95 additions & 1 deletion apps/ccusage/src/adapter/codex/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { getCodexSessionsPaths } from './paths.ts';
const LEGACY_FALLBACK_MODEL = 'gpt-5';
const CODEX_JSONL_MARKERS = ['turn_context', '"type":"token_count"', '"type": "token_count"'];
const ENCODED_CODEX_EVENT_NUMBER_STRIDE = 5;
const TOKEN_USAGE_EVENT_KEY_SEPARATOR = '\0';

export function parseTokenCountLineFast(_line: string): ParsedTokenCountLine | null {
if (!hasTokenCountPayload(_line)) {
Expand Down Expand Up @@ -122,6 +123,25 @@ function convertToEventUsage(
};
}

function createTokenUsageEventKey(event: TokenUsageEvent): string {
const separator = TOKEN_USAGE_EVENT_KEY_SEPARATOR;
return `${event.timestamp}${separator}${event.model ?? ''}${separator}${event.inputTokens}${separator}${event.cachedInputTokens}${separator}${event.outputTokens}${separator}${event.reasoningOutputTokens}${separator}${event.totalTokens}`;
}

function deduplicateTokenUsageEvents(events: TokenUsageEvent[]): TokenUsageEvent[] {
const seen = new Set<string>();
const deduplicated: TokenUsageEvent[] = [];
for (const event of events) {
const key = createTokenUsageEventKey(event);
if (seen.has(key)) {
continue;
}
seen.add(key);
deduplicated.push(event);
}
return deduplicated;
}

function asRecord(value: unknown): Record<string, unknown> | null {
return value != null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
Expand Down Expand Up @@ -411,7 +431,9 @@ export async function loadTokenUsageEvents(): Promise<TokenUsageEvent[]> {
const directoryEvents = await Promise.all(
getCodexSessionsPaths().map(loadTokenUsageEventsFromDirectory),
);
return directoryEvents.flat().sort((a, b) => compareStrings(a.timestamp, b.timestamp));
return deduplicateTokenUsageEvents(directoryEvents.flat()).sort((a, b) =>
compareStrings(a.timestamp, b.timestamp),
);
}

async function runCodexWorker(data: CodexWorkerData): Promise<void> {
Expand Down Expand Up @@ -734,6 +756,78 @@ if (import.meta.vitest != null) {
{ sessionId: 'b', model: 'gpt-5.2', inputTokens: 20 },
]);
});

it('deduplicates copied branch history across session files', async () => {
const copiedHistory = [
JSON.stringify({
timestamp: '2026-05-12T08:00:00.000Z',
type: 'turn_context',
payload: {
model: 'gpt-5.2',
},
}),
JSON.stringify({
timestamp: '2026-05-12T08:01:00.000Z',
type: 'event_msg',
payload: {
type: 'token_count',
info: {
total_token_usage: {
input_tokens: 1_000,
cached_input_tokens: 100,
output_tokens: 200,
reasoning_output_tokens: 20,
total_tokens: 1_200,
},
},
},
}),
].join('\n');

await using fixture = await createFixture({
sessions: {
'project-parent.jsonl': copiedHistory,
'project-branch.jsonl': [
copiedHistory,
JSON.stringify({
timestamp: '2026-05-12T08:02:00.000Z',
type: 'event_msg',
payload: {
type: 'token_count',
info: {
total_token_usage: {
input_tokens: 1_600,
cached_input_tokens: 300,
output_tokens: 450,
reasoning_output_tokens: 40,
total_tokens: 2_050,
},
},
},
}),
].join('\n'),
},
});
vi.stubEnv('CODEX_HOME', fixture.path);

const events = await loadTokenUsageEvents();
expect(events).toMatchObject([
{
inputTokens: 1_000,
cachedInputTokens: 100,
outputTokens: 200,
totalTokens: 1_200,
},
{
sessionId: 'project-branch',
inputTokens: 600,
cachedInputTokens: 200,
outputTokens: 250,
totalTokens: 850,
},
]);
expect(events).toHaveLength(2);
});
});

describe('getCodexWorkerThreadCount', () => {
Expand Down
Loading