Skip to content

Commit b1d04ac

Browse files
mm7894215ryoppippi
andauthored
fix(ccusage): dedupe Claude usage without request IDs (#985)
Use message.id as the fallback dedupe key when Claude usage entries do not include requestId. This keeps the existing messageId:requestId key when requestId is present, while allowing third-party Anthropic-compatible and transport paths that omit requestId to avoid counting repeated JSONL writes multiple times. Add regression coverage for both the hash fallback and daily aggregation with duplicated requestId-less entries. Co-authored-by: ryoppippi <1560508+ryoppippi@users.noreply.github.com>
1 parent f53bbb7 commit b1d04ac

1 file changed

Lines changed: 53 additions & 8 deletions

File tree

apps/ccusage/src/adapter/claude/data-loader.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,18 +1173,16 @@ async function filterFilesByMtime<T>(
11731173
}
11741174

11751175
/**
1176-
* Create a unique identifier for deduplication using message ID and request ID
1176+
* Create a unique identifier for deduplication using message ID and request ID.
11771177
*/
11781178
export function createUniqueHash(data: UsageData): string | null {
11791179
const messageId = data.message.id;
1180-
const requestId = data.requestId;
1181-
1182-
if (messageId == null || requestId == null) {
1180+
if (messageId == null) {
11831181
return null;
11841182
}
11851183

1186-
// Create a hash using simple concatenation
1187-
return `${messageId}:${requestId}`;
1184+
const requestId = data.requestId;
1185+
return requestId == null ? messageId : `${messageId}:${requestId}`;
11881186
}
11891187

11901188
async function processJSONLUsageFileByLine(
@@ -6204,7 +6202,7 @@ if (import.meta.vitest != null) {
62046202
expect(hash).toBeNull();
62056203
});
62066204

6207-
it('should return null when request id is missing', () => {
6205+
it('should fall back to message id when request id is missing', () => {
62086206
const data = {
62096207
timestamp: createISOTimestamp('2025-01-10T10:00:00Z'),
62106208
message: {
@@ -6217,7 +6215,7 @@ if (import.meta.vitest != null) {
62176215
};
62186216

62196217
const hash = createUniqueHash(data);
6220-
expect(hash).toBeNull();
6218+
expect(hash).toBe('msg_123');
62216219
});
62226220
});
62236221

@@ -6519,6 +6517,53 @@ if (import.meta.vitest != null) {
65196517
expect(data[0]?.outputTokens).toBe(250);
65206518
expect(data[0]?.totalCost).toBe(0.01);
65216519
});
6520+
6521+
it('deduplicates entries with the same message id when request id is missing', async () => {
6522+
await using fixture = await createFixture({
6523+
projects: {
6524+
project1: {
6525+
session1: {
6526+
'chat.jsonl': [
6527+
JSON.stringify({
6528+
timestamp: '2025-01-10T10:00:00.000Z',
6529+
message: {
6530+
id: 'msg_123',
6531+
model: 'claude-opus-4-6',
6532+
usage: {
6533+
input_tokens: 100,
6534+
output_tokens: 50,
6535+
},
6536+
},
6537+
costUSD: 0.001,
6538+
}),
6539+
JSON.stringify({
6540+
timestamp: '2025-01-10T10:00:01.000Z',
6541+
message: {
6542+
id: 'msg_123',
6543+
model: 'claude-opus-4-6',
6544+
usage: {
6545+
input_tokens: 200,
6546+
output_tokens: 100,
6547+
},
6548+
},
6549+
costUSD: 0.002,
6550+
}),
6551+
].join('\n'),
6552+
},
6553+
},
6554+
},
6555+
});
6556+
6557+
const data = await loadDailyUsageData({
6558+
claudePath: fixture.path,
6559+
mode: 'display',
6560+
});
6561+
6562+
expect(data).toHaveLength(1);
6563+
expect(data[0]?.inputTokens).toBe(200);
6564+
expect(data[0]?.outputTokens).toBe(100);
6565+
expect(data[0]?.totalCost).toBe(0.002);
6566+
});
65226567
});
65236568

65246569
describe('loadSessionData with deduplication', () => {

0 commit comments

Comments
 (0)