| 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 | |
| 8 | import type { ClaudeSettings } from "@t3tools/contracts"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Path from "effect/Path"; |
| 11 | |
| 12 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 13 | import { |
| 14 | parseTimestampMs, |
| 15 | tokenCount, |
| 16 | type ProviderUsageReader, |
| 17 | type TranscriptUsageFormat, |
| 18 | type UsageRecord, |
| 19 | } from "@t3tools/provider-core/server/usage"; |
| 20 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 21 | import * as ServerConfig from "../../config.ts"; |
| 22 | import { 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 | */ |
| 32 | export 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 | |
| 42 | function 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 | |
| 90 | const orEmpty = (record: UsageRecord | null): readonly UsageRecord[] => |
| 91 | record === null ? [] : [record]; |
| 92 | |
| 93 | export 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 | |
| 108 | export 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>; |