1/**
2 * Usage history for Claude Code: the transcript format of its
3 * `<home>/projects/**.jsonl` session files and where each instance keeps them.
4 *
5 * @module provider/Drivers/claudeUsage
6 */
7
8import type { ClaudeSettings } from "@t3tools/contracts";
9import * as Effect from "effect/Effect";
10import * as Path from "effect/Path";
11
12import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
13import {
14 parseTimestampMs,
15 tokenCount,
16 type ProviderUsageReader,
17 type TranscriptUsageFormat,
18 type UsageRecord,
19} from "@t3tools/provider-core/server/usage";
20import * as HostProcess from "@t3tools/shared/HostProcess";
21import * as ServerConfig from "../../config.ts";
22import { sharedProviderHomePaths } from "../ManagedProviderHome.ts";
23
24/**
25 * Parses one line of a Claude Code transcript.
26 *
27 * T3 Code writes one record per assistant *content block*, and every one of
28 * those records repeats the same complete `usage` object for the parent
29 * message. Summing them overcounts by roughly 2.4x on a real workload, so the
30 * caller must drop repeats by `dedupeKey` and keep the first.
31 */
32export function parseClaudeLine(line: string): UsageRecord | null {
33 let parsed: unknown;
34 try {
35 parsed = JSON.parse(line);
36 } catch {
37 return null;
38 }
39 return parseClaudeRecord(parsed);
40}
41
42function parseClaudeRecord(parsed: unknown): UsageRecord | null {
43 if (typeof parsed !== "object" || parsed === null) return null;
44
45 const record = parsed as Record<string, unknown>;
46 if (record["type"] !== "assistant") return null;
47
48 const message = record["message"];
49 if (typeof message !== "object" || message === null) return null;
50 const messageRecord = message as Record<string, unknown>;
51
52 const usage = messageRecord["usage"];
53 if (typeof usage !== "object" || usage === null) return null;
54 const usageRecord = usage as Record<string, unknown>;
55
56 const timestampMs = parseTimestampMs(record["timestamp"]);
57 if (timestampMs === null) return null;
58
59 const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : "";
60 if (model.length === 0) return null;
61
62 const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null;
63 const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null;
64 // Matches ccusage: prefer the message/request pair, fall back to whichever
65 // half exists. Records with neither cannot be de-duplicated.
66 const dedupeKey =
67 messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`;
68
69 const cost = record["costUSD"];
70
71 return {
72 provider: "claude",
73 timestampMs,
74 model,
75 sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "",
76 totals: {
77 uncachedInputTokens: tokenCount(usageRecord["input_tokens"]),
78 cachedInputTokens: tokenCount(usageRecord["cache_read_input_tokens"]),
79 cacheCreationTokens: tokenCount(usageRecord["cache_creation_input_tokens"]),
80 outputTokens: tokenCount(usageRecord["output_tokens"]),
81 // Anthropic folds thinking tokens into output and does not break them out.
82 reasoningTokens: 0,
83 },
84 reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
85 speed: usageRecord["speed"] === "fast" ? "fast" : "standard",
86 dedupeKey,
87 };
88}
89
90const orEmpty = (record: UsageRecord | null): readonly UsageRecord[] =>
91 record === null ? [] : [record];
92
93export const claudeUsageFormat: TranscriptUsageFormat<void> = {
94 // Keeps the dedupe and cost metadata. The `usage` subtree keeps future token fields.
95 selectFields: {
96 type: true,
97 timestamp: true,
98 requestId: true,
99 sessionId: true,
100 costUSD: true,
101 message: { id: true, model: true, usage: true },
102 },
103 usageMarkers: ['"usage"'],
104 parseLine: (line) => orEmpty(parseClaudeLine(line)),
105 parseProjected: (projected) => orEmpty(parseClaudeRecord(projected)),
106};
107
108export const claudeUsageReader = {
109 kind: "transcripts",
110 provider: "claude",
111 format: claudeUsageFormat,
112 directories: Effect.fn("claudeUsageReader.directories")(function* ({ config, environment }) {
113 // An undecodable config has no trustworthy home to read.
114 if (config === undefined) return [];
115 const path = yield* Path.Path;
116 if (config.sharedAccountId) {
117 const { stateDir } = yield* ServerConfig.ServerConfig;
118 const home = sharedProviderHomePaths(path, stateDir, "claudeAgent", config.sharedAccountId);
119 return [{ dir: path.join(home.shared, "projects") }];
120 }
121 const homeDirectory = yield* HostProcess.HomeDirectory;
122 const configured = config.homePath.trim();
123 const home = configured
124 ? expandHomePath(configured, homeDirectory)
125 : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(homeDirectory, ".claude");
126 return [{ dir: path.resolve(home, "projects") }];
127 }),
128} satisfies ProviderUsageReader<ClaudeSettings, Path.Path | ServerConfig.ServerConfig>;